Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5c44e1f0b | |||
| 958ebeae61 | |||
| 31f51ca53b | |||
| 5abbf73050 | |||
| 7cf766630d | |||
| 59fccafeac | |||
| 19a6996e6b | |||
| 86c4f90910 | |||
| 4d88112021 | |||
| de5c63e299 | |||
| da0853a121 | |||
| 57cc4b6a29 | |||
| e2ddd7b27a | |||
| 693de8501e | |||
| c6df6d444e | |||
| ad5692c5f8 | |||
| 60ab3337af | |||
| dd847793d2 | |||
| 6d6e9f0dd3 | |||
| 0fe0d7ca6b | |||
| abcf1f1bae | |||
| 7d0b03f754 | |||
| bd8931d3d1 | |||
| 0d86c04939 | |||
| 8436ebbf68 | |||
| 824f94b3df | |||
| f9a8e686b2 | |||
| e442a44ba8 | |||
| f1fd29571a | |||
| 6a482a7ba2 | |||
| bfea434a55 | |||
| acb22c5a64 | |||
| 7cd1b621d1 | |||
| 9c37b25bab | |||
| a8bb9a78ef | |||
| e4aff04061 | |||
| c5cadc7de3 | |||
| 7dc0c0d96a | |||
| 2c9c8c8e05 | |||
| b5d7ae57e5 | |||
| 0f66ee3a41 | |||
| 771372c66b | |||
| a7937681e9 | |||
| 792f238d16 | |||
| ba16416c75 | |||
| 6e0ac52a64 | |||
| 5bc40402a6 | |||
| df14c69977 | |||
| 1ea67ba93d | |||
| a7153d9d1d | |||
| e885057a71 | |||
| 7efa081976 | |||
| 5deb93a083 | |||
| e3908e6fe3 | |||
| fe3d0b5e8b | |||
| b6bb74d08d | |||
| c725f84334 | |||
| 11a3380d83 | |||
| 76a5bb060e | |||
| dac8dda4d4 | |||
| a1a62468a6 | |||
| ba3a723e99 | |||
| c5355c08cf | |||
| 6102380051 | |||
| 655e59a707 | |||
| d05745070b | |||
| 88c313bf86 | |||
| a7705848ec | |||
| 69e1bae8dd | |||
| d361757e4a | |||
| 0ef202f2df | |||
| bbfd2b3cd3 | |||
| 120c8f373c | |||
| 9135ebf2e2 | |||
| af31e9dcc2 | |||
| 50bc7e099a | |||
| 39d17c4a3c | |||
| d34c2a75db | |||
| de95c68d75 | |||
| cdf352d4c3 | |||
| 4712393b62 | |||
| fd48c2cf79 | |||
| 5feacd9a3b | |||
| ec35d5db51 | |||
| ddf088e428 | |||
| adc1e585ff | |||
| 84b47a2ed5 | |||
| 6196fba286 | |||
| 5daff2d3cd | |||
| f1bc90e08a | |||
| aabcdc20fd | |||
| 269fbcc2f3 | |||
| d08179c215 | |||
| 90e739d444 | |||
| 4a9fac2d1e | |||
| 07ebd7c68c | |||
| 92f30f7ed6 | |||
| 605aa554d0 | |||
| bed9fca4a5 | |||
| b9968e2e72 | |||
| ab1a30073c | |||
| 31049d06e8 | |||
| ef8559d060 | |||
| 33bb23d2f3 | |||
| b27ac257e7 | |||
| d2cc76e52e | |||
| cad6097d60 | |||
| 2537b225ac | |||
| 4ee48d2729 | |||
| 68f23d652f | |||
| a078508c05 | |||
| abf4dc9256 | |||
| ba8f12d588 | |||
| d57560db06 | |||
| 0d26049b4e | |||
| f72e9b6ea5 | |||
| 029cfb31b0 | |||
| 3a288eb09c | |||
| 803909abb6 | |||
| 56c75b769c | |||
| eea48d94d3 | |||
| e29152ab30 | |||
| f4aa36b35d | |||
| 2903a763a7 | |||
| 4a980ed5db | |||
| d2e687ed0a | |||
| 0d70690ec8 | |||
| a524eeb66e | |||
| 3d56ceb794 | |||
| 65c32b3319 | |||
| 9e8e047aae | |||
| 64d330a332 | |||
| 1ed1529f96 | |||
| b30066ca19 | |||
| aae8e2fa5d | |||
| 9e6d5fdbcb | |||
| 22b2e10ddb | |||
| d53ac2a61e | |||
| 6a0f800716 | |||
| aee1e2451e | |||
| da94eb1284 | |||
| ea51251fe6 | |||
| 399ce1e01a | |||
| 7df8302ba2 | |||
| af8c59b5bb | |||
| cf0f00285b | |||
| b5c67a384c | |||
| af93cedc08 | |||
| b6a6f2d465 | |||
| 6bcbf8b50a | |||
| 666516d7e9 | |||
| b941c5b661 | |||
| 8f8c85c64e | |||
| 116b0c48da | |||
| aa2971e8c5 | |||
| 5656ac1e3e |
@@ -11,3 +11,12 @@ REACT_APP_WS_SERVER_URL=http://localhost:3002
|
|||||||
REACT_APP_PORTAL_URL=
|
REACT_APP_PORTAL_URL=
|
||||||
|
|
||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||||
|
|
||||||
|
# put these in your .env.local, or make sure you don't commit!
|
||||||
|
# must be lowercase `true` when turned on
|
||||||
|
#
|
||||||
|
# whether to enable Service Workers in development
|
||||||
|
REACT_APP_DEV_ENABLE_SW=
|
||||||
|
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||||
|
# debugging Service Workers.
|
||||||
|
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","
|
|||||||
|
|
||||||
# production-only vars
|
# production-only vars
|
||||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
|
||||||
|
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- lipis
|
|
||||||
assignees:
|
|
||||||
- lipis
|
|
||||||
open-pull-requests-limit: 20
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /src/packages/excalidraw/
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
||||||
open-pull-requests-limit: 20
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /src/packages/utils/
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
||||||
open-pull-requests-limit: 20
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Auto release @excalidraw/excalidraw-next
|
name: Auto release excalidraw next
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Auto release preview @excalidraw/excalidraw-preview
|
name: Auto release excalidraw preview
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
types: [created, edited]
|
types: [created, edited]
|
||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
Auto-release-excalidraw-preview:
|
Auto-release-excalidraw-preview:
|
||||||
name: Auto release preview
|
name: Auto release preview
|
||||||
if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
|
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: React to release comment
|
- name: React to release comment
|
||||||
|
|||||||
+18
-15
@@ -22,23 +22,23 @@
|
|||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "6.2.5",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.16.2",
|
"@testing-library/jest-dom": "5.16.2",
|
||||||
"@testing-library/react": "12.1.2",
|
"@testing-library/react": "12.1.5",
|
||||||
"@tldraw/vec": "1.4.3",
|
"@tldraw/vec": "1.7.1",
|
||||||
"@types/jest": "27.4.0",
|
"@types/jest": "27.4.0",
|
||||||
"@types/pica": "5.1.3",
|
"@types/pica": "5.1.3",
|
||||||
"@types/react": "17.0.39",
|
"@types/react": "17.0.39",
|
||||||
"@types/react-dom": "17.0.11",
|
"@types/react-dom": "17.0.11",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
"browser-fs-access": "0.24.1",
|
"browser-fs-access": "0.29.1",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"fake-indexeddb": "3.1.7",
|
"fake-indexeddb": "3.1.7",
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.2",
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"jotai": "1.6.4",
|
"jotai": "1.6.4",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.32",
|
"nanoid": "3.3.3",
|
||||||
"open-color": "1.9.1",
|
"open-color": "1.9.1",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"perfect-freehand": "1.0.16",
|
"perfect-freehand": "1.0.16",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"roughjs": "4.5.2",
|
"roughjs": "4.5.2",
|
||||||
"sass": "1.49.7",
|
"sass": "1.51.0",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.5.5"
|
"typescript": "4.5.5"
|
||||||
},
|
},
|
||||||
@@ -59,19 +59,19 @@
|
|||||||
"@excalidraw/eslint-config": "1.0.0",
|
"@excalidraw/eslint-config": "1.0.0",
|
||||||
"@excalidraw/prettier-config": "1.0.2",
|
"@excalidraw/prettier-config": "1.0.2",
|
||||||
"@types/chai": "4.3.0",
|
"@types/chai": "4.3.0",
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.7",
|
||||||
"@types/pako": "1.0.3",
|
"@types/pako": "1.0.3",
|
||||||
"@types/resize-observer-browser": "0.1.6",
|
"@types/resize-observer-browser": "0.1.7",
|
||||||
"chai": "4.3.6",
|
"chai": "4.3.6",
|
||||||
"dotenv": "10.0.0",
|
"dotenv": "16.0.1",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-config-prettier": "8.5.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
"jest-canvas-mock": "2.3.1",
|
"jest-canvas-mock": "2.4.0",
|
||||||
"lint-staged": "12.3.7",
|
"lint-staged": "12.3.7",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.5.1",
|
"prettier": "2.6.2",
|
||||||
"rewire": "5.0.0"
|
"rewire": "6.0.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@typescript-eslint/typescript-estree": "5.10.2"
|
"@typescript-eslint/typescript-estree": "5.10.2"
|
||||||
@@ -94,7 +94,8 @@
|
|||||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||||
"build:version": "node ./scripts/build-version.js",
|
"build:version": "node ./scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build:prebuild": "node ./scripts/prebuild.js",
|
||||||
|
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"fix:code": "yarn test:code --fix",
|
"fix:code": "yarn test:code --fix",
|
||||||
"fix:other": "yarn prettier --write",
|
"fix:other": "yarn prettier --write",
|
||||||
@@ -112,6 +113,8 @@
|
|||||||
"test:typecheck": "tsc",
|
"test:typecheck": "tsc",
|
||||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||||
"test": "yarn test:app",
|
"test": "yarn test:app",
|
||||||
"autorelease": "node scripts/autorelease.js"
|
"autorelease": "node scripts/autorelease.js",
|
||||||
|
"prerelease": "node scripts/prerelease.js",
|
||||||
|
"release": "node scripts/release.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,25 @@
|
|||||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
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" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
|
||||||
<!-- Excalidraw version -->
|
<!-- Excalidraw version -->
|
||||||
@@ -79,6 +98,22 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||||
|
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
|
||||||
|
<script>
|
||||||
|
{
|
||||||
|
const _WebSocket = window.WebSocket;
|
||||||
|
window.WebSocket = function (url) {
|
||||||
|
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
|
||||||
|
console.info(
|
||||||
|
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return new _WebSocket(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
<script>
|
<script>
|
||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||||
// setting this so that libraries installation reuses this window tab.
|
// setting this so that libraries installation reuses this window tab.
|
||||||
|
|||||||
@@ -17,11 +17,23 @@
|
|||||||
* See https://goo.gl/2aRDsh
|
* See https://goo.gl/2aRDsh
|
||||||
*/
|
*/
|
||||||
|
|
||||||
importScripts("/workbox/workbox-sw.js");
|
// in dev, `process` is undefined because this file is not compiled until build
|
||||||
|
const IS_DEVELOPMENT =
|
||||||
|
typeof process === "undefined" || process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
workbox.setConfig({
|
if (IS_DEVELOPMENT) {
|
||||||
modulePathPrefix: "/workbox/",
|
importScripts(
|
||||||
});
|
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
|
||||||
|
);
|
||||||
|
workbox.setConfig({
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
importScripts("/workbox/workbox-sw.js");
|
||||||
|
workbox.setConfig({
|
||||||
|
modulePathPrefix: "/workbox/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("message", (event) => {
|
self.addEventListener("message", (event) => {
|
||||||
if (event.data && event.data.type === "SKIP_WAITING") {
|
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||||
@@ -30,14 +42,17 @@ self.addEventListener("message", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
workbox.core.clientsClaim();
|
workbox.core.clientsClaim();
|
||||||
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
|
||||||
|
|
||||||
workbox.routing.registerNavigationRoute(
|
if (!IS_DEVELOPMENT) {
|
||||||
workbox.precaching.getCacheKeyForURL("./index.html"),
|
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
||||||
{
|
|
||||||
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
|
workbox.routing.registerNavigationRoute(
|
||||||
},
|
workbox.precaching.getCacheKeyForURL("./index.html"),
|
||||||
);
|
{
|
||||||
|
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache relevant font files
|
// Cache relevant font files
|
||||||
workbox.routing.registerRoute(
|
workbox.routing.registerRoute(
|
||||||
+6
-11
@@ -5,22 +5,25 @@ const core = require("@actions/core");
|
|||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
const pkg = require(excalidrawPackage);
|
const pkg = require(excalidrawPackage);
|
||||||
|
const isPreview = process.argv.slice(2)[0] === "preview";
|
||||||
|
|
||||||
const getShortCommitHash = () => {
|
const getShortCommitHash = () => {
|
||||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const publish = () => {
|
const publish = () => {
|
||||||
|
const tag = isPreview ? "preview" : "next";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`yarn --frozen-lockfile`);
|
execSync(`yarn --frozen-lockfile`);
|
||||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
|
||||||
console.info("Published 🎉");
|
console.info(`Published ${pkg.name}@${tag}🎉`);
|
||||||
core.setOutput(
|
core.setOutput(
|
||||||
"result",
|
"result",
|
||||||
`**Preview version has been shipped** :rocket:
|
`**Preview version has been shipped** :rocket:
|
||||||
You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
|
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
core.setOutput("result", "package couldn't be published :warning:!");
|
core.setOutput("result", "package couldn't be published :warning:!");
|
||||||
@@ -51,27 +54,19 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update package.json
|
// update package.json
|
||||||
pkg.name = "@excalidraw/excalidraw-next";
|
|
||||||
let version = `${pkg.version}-${getShortCommitHash()}`;
|
let version = `${pkg.version}-${getShortCommitHash()}`;
|
||||||
|
|
||||||
// update readme
|
// update readme
|
||||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
|
|
||||||
const isPreview = process.argv.slice(2)[0] === "preview";
|
|
||||||
if (isPreview) {
|
if (isPreview) {
|
||||||
// use pullNumber-commithash as the version for preview
|
// use pullNumber-commithash as the version for preview
|
||||||
const pullRequestNumber = process.argv.slice(3)[0];
|
const pullRequestNumber = process.argv.slice(3)[0];
|
||||||
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
||||||
// replace "excalidraw-next" with "excalidraw-preview"
|
|
||||||
pkg.name = "@excalidraw/excalidraw-preview";
|
|
||||||
data = data.replace(/excalidraw-next/g, "excalidraw-preview");
|
|
||||||
data = data.trim();
|
|
||||||
}
|
}
|
||||||
pkg.version = version;
|
pkg.version = version;
|
||||||
|
|
||||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||||
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
|
||||||
console.info("Publish in progress...");
|
console.info("Publish in progress...");
|
||||||
publish();
|
publish();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const crowdinMap = {
|
|||||||
"ru-RU": "en-ru",
|
"ru-RU": "en-ru",
|
||||||
"si-LK": "en-silk",
|
"si-LK": "en-silk",
|
||||||
"sk-SK": "en-sk",
|
"sk-SK": "en-sk",
|
||||||
|
"sl-SI": "en-sl",
|
||||||
"sv-SE": "en-sv",
|
"sv-SE": "en-sv",
|
||||||
"ta-IN": "en-ta",
|
"ta-IN": "en-ta",
|
||||||
"tr-TR": "en-tr",
|
"tr-TR": "en-tr",
|
||||||
@@ -47,6 +48,8 @@ const crowdinMap = {
|
|||||||
"lv-LV": "en-lv",
|
"lv-LV": "en-lv",
|
||||||
"cs-CZ": "en-cs",
|
"cs-CZ": "en-cs",
|
||||||
"kk-KZ": "en-kk",
|
"kk-KZ": "en-kk",
|
||||||
|
"vi-vn": "en-vi",
|
||||||
|
"mr-in": "en-mr",
|
||||||
};
|
};
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
@@ -86,6 +89,7 @@ const flags = {
|
|||||||
"ru-RU": "🇷🇺",
|
"ru-RU": "🇷🇺",
|
||||||
"si-LK": "🇱🇰",
|
"si-LK": "🇱🇰",
|
||||||
"sk-SK": "🇸🇰",
|
"sk-SK": "🇸🇰",
|
||||||
|
"sl-SI": "🇸🇮",
|
||||||
"sv-SE": "🇸🇪",
|
"sv-SE": "🇸🇪",
|
||||||
"ta-IN": "🇮🇳",
|
"ta-IN": "🇮🇳",
|
||||||
"tr-TR": "🇹🇷",
|
"tr-TR": "🇹🇷",
|
||||||
@@ -93,6 +97,9 @@ const flags = {
|
|||||||
"zh-CN": "🇨🇳",
|
"zh-CN": "🇨🇳",
|
||||||
"zh-HK": "🇭🇰",
|
"zh-HK": "🇭🇰",
|
||||||
"zh-TW": "🇹🇼",
|
"zh-TW": "🇹🇼",
|
||||||
|
"eu-ES": "🇪🇦",
|
||||||
|
"vi-VN": "🇻🇳",
|
||||||
|
"mr-IN": "🇮🇳",
|
||||||
};
|
};
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
@@ -133,6 +140,7 @@ const languages = {
|
|||||||
"ru-RU": "Русский",
|
"ru-RU": "Русский",
|
||||||
"si-LK": "සිංහල",
|
"si-LK": "සිංහල",
|
||||||
"sk-SK": "Slovenčina",
|
"sk-SK": "Slovenčina",
|
||||||
|
"sl-SI": "Slovenščina",
|
||||||
"sv-SE": "Svenska",
|
"sv-SE": "Svenska",
|
||||||
"ta-IN": "Tamil",
|
"ta-IN": "Tamil",
|
||||||
"tr-TR": "Türkçe",
|
"tr-TR": "Türkçe",
|
||||||
@@ -140,6 +148,8 @@ const languages = {
|
|||||||
"zh-CN": "简体中文",
|
"zh-CN": "简体中文",
|
||||||
"zh-HK": "繁體中文 (香港)",
|
"zh-HK": "繁體中文 (香港)",
|
||||||
"zh-TW": "繁體中文",
|
"zh-TW": "繁體中文",
|
||||||
|
"vi-VN": "Tiếng Việt",
|
||||||
|
"mr-IN": "मराठी",
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentages = fs.readFileSync(
|
const percentages = fs.readFileSync(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
// for development purposes we want to have the service-worker.js file
|
||||||
|
// accessible from the public folder. On build though, we need to compile it
|
||||||
|
// and CRA expects that file to be in src/ folder.
|
||||||
|
const moveServiceWorkerScript = () => {
|
||||||
|
const oldPath = "./public/service-worker.js";
|
||||||
|
const newPath = "./src/service-worker.js";
|
||||||
|
|
||||||
|
fs.rename(oldPath, newPath, (error) => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.info("public/service-worker.js moved to src/");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
moveServiceWorkerScript();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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);
|
||||||
+30
-25
@@ -1,39 +1,44 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const util = require("util");
|
const { execSync } = require("child_process");
|
||||||
const exec = util.promisify(require("child_process").exec);
|
|
||||||
const updateReadme = require("./updateReadme");
|
|
||||||
const updateChangelog = require("./updateChangelog");
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
|
const pkg = require(excalidrawPackage);
|
||||||
|
|
||||||
const updatePackageVersion = (nextVersion) => {
|
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
pkg.version = nextVersion;
|
const updateReadme = () => {
|
||||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
|
||||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
|
||||||
|
// remove note for stable readme
|
||||||
|
const data = originalReadMe.slice(excalidrawIndex);
|
||||||
|
|
||||||
|
// update readme
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||||
};
|
};
|
||||||
|
|
||||||
const release = async (nextVersion) => {
|
const publish = () => {
|
||||||
try {
|
try {
|
||||||
updateReadme();
|
execSync(`yarn --frozen-lockfile`);
|
||||||
await updateChangelog(nextVersion);
|
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||||
updatePackageVersion(nextVersion);
|
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||||
await exec(`git add -u`);
|
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||||
await exec(
|
|
||||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
|
||||||
);
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
console.log("Done!");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextVersion = process.argv.slice(2)[0];
|
const release = () => {
|
||||||
if (!nextVersion) {
|
updateReadme();
|
||||||
console.error("Pass the next version to release!");
|
console.info("Note for stable readme removed");
|
||||||
process.exit(1);
|
|
||||||
}
|
publish();
|
||||||
release(nextVersion);
|
console.info(`Published ${pkg.version}!`);
|
||||||
|
|
||||||
|
// revert readme after release
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
|
||||||
|
console.info("Readme reverted");
|
||||||
|
};
|
||||||
|
|
||||||
|
release();
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const updateReadme = () => {
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
|
|
||||||
// remove note for unstable release
|
|
||||||
data = data.replace(
|
|
||||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
// replace "excalidraw-next" with "excalidraw"
|
|
||||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
|
||||||
data = data.trim();
|
|
||||||
|
|
||||||
const demoIndex = data.indexOf("### Demo");
|
|
||||||
const excalidrawNextNote =
|
|
||||||
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
|
||||||
// Add excalidraw next note to try out for unreleased changes
|
|
||||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
|
||||||
|
|
||||||
// update readme
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = updateReadme;
|
|
||||||
@@ -25,9 +25,9 @@ export const actionAddToLibrary = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return app.library
|
return app.library
|
||||||
.loadLibrary()
|
.getLatestLibrary()
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
return app.library.saveLibrary([
|
return app.library.setLibrary([
|
||||||
{
|
{
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
status: "unpublished",
|
status: "unpublished",
|
||||||
@@ -42,7 +42,7 @@ export const actionAddToLibrary = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toastMessage: t("toast.addedToLibrary"),
|
toast: { message: t("toast.addedToLibrary") },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
|
|||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
@@ -304,21 +304,28 @@ export const actionErase = register({
|
|||||||
name: "eraser",
|
name: "eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState) => {
|
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 {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
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,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export const actionCopy = register({
|
|||||||
name: "copy",
|
name: "copy",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
|
|
||||||
|
copyToClipboard(selectedElements, appState, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -105,14 +107,16 @@ export const actionCopyAsPng = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toastMessage: t("toast.copyToClipboardAsPng", {
|
toast: {
|
||||||
exportSelection: selectedElements.length
|
message: t("toast.copyToClipboardAsPng", {
|
||||||
? t("toast.selection")
|
exportSelection: selectedElements.length
|
||||||
: t("toast.canvas"),
|
? t("toast.selection")
|
||||||
exportColorScheme: appState.exportWithDarkMode
|
: t("toast.canvas"),
|
||||||
? t("buttons.darkMode")
|
exportColorScheme: appState.exportWithDarkMode
|
||||||
: t("buttons.lightMode"),
|
? t("buttons.darkMode")
|
||||||
}),
|
: t("buttons.lightMode"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getElementsInGroup } from "../groups";
|
|||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
import { updateActiveTool } from "../utils";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -134,7 +135,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: { ...appState.activeTool, type: "selection" },
|
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: isSomeElementSelected(
|
commitToHistory: isSomeElementSelected(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
import { distributeElements, Distribution } from "../distribute";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|||||||
@@ -128,12 +128,15 @@ const duplicateElements = (
|
|||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
selectedElementIds: newElements.reduce(
|
||||||
if (!isBoundToContainer(element)) {
|
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
acc[element.id] = true;
|
if (!isBoundToContainer(element)) {
|
||||||
}
|
acc[element.id] = true;
|
||||||
return acc;
|
}
|
||||||
}, {} as any),
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
getNonDeletedElements(finalElements),
|
getNonDeletedElements(finalElements),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
|
|||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
import { resaveAsImageWithScene } from "../data/resave";
|
import { resaveAsImageWithScene } from "../data/resave";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDeviceType } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
import { CheckboxItem } from "../components/CheckboxItem";
|
||||||
@@ -144,13 +144,15 @@ export const actionSaveToActiveFile = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
toastMessage: fileHandleExists
|
toast: fileHandleExists
|
||||||
? fileHandle?.name
|
? {
|
||||||
? t("toast.fileSavedToFilename").replace(
|
message: fileHandle?.name
|
||||||
"{filename}",
|
? t("toast.fileSavedToFilename").replace(
|
||||||
`"${fileHandle.name}"`,
|
"{filename}",
|
||||||
)
|
`"${fileHandle.name}"`,
|
||||||
: t("toast.fileSaved")
|
)
|
||||||
|
: t("toast.fileSaved"),
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -204,7 +206,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
icon={saveAs}
|
icon={saveAs}
|
||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useDeviceType().isMobile}
|
showAriaLabel={useDevice().isMobile}
|
||||||
hidden={!nativeFileSystemSupported}
|
hidden={!nativeFileSystemSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
@@ -248,7 +250,7 @@ export const actionLoadScene = register({
|
|||||||
icon={load}
|
icon={load}
|
||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useDeviceType().isMobile}
|
showAriaLabel={useDevice().isMobile}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
data-testid="load-button"
|
data-testid="load-button"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { resetCursor } from "../utils";
|
import { updateActiveTool, resetCursor } from "../utils";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -14,11 +14,12 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement } from "../element/typeChecks";
|
import { isBindingElement } from "../element/typeChecks";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.editingLinearElement;
|
appState.editingLinearElement;
|
||||||
@@ -49,8 +50,12 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
if (appState.pendingImageElement) {
|
const pendingImageElement =
|
||||||
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
appState.pendingImageElementId &&
|
||||||
|
scene.getElement(appState.pendingImageElementId);
|
||||||
|
|
||||||
|
if (pendingImageElement) {
|
||||||
|
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
@@ -137,6 +142,20 @@ export const actionFinalize = register({
|
|||||||
resetCursor(canvas);
|
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 {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -147,14 +166,7 @@ export const actionFinalize = register({
|
|||||||
appState.activeTool.type === "freedraw") &&
|
appState.activeTool.type === "freedraw") &&
|
||||||
multiPointElement
|
multiPointElement
|
||||||
? appState.activeTool
|
? appState.activeTool
|
||||||
: {
|
: activeTool,
|
||||||
...appState.activeTool,
|
|
||||||
type:
|
|
||||||
appState.activeTool.type === "eraser" &&
|
|
||||||
appState.activeTool.lastActiveToolBeforeEraser
|
|
||||||
? appState.activeTool.lastActiveToolBeforeEraser
|
|
||||||
: "selection",
|
|
||||||
},
|
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
@@ -169,7 +181,7 @@ export const actionFinalize = register({
|
|||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
pendingImageElement: null,
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.activeTool.type === "freedraw",
|
commitToHistory: appState.activeTool.type === "freedraw",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getClientColors, getClientInitials } from "../clients";
|
import { getClientColors } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { Collaborator } from "../types";
|
import { Collaborator } from "../types";
|
||||||
@@ -31,28 +31,18 @@ export const actionGoToCollaborator = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, data }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const clientId: string | undefined = data?.id;
|
const [clientId, collaborator] = data as [string, Collaborator];
|
||||||
if (!clientId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collaborator = appState.collaborators.get(clientId);
|
|
||||||
|
|
||||||
if (!collaborator) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { background, stroke } = getClientColors(clientId, appState);
|
const { background, stroke } = getClientColors(clientId, appState);
|
||||||
const shortName = getClientInitials(collaborator.username);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
color={background}
|
color={background}
|
||||||
border={stroke}
|
border={stroke}
|
||||||
onClick={() => updateData(collaborator.pointer)}
|
onClick={() => updateData(collaborator.pointer)}
|
||||||
>
|
name={collaborator.username || ""}
|
||||||
{shortName}
|
src={collaborator.avatarUrl}
|
||||||
</Avatar>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -485,10 +485,14 @@ export const actionChangeOpacity = register({
|
|||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(
|
||||||
newElementWith(el, {
|
elements,
|
||||||
opacity: value,
|
appState,
|
||||||
}),
|
(el) =>
|
||||||
|
newElementWith(el, {
|
||||||
|
opacity: value,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemOpacity: value },
|
appState: { ...appState, currentItemOpacity: value },
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -503,20 +507,6 @@ export const actionChangeOpacity = register({
|
|||||||
max="100"
|
max="100"
|
||||||
step="10"
|
step="10"
|
||||||
onChange={(event) => updateData(+event.target.value)}
|
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={
|
value={
|
||||||
getFormValue(
|
getFormValue(
|
||||||
elements,
|
elements,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { KEYS } from "../keys";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
import { getNonDeletedElements, isTextElement } from "../element";
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
@@ -15,16 +16,19 @@ export const actionSelectAll = register({
|
|||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
selectedElementIds: elements.reduce(
|
||||||
if (
|
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
!element.isDeleted &&
|
if (
|
||||||
!(isTextElement(element) && element.containerId) &&
|
!element.isDeleted &&
|
||||||
element.locked === false
|
!(isTextElement(element) && element.containerId) &&
|
||||||
) {
|
!element.locked
|
||||||
map[element.id] = true;
|
) {
|
||||||
}
|
map[element.id] = true;
|
||||||
return map;
|
}
|
||||||
}, {} as any),
|
return map;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe("actionStyles", () => {
|
|||||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||||
Keyboard.codeDown(CODES.C);
|
Keyboard.codeDown(CODES.C);
|
||||||
});
|
});
|
||||||
const secondRect = JSON.parse(copiedStyles);
|
const secondRect = JSON.parse(copiedStyles)[0];
|
||||||
expect(secondRect.id).toBe(h.elements[1].id);
|
expect(secondRect.id).toBe(h.elements[1].id);
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
|
|||||||
+62
-23
@@ -6,13 +6,15 @@ import {
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getContainerElement } from "../element/textElement";
|
import { getBoundTextElement } from "../element/textElement";
|
||||||
|
import { hasBoundTextElement } from "../element/typeChecks";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
@@ -21,14 +23,20 @@ export const actionCopyStyles = register({
|
|||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
|
const elementsCopied = [];
|
||||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||||
|
elementsCopied.push(element);
|
||||||
|
if (element && hasBoundTextElement(element)) {
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
elementsCopied.push(boundTextElement);
|
||||||
|
}
|
||||||
if (element) {
|
if (element) {
|
||||||
copiedStyles = JSON.stringify(element);
|
copiedStyles = JSON.stringify(elementsCopied);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toastMessage: t("toast.copyStyles"),
|
toast: { message: t("toast.copyStyles") },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -42,31 +50,62 @@ export const actionPasteStyles = register({
|
|||||||
name: "pasteStyles",
|
name: "pasteStyles",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const pastedElement = JSON.parse(copiedStyles);
|
const elementsCopied = JSON.parse(copiedStyles);
|
||||||
|
const pastedElement = elementsCopied[0];
|
||||||
|
const boundTextElement = elementsCopied[1];
|
||||||
if (!isExcalidrawElement(pastedElement)) {
|
if (!isExcalidrawElement(pastedElement)) {
|
||||||
return { elements, commitToHistory: false };
|
return { elements, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
|
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) => {
|
elements: elements.map((element) => {
|
||||||
if (appState.selectedElementIds[element.id]) {
|
if (selectedElementIds.includes(element.id)) {
|
||||||
const newElement = newElementWith(element, {
|
let elementStylesToCopyFrom = pastedElement;
|
||||||
backgroundColor: pastedElement?.backgroundColor,
|
if (isTextElement(element) && element.containerId) {
|
||||||
strokeWidth: pastedElement?.strokeWidth,
|
elementStylesToCopyFrom = boundTextElement;
|
||||||
strokeColor: pastedElement?.strokeColor,
|
|
||||||
strokeStyle: pastedElement?.strokeStyle,
|
|
||||||
fillStyle: pastedElement?.fillStyle,
|
|
||||||
opacity: pastedElement?.opacity,
|
|
||||||
roughness: pastedElement?.roughness,
|
|
||||||
});
|
|
||||||
if (isTextElement(newElement) && isTextElement(element)) {
|
|
||||||
mutateElement(newElement, {
|
|
||||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
|
||||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
|
||||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
|
||||||
});
|
|
||||||
|
|
||||||
redrawTextBoundingBox(newElement, getContainerElement(newElement));
|
|
||||||
}
|
}
|
||||||
|
if (!elementStylesToCopyFrom) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
let newElement = newElementWith(element, {
|
||||||
|
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||||
|
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||||
|
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||||
|
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||||
|
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||||
|
opacity: elementStylesToCopyFrom?.opacity,
|
||||||
|
roughness: elementStylesToCopyFrom?.roughness,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isTextElement(newElement)) {
|
||||||
|
newElement = newElementWith(newElement, {
|
||||||
|
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
||||||
|
fontFamily:
|
||||||
|
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||||
|
textAlign:
|
||||||
|
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
|
});
|
||||||
|
let container = null;
|
||||||
|
if (newElement.containerId) {
|
||||||
|
container =
|
||||||
|
selectedElements.find(
|
||||||
|
(element) =>
|
||||||
|
isTextElement(newElement) &&
|
||||||
|
element.id === newElement.containerId,
|
||||||
|
) || null;
|
||||||
|
}
|
||||||
|
redrawTextBoundingBox(newElement, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newElement.type === "arrow") {
|
||||||
|
newElement = newElementWith(newElement, {
|
||||||
|
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||||
|
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const trackAction = (
|
|||||||
trackEvent(
|
trackEvent(
|
||||||
action.trackEvent.category,
|
action.trackEvent.category,
|
||||||
action.trackEvent.action || action.name,
|
action.trackEvent.action || action.name,
|
||||||
`${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
|
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ExcalidrawProps,
|
ExcalidrawProps,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { ToolButtonSize } from "../components/ToolButton";
|
|
||||||
|
|
||||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ export type PanelComponentProps = {
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
appProps: ExcalidrawProps;
|
appProps: ExcalidrawProps;
|
||||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
data?: Record<string, any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
|
|||||||
+8
-5
@@ -43,6 +43,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
activeTool: {
|
activeTool: {
|
||||||
type: "selection",
|
type: "selection",
|
||||||
|
customType: null,
|
||||||
locked: false,
|
locked: false,
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
},
|
},
|
||||||
@@ -57,6 +58,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
gridSize: null,
|
gridSize: null,
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
isLibraryOpen: false,
|
isLibraryOpen: false,
|
||||||
|
isLibraryMenuDocked: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
isRotating: false,
|
isRotating: false,
|
||||||
@@ -79,14 +81,14 @@ export const getDefaultAppState = (): Omit<
|
|||||||
showStats: false,
|
showStats: false,
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
toastMessage: null,
|
toast: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: {
|
zoom: {
|
||||||
value: 1 as NormalizedZoomValue,
|
value: 1 as NormalizedZoomValue,
|
||||||
},
|
},
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElement: null,
|
pendingImageElementId: null,
|
||||||
showHyperlinkPopup: false,
|
showHyperlinkPopup: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -145,7 +147,8 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
gridSize: { browser: true, export: true, server: true },
|
gridSize: { browser: true, export: true, server: true },
|
||||||
height: { browser: false, export: false, server: false },
|
height: { browser: false, export: false, server: false },
|
||||||
isBindingEnabled: { browser: false, export: false, server: false },
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
isLibraryOpen: { browser: false, export: false, server: false },
|
isLibraryOpen: { browser: true, export: false, server: false },
|
||||||
|
isLibraryMenuDocked: { browser: true, export: false, server: false },
|
||||||
isLoading: { browser: false, export: false, server: false },
|
isLoading: { browser: false, export: false, server: false },
|
||||||
isResizing: { browser: false, export: false, server: false },
|
isResizing: { browser: false, export: false, server: false },
|
||||||
isRotating: { browser: false, export: false, server: false },
|
isRotating: { browser: false, export: false, server: false },
|
||||||
@@ -170,13 +173,13 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
showStats: { browser: true, export: false, server: false },
|
showStats: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false, server: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false, server: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
toastMessage: { browser: false, export: false, server: false },
|
toast: { browser: false, export: false, server: false },
|
||||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||||
width: { browser: false, export: false, server: false },
|
width: { browser: false, export: false, server: false },
|
||||||
zenModeEnabled: { browser: true, export: false, server: false },
|
zenModeEnabled: { browser: true, export: false, server: false },
|
||||||
zoom: { browser: true, export: false, server: false },
|
zoom: { browser: true, export: false, server: false },
|
||||||
viewModeEnabled: { browser: false, export: false, server: false },
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
pendingImageElement: { browser: false, export: false, server: false },
|
pendingImageElementId: { browser: false, export: false, server: false },
|
||||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-7
@@ -29,18 +29,24 @@ type ParseSpreadsheetResult =
|
|||||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||||
|
|
||||||
const tryParseNumber = (s: string): number | null => {
|
/**
|
||||||
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
* @private exported for testing
|
||||||
|
*/
|
||||||
|
export const tryParseNumber = (s: string): number | null => {
|
||||||
|
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parseFloat(match[1].replace(/,/g, ""));
|
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||||
|
|
||||||
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
/**
|
||||||
|
* @private exported for testing
|
||||||
|
*/
|
||||||
|
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||||
const numCols = cells[0].length;
|
const numCols = cells[0].length;
|
||||||
|
|
||||||
if (numCols > 2) {
|
if (numCols > 2) {
|
||||||
@@ -71,13 +77,16 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
const labelColumnNumeric = isNumericColumn(cells, 0);
|
||||||
|
const valueColumnNumeric = isNumericColumn(cells, 1);
|
||||||
|
|
||||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
if (!labelColumnNumeric && !valueColumnNumeric) {
|
||||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
|
||||||
|
? [0, 1]
|
||||||
|
: [1, 0];
|
||||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
||||||
const rows = hasHeader ? cells.slice(1) : cells;
|
const rows = hasHeader ? cells.slice(1) : cells;
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -2,7 +2,6 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { getSelectedElements } from "./scene";
|
|
||||||
import { AppState, BinaryFiles } from "./types";
|
import { AppState, BinaryFiles } from "./types";
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
@@ -12,7 +11,7 @@ import { isPromiseLike } from "./utils";
|
|||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
elements: ExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
files: BinaryFiles | undefined;
|
files: BinaryFiles | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,19 +56,20 @@ const clipboardContainsElements = (
|
|||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles | null,
|
||||||
) => {
|
) => {
|
||||||
// select binded text elements when copying
|
// select binded text elements when copying
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements: selectedElements,
|
elements,
|
||||||
files: selectedElements.reduce((acc, element) => {
|
files: files
|
||||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
? elements.reduce((acc, element) => {
|
||||||
acc[element.fileId] = files[element.fileId];
|
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||||
}
|
acc[element.fileId] = files[element.fileId];
|
||||||
return acc;
|
}
|
||||||
}, {} as BinaryFiles),
|
return acc;
|
||||||
|
}, {} as BinaryFiles)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
CLIPBOARD = json;
|
CLIPBOARD = json;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDeviceType } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
@@ -15,7 +15,12 @@ import {
|
|||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
import { AppState, Zoom } from "../types";
|
import { AppState, Zoom } from "../types";
|
||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
import {
|
||||||
|
capitalizeString,
|
||||||
|
isTransparent,
|
||||||
|
updateActiveTool,
|
||||||
|
setCursorForShape,
|
||||||
|
} from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
@@ -47,7 +52,7 @@ export const SelectedShapeActions = ({
|
|||||||
isSingleElementBoundContainer = true;
|
isSingleElementBoundContainer = true;
|
||||||
}
|
}
|
||||||
const isEditing = Boolean(appState.editingElement);
|
const isEditing = Boolean(appState.editingElement);
|
||||||
const deviceType = useDeviceType();
|
const device = useDevice();
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
const showFillIcons =
|
const showFillIcons =
|
||||||
@@ -172,8 +177,8 @@ export const SelectedShapeActions = ({
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{!deviceType.isMobile && renderAction("duplicateSelection")}
|
{!device.isMobile && renderAction("duplicateSelection")}
|
||||||
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
|
{!device.isMobile && renderAction("deleteSelectedElements")}
|
||||||
{renderAction("group")}
|
{renderAction("group")}
|
||||||
{renderAction("ungroup")}
|
{renderAction("ungroup")}
|
||||||
{showLinkIcon && renderAction("hyperlink")}
|
{showLinkIcon && renderAction("hyperlink")}
|
||||||
@@ -229,7 +234,9 @@ export const ShapesSwitcher = ({
|
|||||||
if (appState.activeTool.type !== value) {
|
if (appState.activeTool.type !== value) {
|
||||||
trackEvent("toolbar", value, "ui");
|
trackEvent("toolbar", value, "ui");
|
||||||
}
|
}
|
||||||
const nextActiveTool = { ...activeTool, type: value };
|
const nextActiveTool = updateActiveTool(appState, {
|
||||||
|
type: value,
|
||||||
|
});
|
||||||
setAppState({
|
setAppState({
|
||||||
activeTool: nextActiveTool,
|
activeTool: nextActiveTool,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
|
|||||||
+487
-395
File diff suppressed because it is too large
Load Diff
@@ -12,5 +12,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
|
&-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-11
@@ -1,20 +1,36 @@
|
|||||||
import "./Avatar.scss";
|
import "./Avatar.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import { getClientInitials } from "../clients";
|
||||||
|
|
||||||
type AvatarProps = {
|
type AvatarProps = {
|
||||||
children: string;
|
|
||||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||||
color: string;
|
color: string;
|
||||||
border: string;
|
border: string;
|
||||||
|
name: string;
|
||||||
|
src?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
|
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
|
||||||
<div
|
const shortName = getClientInitials(name);
|
||||||
className="Avatar"
|
const [error, setError] = useState(false);
|
||||||
style={{ background: color, border: `1px solid ${border}` }}
|
const loadImg = !error && src;
|
||||||
onClick={onClick}
|
const style = loadImg
|
||||||
>
|
? undefined
|
||||||
{children}
|
: { background: color, border: `1px solid ${border}` };
|
||||||
</div>
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDeviceType } from "./App";
|
import { useDevice } from "./App";
|
||||||
import { trash } from "./icons";
|
import { trash } from "./icons";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
|||||||
icon={trash}
|
icon={trash}
|
||||||
title={t("buttons.clearReset")}
|
title={t("buttons.clearReset")}
|
||||||
aria-label={t("buttons.clearReset")}
|
aria-label={t("buttons.clearReset")}
|
||||||
showAriaLabel={useDeviceType().isMobile}
|
showAriaLabel={useDevice().isMobile}
|
||||||
onClick={toggleDialog}
|
onClick={toggleDialog}
|
||||||
data-testid="clear-canvas-button"
|
data-testid="clear-canvas-button"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,13 +18,15 @@
|
|||||||
left: -5px;
|
left: -5px;
|
||||||
}
|
}
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
|
min-height: 1em;
|
||||||
|
line-height: 1;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: $oc-green-6;
|
background-color: $oc-green-6;
|
||||||
color: $oc-white;
|
color: $oc-white;
|
||||||
font-size: 0.7em;
|
font-size: 0.6em;
|
||||||
font-family: var(--ui-font);
|
font-family: "Cascadia";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDeviceType } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { users } from "./icons";
|
import { users } from "./icons";
|
||||||
|
|
||||||
import "./CollabButton.scss";
|
import "./CollabButton.scss";
|
||||||
@@ -26,9 +26,9 @@ const CollabButton = ({
|
|||||||
type="button"
|
type="button"
|
||||||
title={t("labels.liveCollaboration")}
|
title={t("labels.liveCollaboration")}
|
||||||
aria-label={t("labels.liveCollaboration")}
|
aria-label={t("labels.liveCollaboration")}
|
||||||
showAriaLabel={useDeviceType().isMobile}
|
showAriaLabel={useDevice().isMobile}
|
||||||
>
|
>
|
||||||
{collaboratorCount > 0 && (
|
{isCollaborating && (
|
||||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||||
)}
|
)}
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
|
|||||||
@@ -128,45 +128,33 @@ const Picker = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
if (event.key === KEYS.TAB) {
|
let handled = false;
|
||||||
const { activeElement } = document;
|
if (isArrowKey(event.key)) {
|
||||||
if (event.shiftKey) {
|
handled = true;
|
||||||
if (activeElement === firstItem.current) {
|
|
||||||
colorInput.current?.focus();
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
} else if (activeElement === colorInput.current) {
|
|
||||||
firstItem.current?.focus();
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
} else if (isArrowKey(event.key)) {
|
|
||||||
const { activeElement } = document;
|
const { activeElement } = document;
|
||||||
const isRTL = getLanguage().rtl;
|
const isRTL = getLanguage().rtl;
|
||||||
let isCustom = false;
|
let isCustom = false;
|
||||||
let index = Array.prototype.indexOf.call(
|
let index = Array.prototype.indexOf.call(
|
||||||
gallery!.current!.querySelector(".color-picker-content--default")!
|
gallery.current!.querySelector(".color-picker-content--default")
|
||||||
.children,
|
?.children,
|
||||||
activeElement,
|
activeElement,
|
||||||
);
|
);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
index = Array.prototype.indexOf.call(
|
index = Array.prototype.indexOf.call(
|
||||||
gallery!.current!.querySelector(
|
gallery.current!.querySelector(".color-picker-content--canvas-colors")
|
||||||
".color-picker-content--canvas-colors",
|
?.children,
|
||||||
)!.children,
|
|
||||||
activeElement,
|
activeElement,
|
||||||
);
|
);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
isCustom = true;
|
isCustom = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const parentSelector = isCustom
|
const parentElement = isCustom
|
||||||
? gallery!.current!.querySelector(
|
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
|
||||||
".color-picker-content--canvas-colors",
|
: gallery.current?.querySelector(".color-picker-content--default");
|
||||||
)!
|
|
||||||
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
|
||||||
|
|
||||||
if (index !== -1) {
|
if (parentElement && index !== -1) {
|
||||||
const length = parentSelector!.children.length - (showInput ? 1 : 0);
|
const length = parentElement.children.length - (showInput ? 1 : 0);
|
||||||
const nextIndex =
|
const nextIndex =
|
||||||
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
|
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
|
||||||
? (index + 1) % length
|
? (index + 1) % length
|
||||||
@@ -177,30 +165,38 @@ const Picker = ({
|
|||||||
: !isCustom && event.key === KEYS.ARROW_UP
|
: !isCustom && event.key === KEYS.ARROW_UP
|
||||||
? (length + index - 5) % length
|
? (length + index - 5) % length
|
||||||
: index;
|
: index;
|
||||||
(parentSelector!.children![nextIndex] as HTMLElement)?.focus();
|
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (
|
} else if (
|
||||||
keyBindings.includes(event.key.toLowerCase()) &&
|
keyBindings.includes(event.key.toLowerCase()) &&
|
||||||
|
!event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
!event.altKey &&
|
||||||
!isWritableElement(event.target)
|
!isWritableElement(event.target)
|
||||||
) {
|
) {
|
||||||
|
handled = true;
|
||||||
const index = keyBindings.indexOf(event.key.toLowerCase());
|
const index = keyBindings.indexOf(event.key.toLowerCase());
|
||||||
const isCustom = index >= MAX_DEFAULT_COLORS;
|
const isCustom = index >= MAX_DEFAULT_COLORS;
|
||||||
const parentSelector = isCustom
|
const parentElement = isCustom
|
||||||
? gallery!.current!.querySelector(
|
? gallery?.current?.querySelector(
|
||||||
".color-picker-content--canvas-colors",
|
".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;
|
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
|
||||||
(parentSelector!.children![actualIndex] as HTMLElement)?.focus();
|
(
|
||||||
|
parentElement?.children[actualIndex] as HTMLElement | undefined
|
||||||
|
)?.focus();
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||||
|
handled = true;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
event.nativeEvent.stopImmediatePropagation();
|
if (handled) {
|
||||||
event.stopPropagation();
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderColors = (colors: Array<string>, custom: boolean = false) => {
|
const renderColors = (colors: Array<string>, custom: boolean = false) => {
|
||||||
@@ -264,7 +260,8 @@ const Picker = ({
|
|||||||
gallery.current = el;
|
gallery.current = el;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={0}
|
// to allow focusing by clicking but not by tabbing
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="color-picker-content--default">
|
<div className="color-picker-content--default">
|
||||||
{renderColors(colors)}
|
{renderColors(colors)}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import clsx from "clsx";
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useExcalidrawContainer, useDeviceType } from "../components/App";
|
import { useExcalidrawContainer, useDevice } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import "./Dialog.scss";
|
import "./Dialog.scss";
|
||||||
import { back, close } from "./icons";
|
import { back, close } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { queryFocusableElements } from "../utils";
|
||||||
|
|
||||||
export interface DialogProps {
|
export interface DialogProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -64,14 +65,6 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [islandNode, props.autofocus]);
|
}, [islandNode, props.autofocus]);
|
||||||
|
|
||||||
const queryFocusableElements = (node: HTMLElement) => {
|
|
||||||
const focusableElements = node.querySelectorAll<HTMLElement>(
|
|
||||||
"button, a, input, select, textarea, div[tabindex]",
|
|
||||||
);
|
|
||||||
|
|
||||||
return focusableElements ? Array.from(focusableElements) : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
(lastActiveElement as HTMLElement).focus();
|
(lastActiveElement as HTMLElement).focus();
|
||||||
props.onCloseRequest();
|
props.onCloseRequest();
|
||||||
@@ -94,7 +87,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{useDeviceType().isMobile ? back : close}
|
{useDevice().isMobile ? back : close}
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="Dialog__content">{props.children}</div>
|
<div className="Dialog__content">{props.children}</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
|||||||
return t("hints.text");
|
return t("hints.text");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.activeTool.type === "image" && appState.pendingImageElement) {
|
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
|
||||||
return t("hints.placeImage");
|
return t("hints.placeImage");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { canvasToBlob } from "../data/blob";
|
|||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { CanvasError } from "../errors";
|
import { CanvasError } from "../errors";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDeviceType } from "./App";
|
import { useDevice } from "./App";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../scene/export";
|
import { exportToCanvas } from "../scene/export";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
@@ -170,7 +170,9 @@ const ImageExportModal = ({
|
|||||||
<Stack.Row gap={2}>
|
<Stack.Row gap={2}>
|
||||||
{actionManager.renderAction("changeExportScale")}
|
{actionManager.renderAction("changeExportScale")}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
<p style={{ marginLeft: "1em", userSelect: "none" }}>
|
||||||
|
{t("buttons.scale")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -250,7 +252,7 @@ export const ImageExportDialog = ({
|
|||||||
icon={exportImage}
|
icon={exportImage}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.exportImage")}
|
aria-label={t("buttons.exportImage")}
|
||||||
showAriaLabel={useDeviceType().isMobile}
|
showAriaLabel={useDevice().isMobile}
|
||||||
title={t("buttons.exportImage")}
|
title={t("buttons.exportImage")}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateLang = async () => {
|
const updateLang = async () => {
|
||||||
await setLanguage(currentLang);
|
await setLanguage(currentLang);
|
||||||
|
setLoading(false);
|
||||||
};
|
};
|
||||||
const currentLang =
|
const currentLang =
|
||||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||||
updateLang();
|
updateLang();
|
||||||
setLoading(false);
|
|
||||||
}, [props.langCode]);
|
}, [props.langCode]);
|
||||||
|
|
||||||
return loading ? <LoadingMessage /> : props.children;
|
return loading ? <LoadingMessage /> : props.children;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDeviceType } from "./App";
|
import { useDevice } from "./App";
|
||||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||||
@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
|
|||||||
icon={exportFile}
|
icon={exportFile}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.export")}
|
aria-label={t("buttons.export")}
|
||||||
showAriaLabel={useDeviceType().isMobile}
|
showAriaLabel={useDevice().isMobile}
|
||||||
title={t("buttons.export")}
|
title={t("buttons.export")}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
|
|||||||
@@ -1,9 +1,63 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
.layer-ui__sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--sat);
|
||||||
|
bottom: var(--sab);
|
||||||
|
right: var(--sar);
|
||||||
|
z-index: 5;
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
margin: var(--space-factor);
|
||||||
|
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
||||||
|
|
||||||
|
.Island {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon__close {
|
||||||
|
.Modal__close {
|
||||||
|
width: calc(var(--space-factor) * 7);
|
||||||
|
height: calc(var(--space-factor) * 7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Island {
|
||||||
|
--padding: 0;
|
||||||
|
background-color: var(--island-bg-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
|
position: relative;
|
||||||
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
.layer-ui__wrapper.animate {
|
||||||
|
transition: width 0.1s ease-in-out;
|
||||||
|
}
|
||||||
.layer-ui__wrapper {
|
.layer-ui__wrapper {
|
||||||
|
// when the rightside sidebar is docked, we need to resize the UI by its
|
||||||
|
// width, making the nested UI content shift to the left. To do this,
|
||||||
|
// we need the UI container to actually have dimensions set, but
|
||||||
|
// then we also need to disable pointer events else the canvas below
|
||||||
|
// wouldn't be interactive.
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
z-index: var(--zIndex-layerUI);
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
&__top-right {
|
&__top-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-83
@@ -1,7 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
@@ -25,9 +25,8 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
|||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library from "../data/library";
|
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
@@ -37,7 +36,10 @@ import "./LayerUI.scss";
|
|||||||
import "./Toolbar.scss";
|
import "./Toolbar.scss";
|
||||||
import { PenModeButton } from "./PenModeButton";
|
import { PenModeButton } from "./PenModeButton";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { useDeviceType } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
|
import { Stats } from "./Stats";
|
||||||
|
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||||
|
import { actionToggleZenMode } from "../actions";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -50,18 +52,13 @@ interface LayerUIProps {
|
|||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
onPenModeToggle: () => void;
|
||||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
zenModeEnabled: boolean;
|
|
||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
toggleZenMode: () => void;
|
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderTopRightUI?: (
|
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||||
isMobile: boolean,
|
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||||
appState: AppState,
|
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||||
) => JSX.Element | null;
|
|
||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
|
||||||
viewModeEnabled: boolean;
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
UIOptions: AppProps["UIOptions"];
|
UIOptions: AppProps["UIOptions"];
|
||||||
focusContainer: () => void;
|
focusContainer: () => void;
|
||||||
@@ -69,7 +66,6 @@ interface LayerUIProps {
|
|||||||
id: string;
|
id: string;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
@@ -81,14 +77,12 @@ const LayerUI = ({
|
|||||||
onLockToggle,
|
onLockToggle,
|
||||||
onPenModeToggle,
|
onPenModeToggle,
|
||||||
onInsertElements,
|
onInsertElements,
|
||||||
zenModeEnabled,
|
|
||||||
showExitZenModeBtn,
|
showExitZenModeBtn,
|
||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
toggleZenMode,
|
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
viewModeEnabled,
|
renderCustomStats,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
UIOptions,
|
UIOptions,
|
||||||
focusContainer,
|
focusContainer,
|
||||||
@@ -96,7 +90,7 @@ const LayerUI = ({
|
|||||||
id,
|
id,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const deviceType = useDeviceType();
|
const device = useDevice();
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
@@ -172,7 +166,7 @@ const LayerUI = ({
|
|||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
className={clsx("zen-mode-transition", {
|
className={clsx("zen-mode-transition", {
|
||||||
"transition-left": zenModeEnabled,
|
"transition-left": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
@@ -193,7 +187,7 @@ const LayerUI = ({
|
|||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
className={clsx("zen-mode-transition", {
|
className={clsx("zen-mode-transition", {
|
||||||
"transition-left": zenModeEnabled,
|
"transition-left": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
@@ -233,7 +227,7 @@ const LayerUI = ({
|
|||||||
<Section
|
<Section
|
||||||
heading="selectedShapeActions"
|
heading="selectedShapeActions"
|
||||||
className={clsx("zen-mode-transition", {
|
className={clsx("zen-mode-transition", {
|
||||||
"transition-left": zenModeEnabled,
|
"transition-left": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Island
|
<Island
|
||||||
@@ -277,7 +271,9 @@ const LayerUI = ({
|
|||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState, true)}
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
onClose={closeLibrary}
|
onClose={closeLibrary}
|
||||||
onInsertShape={onInsertElements}
|
onInsertLibraryItems={(libraryItems) => {
|
||||||
|
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||||
|
}}
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
@@ -301,32 +297,34 @@ const LayerUI = ({
|
|||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
gap={4}
|
gap={4}
|
||||||
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
|
className={clsx({
|
||||||
|
"disable-pointerEvents": appState.zenModeEnabled,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{viewModeEnabled
|
{appState.viewModeEnabled
|
||||||
? renderViewModeCanvasActions()
|
? renderViewModeCanvasActions()
|
||||||
: renderCanvasActions()}
|
: renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
{!viewModeEnabled && (
|
{!appState.viewModeEnabled && (
|
||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row
|
<Stack.Row
|
||||||
gap={1}
|
gap={1}
|
||||||
className={clsx("App-toolbar-container", {
|
className={clsx("App-toolbar-container", {
|
||||||
"zen-mode": zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<PenModeButton
|
<PenModeButton
|
||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={appState.zenModeEnabled}
|
||||||
checked={appState.penMode}
|
checked={appState.penMode}
|
||||||
onChange={onPenModeToggle}
|
onChange={onPenModeToggle}
|
||||||
title={t("toolBar.penMode")}
|
title={t("toolBar.penMode")}
|
||||||
penDetected={appState.penDetected}
|
penDetected={appState.penDetected}
|
||||||
/>
|
/>
|
||||||
<LockButton
|
<LockButton
|
||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={appState.zenModeEnabled}
|
||||||
checked={appState.activeTool.locked}
|
checked={appState.activeTool.locked}
|
||||||
onChange={() => onLockToggle()}
|
onChange={() => onLockToggle()}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
@@ -334,13 +332,13 @@ const LayerUI = ({
|
|||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={1}
|
||||||
className={clsx("App-toolbar", {
|
className={clsx("App-toolbar", {
|
||||||
"zen-mode": zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer
|
<HintViewer
|
||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
isMobile={deviceType.isMobile}
|
isMobile={device.isMobile}
|
||||||
/>
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
@@ -362,7 +360,6 @@ const LayerUI = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
@@ -371,27 +368,15 @@ const LayerUI = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||||
{
|
{
|
||||||
"transition-right": zenModeEnabled,
|
"transition-right": appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<UserList>
|
<UserList
|
||||||
{appState.collaborators.size > 0 &&
|
collaborators={appState.collaborators}
|
||||||
Array.from(appState.collaborators)
|
actionManager={actionManager}
|
||||||
// Collaborator is either not initialized or is actually the current user.
|
/>
|
||||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
{renderTopRightUI?.(device.isMobile, appState)}
|
||||||
.map(([clientId, client]) => (
|
|
||||||
<Tooltip
|
|
||||||
label={client.username || "Unknown user"}
|
|
||||||
key={clientId}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("goToCollaborator", {
|
|
||||||
id: clientId,
|
|
||||||
})}
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</UserList>
|
|
||||||
{renderTopRightUI?.(deviceType.isMobile, appState)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
@@ -408,7 +393,8 @@ const LayerUI = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__footer-left zen-mode-transition",
|
"layer-ui__wrapper__footer-left zen-mode-transition",
|
||||||
{
|
{
|
||||||
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
|
appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -420,12 +406,12 @@ const LayerUI = ({
|
|||||||
zoom={appState.zoom}
|
zoom={appState.zoom}
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
{!viewModeEnabled && (
|
{!appState.viewModeEnabled && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
zenModeEnabled,
|
appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("undo", { size: "small" })}
|
{actionManager.renderAction("undo", { size: "small" })}
|
||||||
@@ -435,20 +421,20 @@ const LayerUI = ({
|
|||||||
<div
|
<div
|
||||||
className={clsx("eraser-buttons zen-mode-transition", {
|
className={clsx("eraser-buttons zen-mode-transition", {
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
zenModeEnabled,
|
appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("eraser", { size: "small" })}
|
{actionManager.renderAction("eraser", { size: "small" })}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!viewModeEnabled &&
|
{!appState.viewModeEnabled &&
|
||||||
appState.multiElement &&
|
appState.multiElement &&
|
||||||
deviceType.isTouchScreen && (
|
device.isTouchScreen && (
|
||||||
<div
|
<div
|
||||||
className={clsx("finalize-button zen-mode-transition", {
|
className={clsx("finalize-button zen-mode-transition", {
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
zenModeEnabled,
|
appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("finalize", { size: "small" })}
|
{actionManager.renderAction("finalize", { size: "small" })}
|
||||||
@@ -462,7 +448,7 @@ const LayerUI = ({
|
|||||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||||
{
|
{
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
zenModeEnabled,
|
appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -472,7 +458,7 @@ const LayerUI = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__footer-right zen-mode-transition",
|
"layer-ui__wrapper__footer-right zen-mode-transition",
|
||||||
{
|
{
|
||||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
"transition-right disable-pointerEvents": appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -482,7 +468,7 @@ const LayerUI = ({
|
|||||||
className={clsx("disable-zen-mode", {
|
className={clsx("disable-zen-mode", {
|
||||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||||
})}
|
})}
|
||||||
onClick={toggleZenMode}
|
onClick={() => actionManager.executeAction(actionToggleZenMode)}
|
||||||
>
|
>
|
||||||
{t("buttons.exitZenMode")}
|
{t("buttons.exitZenMode")}
|
||||||
</button>
|
</button>
|
||||||
@@ -521,7 +507,24 @@ const LayerUI = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return deviceType.isMobile ? (
|
const renderStats = () => {
|
||||||
|
if (!appState.showStats) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stats
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
elements={elements}
|
||||||
|
onClose={() => {
|
||||||
|
actionManager.executeAction(actionToggleStats);
|
||||||
|
}}
|
||||||
|
renderCustomStats={renderCustomStats}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return device.isMobile ? (
|
||||||
<>
|
<>
|
||||||
{dialogs}
|
{dialogs}
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
@@ -538,37 +541,51 @@ const LayerUI = ({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
renderCustomFooter={renderCustomFooter}
|
renderCustomFooter={renderCustomFooter}
|
||||||
viewModeEnabled={viewModeEnabled}
|
|
||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
onImageAction={onImageAction}
|
onImageAction={onImageAction}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
|
renderStats={renderStats}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<>
|
||||||
className={clsx("layer-ui__wrapper", {
|
<div
|
||||||
"disable-pointerEvents":
|
className={clsx("layer-ui__wrapper", {
|
||||||
appState.draggingElement ||
|
"disable-pointerEvents":
|
||||||
appState.resizingElement ||
|
appState.draggingElement ||
|
||||||
(appState.editingElement && !isTextElement(appState.editingElement)),
|
appState.resizingElement ||
|
||||||
})}
|
(appState.editingElement &&
|
||||||
>
|
!isTextElement(appState.editingElement)),
|
||||||
{dialogs}
|
})}
|
||||||
{renderFixedSideContainer()}
|
style={
|
||||||
{renderBottomAppMenu()}
|
appState.isLibraryOpen &&
|
||||||
{appState.scrolledOutside && (
|
appState.isLibraryMenuDocked &&
|
||||||
<button
|
device.canDeviceFitSidebar
|
||||||
className="scroll-back-to-content"
|
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||||
onClick={() => {
|
: {}
|
||||||
setAppState({
|
}
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
>
|
||||||
});
|
{dialogs}
|
||||||
}}
|
{renderFixedSideContainer()}
|
||||||
>
|
{renderBottomAppMenu()}
|
||||||
{t("buttons.scrollBackToContent")}
|
{renderStats()}
|
||||||
</button>
|
{appState.scrolledOutside && (
|
||||||
|
<button
|
||||||
|
className="scroll-back-to-content"
|
||||||
|
onClick={() => {
|
||||||
|
setAppState({
|
||||||
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.scrollBackToContent")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{appState.isLibraryOpen && (
|
||||||
|
<div className="layer-ui__sidebar">{libraryMenu}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import clsx from "clsx";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { capitalizeString } from "../utils";
|
import { capitalizeString } from "../utils";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { useDevice } from "./App";
|
||||||
|
|
||||||
const LIBRARY_ICON = (
|
const LIBRARY_ICON = (
|
||||||
<svg viewBox="0 0 576 512">
|
<svg viewBox="0 0 576 512">
|
||||||
@@ -18,6 +20,7 @@ export const LibraryButton: React.FC<{
|
|||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}> = ({ appState, setAppState, isMobile }) => {
|
}> = ({ appState, setAppState, isMobile }) => {
|
||||||
|
const device = useDevice();
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -34,7 +37,19 @@ export const LibraryButton: React.FC<{
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="editor-library"
|
name="editor-library"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setAppState({ isLibraryOpen: event.target.checked });
|
document
|
||||||
|
.querySelector(".layer-ui__wrapper")
|
||||||
|
?.classList.remove("animate");
|
||||||
|
const nextState = event.target.checked;
|
||||||
|
setAppState({ isLibraryOpen: nextState });
|
||||||
|
// track only openings
|
||||||
|
if (nextState) {
|
||||||
|
trackEvent(
|
||||||
|
"library",
|
||||||
|
"toggleLibrary (open)",
|
||||||
|
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
checked={appState.isLibraryOpen}
|
checked={appState.isLibraryOpen}
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
aria-label={capitalizeString(t("toolBar.library"))}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.layer-ui__library {
|
.layer-ui__library {
|
||||||
margin: auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -11,19 +10,26 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 2px 0;
|
margin: 2px 0 15px 0;
|
||||||
|
.Spinner {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
// 2px from the left to account for focus border of left-most button
|
// 2px from the left to account for focus border of left-most button
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
.layer-ui__sidebar {
|
||||||
margin-inline-start: auto;
|
.layer-ui__library {
|
||||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
padding: 0;
|
||||||
padding-inline-end: 18px;
|
height: 100%;
|
||||||
white-space: nowrap;
|
}
|
||||||
}
|
.library-menu-items-container {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,4 +67,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-menu-browse-button {
|
||||||
|
width: 80%;
|
||||||
|
min-height: 22px;
|
||||||
|
margin: 0 auto;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: $oc-white;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none !important;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-primary-darker);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: var(--color-primary-darkest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-menu-browse-button--mobile {
|
||||||
|
min-height: 22px;
|
||||||
|
margin-left: auto;
|
||||||
|
a {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ import "./LibraryMenu.scss";
|
|||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
import { EVENT } from "../constants";
|
import { EVENT } from "../constants";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { arrayToMap } from "../utils";
|
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
|
import { useDevice } from "./App";
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
ref: RefObject<HTMLElement>,
|
ref: RefObject<HTMLElement>,
|
||||||
@@ -77,7 +77,7 @@ const LibraryMenuWrapper = forwardRef<
|
|||||||
|
|
||||||
export const LibraryMenu = ({
|
export const LibraryMenu = ({
|
||||||
onClose,
|
onClose,
|
||||||
onInsertShape,
|
onInsertLibraryItems,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
theme,
|
theme,
|
||||||
@@ -91,7 +91,7 @@ export const LibraryMenu = ({
|
|||||||
}: {
|
}: {
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||||
onAddToLibrary: () => void;
|
onAddToLibrary: () => void;
|
||||||
theme: AppState["theme"];
|
theme: AppState["theme"];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
@@ -104,17 +104,30 @@ export const LibraryMenu = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useOnClickOutside(ref, (event) => {
|
const device = useDevice();
|
||||||
// If click on the library icon, do nothing.
|
|
||||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
useOnClickOutside(
|
||||||
return;
|
ref,
|
||||||
}
|
useCallback(
|
||||||
onClose();
|
(event) => {
|
||||||
});
|
// If click on the library icon, do nothing.
|
||||||
|
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === KEYS.ESCAPE) {
|
if (
|
||||||
|
event.key === KEYS.ESCAPE &&
|
||||||
|
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
|
||||||
|
) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -122,7 +135,7 @@ export const LibraryMenu = ({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
|
||||||
|
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||||
@@ -139,7 +152,7 @@ export const LibraryMenu = ({
|
|||||||
const nextItems = libraryItems.filter(
|
const nextItems = libraryItems.filter(
|
||||||
(item) => !selectedItems.includes(item.id),
|
(item) => !selectedItems.includes(item.id),
|
||||||
);
|
);
|
||||||
library.saveLibrary(nextItems).catch(() => {
|
library.setLibrary(nextItems).catch(() => {
|
||||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||||
});
|
});
|
||||||
setSelectedItems([]);
|
setSelectedItems([]);
|
||||||
@@ -170,7 +183,7 @@ export const LibraryMenu = ({
|
|||||||
...libraryItems,
|
...libraryItems,
|
||||||
];
|
];
|
||||||
onAddToLibrary();
|
onAddToLibrary();
|
||||||
library.saveLibrary(nextItems).catch(() => {
|
library.setLibrary(nextItems).catch(() => {
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -220,16 +233,15 @@ export const LibraryMenu = ({
|
|||||||
libItem.status = "published";
|
libItem.status = "published";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
library.saveLibrary(nextLibItems);
|
library.setLibrary(nextLibItems);
|
||||||
},
|
},
|
||||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
if (
|
||||||
LibraryItem["id"] | null
|
libraryItemsData.status === "loading" &&
|
||||||
>(null);
|
!libraryItemsData.isInitialized
|
||||||
|
) {
|
||||||
if (libraryItemsData.status === "loading") {
|
|
||||||
return (
|
return (
|
||||||
<LibraryMenuWrapper ref={ref}>
|
<LibraryMenuWrapper ref={ref}>
|
||||||
<div className="layer-ui__library-message">
|
<div className="layer-ui__library-message">
|
||||||
@@ -255,7 +267,7 @@ export const LibraryMenu = ({
|
|||||||
}
|
}
|
||||||
onError={(error) => window.alert(error)}
|
onError={(error) => window.alert(error)}
|
||||||
updateItemsInStorage={() =>
|
updateItemsInStorage={() =>
|
||||||
library.saveLibrary(libraryItemsData.libraryItems)
|
library.setLibrary(libraryItemsData.libraryItems)
|
||||||
}
|
}
|
||||||
onRemove={(id: string) =>
|
onRemove={(id: string) =>
|
||||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||||
@@ -264,6 +276,7 @@ export const LibraryMenu = ({
|
|||||||
)}
|
)}
|
||||||
{publishLibSuccess && renderPublishSuccess()}
|
{publishLibSuccess && renderPublishSuccess()}
|
||||||
<LibraryMenuItems
|
<LibraryMenuItems
|
||||||
|
isLoading={libraryItemsData.status === "loading"}
|
||||||
libraryItems={libraryItemsData.libraryItems}
|
libraryItems={libraryItemsData.libraryItems}
|
||||||
onRemoveFromLibrary={() =>
|
onRemoveFromLibrary={() =>
|
||||||
removeFromLibrary(libraryItemsData.libraryItems)
|
removeFromLibrary(libraryItemsData.libraryItems)
|
||||||
@@ -271,56 +284,17 @@ export const LibraryMenu = ({
|
|||||||
onAddToLibrary={(elements) =>
|
onAddToLibrary={(elements) =>
|
||||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||||
}
|
}
|
||||||
onInsertShape={onInsertShape}
|
onInsertLibraryItems={onInsertLibraryItems}
|
||||||
pendingElements={pendingElements}
|
pendingElements={pendingElements}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
appState={appState}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
library={library}
|
library={library}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
files={files}
|
files={files}
|
||||||
id={id}
|
id={id}
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onToggle={(id, event) => {
|
onSelectItems={(ids) => setSelectedItems(ids)}
|
||||||
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)}
|
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||||
resetLibrary={resetLibrary}
|
resetLibrary={resetLibrary}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.library-menu-items-container {
|
.library-menu-items-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
.library-actions {
|
.library-actions {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
margin-right: auto;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
button .library-actions-counter {
|
button .library-actions-counter {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -87,12 +96,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&__items {
|
&__items {
|
||||||
max-height: 50vh;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow-y: auto;
|
||||||
margin-top: 0.5rem;
|
overflow-x: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin: 0.6em 0.2em;
|
margin: 0.6em 0.2em;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { chunk } from "lodash";
|
import { chunk } from "lodash";
|
||||||
import { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
||||||
import Library from "../data/library";
|
import Library from "../data/library";
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -11,48 +11,57 @@ import {
|
|||||||
LibraryItem,
|
LibraryItem,
|
||||||
LibraryItems,
|
LibraryItems,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { arrayToMap, muteFSAbortError } from "../utils";
|
||||||
import { useDeviceType } from "./App";
|
import { useDevice } from "./App";
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
import "./LibraryMenuItems.scss";
|
import "./LibraryMenuItems.scss";
|
||||||
import { VERSIONS } from "../constants";
|
import { MIME_TYPES, VERSIONS } from "../constants";
|
||||||
|
import Spinner from "./Spinner";
|
||||||
|
import { fileOpen } from "../data/filesystem";
|
||||||
|
|
||||||
|
import { SidebarLockButton } from "./SidebarLockButton";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
const LibraryMenuItems = ({
|
||||||
|
isLoading,
|
||||||
libraryItems,
|
libraryItems,
|
||||||
onRemoveFromLibrary,
|
onRemoveFromLibrary,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
onInsertShape,
|
onInsertLibraryItems,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
theme,
|
theme,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
appState,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
library,
|
library,
|
||||||
files,
|
files,
|
||||||
id,
|
id,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
onToggle,
|
onSelectItems,
|
||||||
onPublish,
|
onPublish,
|
||||||
resetLibrary,
|
resetLibrary,
|
||||||
}: {
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
libraryItems: LibraryItems;
|
libraryItems: LibraryItems;
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onRemoveFromLibrary: () => void;
|
onRemoveFromLibrary: () => void;
|
||||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||||
theme: AppState["theme"];
|
theme: AppState["theme"];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
appState: AppState;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
library: Library;
|
library: Library;
|
||||||
id: string;
|
id: string;
|
||||||
selectedItems: LibraryItem["id"][];
|
selectedItems: LibraryItem["id"][];
|
||||||
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
resetLibrary: () => void;
|
resetLibrary: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -84,9 +93,7 @@ const LibraryMenuItems = ({
|
|||||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||||
|
|
||||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||||
|
const device = useDevice();
|
||||||
const isMobile = useDeviceType().isMobile;
|
|
||||||
|
|
||||||
const renderLibraryActions = () => {
|
const renderLibraryActions = () => {
|
||||||
const itemsSelected = !!selectedItems.length;
|
const itemsSelected = !!selectedItems.length;
|
||||||
const items = itemsSelected
|
const items = itemsSelected
|
||||||
@@ -97,19 +104,34 @@ const LibraryMenuItems = ({
|
|||||||
: t("buttons.resetLibrary");
|
: t("buttons.resetLibrary");
|
||||||
return (
|
return (
|
||||||
<div className="library-actions">
|
<div className="library-actions">
|
||||||
{(!itemsSelected || !isMobile) && (
|
{!itemsSelected && (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
key="import"
|
key="import"
|
||||||
type="button"
|
type="button"
|
||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
icon={load}
|
icon={load}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
importLibraryFromJSON(library)
|
try {
|
||||||
.catch(muteFSAbortError)
|
await library.updateLibrary({
|
||||||
.catch((error) => {
|
libraryItems: fileOpen({
|
||||||
setAppState({ errorMessage: error.message });
|
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,
|
||||||
});
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
console.warn(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="library-actions--load"
|
className="library-actions--load"
|
||||||
/>
|
/>
|
||||||
@@ -125,7 +147,7 @@ const LibraryMenuItems = ({
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const libraryItems = itemsSelected
|
const libraryItems = itemsSelected
|
||||||
? items
|
? items
|
||||||
: await library.loadLibrary();
|
: await library.getLatestLibrary();
|
||||||
saveLibraryAsJSON(libraryItems)
|
saveLibraryAsJSON(libraryItems)
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -157,7 +179,7 @@ const LibraryMenuItems = ({
|
|||||||
</ToolButton>
|
</ToolButton>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{itemsSelected && !isPublished && (
|
{itemsSelected && (
|
||||||
<Tooltip label={t("hints.publishLibrary")}>
|
<Tooltip label={t("hints.publishLibrary")}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -167,7 +189,7 @@ const LibraryMenuItems = ({
|
|||||||
className="library-actions--publish"
|
className="library-actions--publish"
|
||||||
onClick={onPublish}
|
onClick={onPublish}
|
||||||
>
|
>
|
||||||
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||||
{selectedItems.length > 0 && (
|
{selectedItems.length > 0 && (
|
||||||
<span className="library-actions-counter">
|
<span className="library-actions-counter">
|
||||||
{selectedItems.length}
|
{selectedItems.length}
|
||||||
@@ -176,17 +198,89 @@ const LibraryMenuItems = ({
|
|||||||
</ToolButton>
|
</ToolButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{device.isMobile && (
|
||||||
|
<div className="library-menu-browse-button--mobile">
|
||||||
|
<a
|
||||||
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
|
window.name || "_blank"
|
||||||
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||||
|
VERSIONS.excalidrawLibrary
|
||||||
|
}`}
|
||||||
|
target="_excalidraw_libraries"
|
||||||
|
>
|
||||||
|
{t("labels.libraries")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
|
||||||
|
|
||||||
const referrer =
|
const referrer =
|
||||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||||
const isPublished = selectedItems.some(
|
|
||||||
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
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 createLibraryItemCompo = (params: {
|
const createLibraryItemCompo = (params: {
|
||||||
item:
|
item:
|
||||||
@@ -208,8 +302,12 @@ const LibraryMenuItems = ({
|
|||||||
onClick={params.onClick || (() => {})}
|
onClick={params.onClick || (() => {})}
|
||||||
id={params.item?.id || null}
|
id={params.item?.id || null}
|
||||||
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||||
onToggle={(id, event) => {
|
onToggle={onItemSelectToggle}
|
||||||
onToggle(id, event);
|
onDrag={(id, event) => {
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
MIME_TYPES.excalidrawlib,
|
||||||
|
serializeLibraryAsJSON(getInsertedElements(id)),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@@ -229,7 +327,7 @@ const LibraryMenuItems = ({
|
|||||||
if (item.id) {
|
if (item.id) {
|
||||||
return createLibraryItemCompo({
|
return createLibraryItemCompo({
|
||||||
item,
|
item,
|
||||||
onClick: () => onInsertShape(item.elements),
|
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
|
||||||
key: item.id,
|
key: item.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -268,49 +366,192 @@ const LibraryMenuItems = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unpublishedItems = libraryItems.filter(
|
||||||
|
(item) => item.status !== "published",
|
||||||
|
);
|
||||||
const publishedItems = libraryItems.filter(
|
const publishedItems = libraryItems.filter(
|
||||||
(item) => item.status === "published",
|
(item) => item.status === "published",
|
||||||
);
|
);
|
||||||
const unpublishedItems = [
|
|
||||||
// append pending library item
|
|
||||||
...(pendingElements.length
|
|
||||||
? [{ id: null, elements: pendingElements }]
|
|
||||||
: []),
|
|
||||||
...libraryItems.filter((item) => item.status !== "published"),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
const renderLibraryHeader = () => {
|
||||||
<div className="library-menu-items-container">
|
return (
|
||||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
<>
|
||||||
<div className="layer-ui__library-header" key="library-header">
|
<div className="layer-ui__library-header" key="library-header">
|
||||||
{renderLibraryActions()}
|
{renderLibraryActions()}
|
||||||
<a
|
{device.canDeviceFitSidebar && (
|
||||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
<>
|
||||||
window.name || "_blank"
|
<div className="layer-ui__sidebar-lock-button">
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
<SidebarLockButton
|
||||||
VERSIONS.excalidrawLibrary
|
checked={appState.isLibraryMenuDocked}
|
||||||
}`}
|
onChange={() => {
|
||||||
target="_excalidraw_libraries"
|
document
|
||||||
>
|
.querySelector(".layer-ui__wrapper")
|
||||||
{t("labels.libraries")}
|
?.classList.add("animate");
|
||||||
</a>
|
const nextState = !appState.isLibraryMenuDocked;
|
||||||
</div>
|
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 (
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
className="library-menu-items-container__items"
|
className="library-menu-items-container__items"
|
||||||
align="start"
|
align="start"
|
||||||
gap={1}
|
gap={1}
|
||||||
|
style={{
|
||||||
|
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<div className="separator">{t("labels.personalLib")}</div>
|
<div className="separator">
|
||||||
{renderLibrarySection(unpublishedItems)}
|
{(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.excalidrawLib")} </div>
|
{(publishedItems.length > 0 ||
|
||||||
|
(!device.isMobile &&
|
||||||
{renderLibrarySection(publishedItems)}
|
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
||||||
|
<div className="separator">{t("labels.excalidrawLib")}</div>
|
||||||
|
)}
|
||||||
|
{publishedItems.length > 0 ? (
|
||||||
|
renderLibrarySection(publishedItems)
|
||||||
|
) : unpublishedItems.length > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: "1rem 0",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
fontSize: ".9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("library.noItems")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLibraryFooter = () => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="library-menu-browse-button"
|
||||||
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
|
window.name || "_blank"
|
||||||
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||||
|
VERSIONS.excalidrawLibrary
|
||||||
|
}`}
|
||||||
|
target="_excalidraw_libraries"
|
||||||
|
>
|
||||||
|
{t("labels.libraries")}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="library-menu-items-container"
|
||||||
|
style={
|
||||||
|
device.isMobile
|
||||||
|
? {
|
||||||
|
minHeight: "200px",
|
||||||
|
maxHeight: "70vh",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||||
|
{renderLibraryHeader()}
|
||||||
|
{renderLibraryMenuItems()}
|
||||||
|
{!device.isMobile && renderLibraryFooter()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.library-unit {
|
.library-unit {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--button-gray-2);
|
border: 1px solid transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -21,10 +21,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.theme--dark .library-unit {
|
|
||||||
border-color: rgb(48, 48, 48);
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-unit__dragger {
|
.library-unit__dragger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { useDevice } from "../components/App";
|
||||||
import { useDeviceType } from "../components/App";
|
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { BinaryFiles, LibraryItem } from "../types";
|
import { BinaryFiles, LibraryItem } from "../types";
|
||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
@@ -29,6 +28,7 @@ export const LibraryUnit = ({
|
|||||||
onClick,
|
onClick,
|
||||||
selected,
|
selected,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onDrag,
|
||||||
}: {
|
}: {
|
||||||
id: LibraryItem["id"] | /** for pending item */ null;
|
id: LibraryItem["id"] | /** for pending item */ null;
|
||||||
elements?: LibraryItem["elements"];
|
elements?: LibraryItem["elements"];
|
||||||
@@ -37,6 +37,7 @@ export const LibraryUnit = ({
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onToggle: (id: string, event: React.MouseEvent) => void;
|
onToggle: (id: string, event: React.MouseEvent) => void;
|
||||||
|
onDrag: (id: string, event: React.DragEvent) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,7 +67,7 @@ export const LibraryUnit = ({
|
|||||||
}, [elements, files]);
|
}, [elements, files]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useDeviceType().isMobile;
|
const isMobile = useDevice().isMobile;
|
||||||
const adder = isPending && (
|
const adder = isPending && (
|
||||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||||
);
|
);
|
||||||
@@ -99,11 +100,12 @@ export const LibraryUnit = ({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
|
if (!id) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
event.dataTransfer.setData(
|
onDrag(id, event);
|
||||||
MIME_TYPES.excalidrawlib,
|
|
||||||
JSON.stringify(elements),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{adder}
|
{adder}
|
||||||
|
|||||||
@@ -32,14 +32,17 @@ type MobileMenuProps = {
|
|||||||
onPenModeToggle: () => void;
|
onPenModeToggle: () => void;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderCustomFooter?: (
|
||||||
viewModeEnabled: boolean;
|
isMobile: boolean,
|
||||||
|
appState: AppState,
|
||||||
|
) => JSX.Element | null;
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
renderTopRightUI?: (
|
renderTopRightUI?: (
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => JSX.Element | null;
|
) => JSX.Element | null;
|
||||||
|
renderStats: () => JSX.Element | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@@ -56,10 +59,10 @@ export const MobileMenu = ({
|
|||||||
canvas,
|
canvas,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
viewModeEnabled,
|
|
||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
|
renderStats,
|
||||||
}: MobileMenuProps) => {
|
}: MobileMenuProps) => {
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
return (
|
return (
|
||||||
@@ -120,7 +123,7 @@ export const MobileMenu = ({
|
|||||||
!appState.editingElement &&
|
!appState.editingElement &&
|
||||||
getSelectedElements(elements, appState).length === 0;
|
getSelectedElements(elements, appState).length === 0;
|
||||||
|
|
||||||
if (viewModeEnabled) {
|
if (appState.viewModeEnabled) {
|
||||||
return (
|
return (
|
||||||
<div className="App-toolbar-content">
|
<div className="App-toolbar-content">
|
||||||
{actionManager.renderAction("toggleCanvasMenu")}
|
{actionManager.renderAction("toggleCanvasMenu")}
|
||||||
@@ -146,7 +149,7 @@ export const MobileMenu = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderCanvasActions = () => {
|
const renderCanvasActions = () => {
|
||||||
if (viewModeEnabled) {
|
if (appState.viewModeEnabled) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderJSONExportDialog()}
|
{renderJSONExportDialog()}
|
||||||
@@ -180,7 +183,8 @@ export const MobileMenu = ({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!viewModeEnabled && renderToolbar()}
|
{!appState.viewModeEnabled && renderToolbar()}
|
||||||
|
{renderStats()}
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -199,27 +203,18 @@ export const MobileMenu = ({
|
|||||||
{appState.collaborators.size > 0 && (
|
{appState.collaborators.size > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.collaborators")}</legend>
|
<legend>{t("labels.collaborators")}</legend>
|
||||||
<UserList mobile>
|
<UserList
|
||||||
{Array.from(appState.collaborators)
|
mobile
|
||||||
// Collaborator is either not initialized or is actually the current user.
|
collaborators={appState.collaborators}
|
||||||
.filter(
|
actionManager={actionManager}
|
||||||
([_, client]) => Object.keys(client).length !== 0,
|
/>
|
||||||
)
|
|
||||||
.map(([clientId, client]) => (
|
|
||||||
<React.Fragment key={clientId}>
|
|
||||||
{actionManager.renderAction("goToCollaborator", {
|
|
||||||
id: clientId,
|
|
||||||
})}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</UserList>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
) : appState.openMenu === "shape" &&
|
) : appState.openMenu === "shape" &&
|
||||||
!viewModeEnabled &&
|
!appState.viewModeEnabled &&
|
||||||
showSelectedShapeActions(appState, elements) ? (
|
showSelectedShapeActions(appState, elements) ? (
|
||||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||||
<SelectedShapeActions
|
<SelectedShapeActions
|
||||||
@@ -232,18 +227,20 @@ export const MobileMenu = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<footer className="App-toolbar">
|
<footer className="App-toolbar">
|
||||||
{renderAppToolbar()}
|
{renderAppToolbar()}
|
||||||
{appState.scrolledOutside && !appState.openMenu && (
|
{appState.scrolledOutside &&
|
||||||
<button
|
!appState.openMenu &&
|
||||||
className="scroll-back-to-content"
|
!appState.isLibraryOpen && (
|
||||||
onClick={() => {
|
<button
|
||||||
setAppState({
|
className="scroll-back-to-content"
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
onClick={() => {
|
||||||
});
|
setAppState({
|
||||||
}}
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
>
|
});
|
||||||
{t("buttons.scrollBackToContent")}
|
}}
|
||||||
</button>
|
>
|
||||||
)}
|
{t("buttons.scrollBackToContent")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
</Island>
|
</Island>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useExcalidrawContainer, useDeviceType } from "./App";
|
import { useExcalidrawContainer, useDevice } from "./App";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { THEME } from "../constants";
|
import { THEME } from "../constants";
|
||||||
|
|
||||||
@@ -59,17 +59,17 @@ export const Modal = (props: {
|
|||||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
const useBodyRoot = (theme: AppState["theme"]) => {
|
||||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const deviceType = useDeviceType();
|
const device = useDevice();
|
||||||
const isMobileRef = useRef(deviceType.isMobile);
|
const isMobileRef = useRef(device.isMobile);
|
||||||
isMobileRef.current = deviceType.isMobile;
|
isMobileRef.current = device.isMobile;
|
||||||
|
|
||||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (div) {
|
if (div) {
|
||||||
div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
|
div.classList.toggle("excalidraw--mobile", device.isMobile);
|
||||||
}
|
}
|
||||||
}, [div, deviceType.isMobile]);
|
}, [div, device.isMobile]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const isDarkTheme =
|
const isDarkTheme =
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
.popover {
|
.popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
padding: 5px 0 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useLayoutEffect, useRef, useEffect } from "react";
|
import React, { useLayoutEffect, useRef, useEffect } from "react";
|
||||||
import "./Popover.scss";
|
import "./Popover.scss";
|
||||||
import { unstable_batchedUpdates } from "react-dom";
|
import { unstable_batchedUpdates } from "react-dom";
|
||||||
|
import { queryFocusableElements } from "../utils";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
top?: number;
|
top?: number;
|
||||||
@@ -27,17 +29,67 @@ export const Popover = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const container = popoverRef.current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === KEYS.TAB) {
|
||||||
|
const focusableElements = queryFocusableElements(container);
|
||||||
|
const { activeElement } = document;
|
||||||
|
const currentIndex = focusableElements.findIndex(
|
||||||
|
(element) => element === activeElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentIndex === 0 && event.shiftKey) {
|
||||||
|
focusableElements[focusableElements.length - 1].focus();
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
} else if (
|
||||||
|
currentIndex === focusableElements.length - 1 &&
|
||||||
|
!event.shiftKey
|
||||||
|
) {
|
||||||
|
focusableElements[0].focus();
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [container]);
|
||||||
|
|
||||||
// ensure the popover doesn't overflow the viewport
|
// ensure the popover doesn't overflow the viewport
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (fitInViewport && popoverRef.current) {
|
if (fitInViewport && popoverRef.current) {
|
||||||
const element = popoverRef.current;
|
const element = popoverRef.current;
|
||||||
const { x, y, width, height } = element.getBoundingClientRect();
|
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) {
|
if (x + width - offsetLeft > viewportWidth) {
|
||||||
element.style.left = `${viewportWidth - width}px`;
|
element.style.left = `${viewportWidth - width - 10}px`;
|
||||||
}
|
}
|
||||||
if (y + height - offsetTop > viewportHeight) {
|
if (y + height - offsetTop > viewportHeight) {
|
||||||
element.style.top = `${viewportHeight - height}px`;
|
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]);
|
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-warning {
|
||||||
|
color: $oc-red-6;
|
||||||
|
}
|
||||||
|
|
||||||
&-note {
|
&-note {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
@@ -295,6 +295,11 @@ const PublishLibrary = ({
|
|||||||
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
||||||
|
|
||||||
const shouldRenderForm = !!libraryItems.length;
|
const shouldRenderForm = !!libraryItems.length;
|
||||||
|
|
||||||
|
const containsPublishedItems = libraryItems.some(
|
||||||
|
(item) => item.status === "published",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
onCloseRequest={onDialogClose}
|
onCloseRequest={onDialogClose}
|
||||||
@@ -329,6 +334,11 @@ const PublishLibrary = ({
|
|||||||
<div className="publish-library-note">
|
<div className="publish-library-note">
|
||||||
{t("publishDialog.noteItems")}
|
{t("publishDialog.noteItems")}
|
||||||
</div>
|
</div>
|
||||||
|
{containsPublishedItems && (
|
||||||
|
<span className="publish-library-note publish-library-warning">
|
||||||
|
{t("publishDialog.republishWarning")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{renderLibraryItems()}
|
{renderLibraryItems()}
|
||||||
<div className="publish-library__fields">
|
<div className="publish-library__fields">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.layer-ui__sidebar-lock-button {
|
||||||
|
@include toolbarButtonColorStates;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
}
|
||||||
|
.ToolIcon_type_floating .side_lock_icon {
|
||||||
|
width: calc(var(--space-factor) * 7);
|
||||||
|
height: calc(var(--space-factor) * 7);
|
||||||
|
svg {
|
||||||
|
// mirror
|
||||||
|
transform: scale(-1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ToolButtonSize } from "./ToolButton";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
|
import "./SidebarLockButton.scss";
|
||||||
|
|
||||||
|
type SidebarLockIconProps = {
|
||||||
|
checked: boolean;
|
||||||
|
onChange?(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||||
|
|
||||||
|
const SIDE_LIBRARY_TOGGLE_ICON = (
|
||||||
|
<svg viewBox="0 0 24 24" fill="#ffffff">
|
||||||
|
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SidebarLockButton = (props: SidebarLockIconProps) => {
|
||||||
|
return (
|
||||||
|
<Tooltip label={t("labels.sidebarLock")}>
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||||
|
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="ToolIcon_type_checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
onChange={props.onChange}
|
||||||
|
checked={props.checked}
|
||||||
|
aria-label={t("labels.sidebarLock")}
|
||||||
|
/>{" "}
|
||||||
|
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
|
||||||
|
{SIDE_LIBRARY_TOGGLE_ICON}
|
||||||
|
</div>{" "}
|
||||||
|
</label>{" "}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,11 +3,24 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.single-library-item {
|
.single-library-item {
|
||||||
position: relative;
|
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 {
|
&__svg {
|
||||||
|
background-color: $oc-white;
|
||||||
|
padding: 0.3rem;
|
||||||
width: 7.5rem;
|
width: 7.5rem;
|
||||||
height: 7.5rem;
|
height: 7.5rem;
|
||||||
border: 1px solid var(--button-gray-2);
|
border: 1px solid var(--button-gray-2);
|
||||||
margin: 0.3rem;
|
|
||||||
svg {
|
svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -40,7 +53,7 @@
|
|||||||
&--remove {
|
&--remove {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.2rem;
|
top: 0.2rem;
|
||||||
right: 1.3rem;
|
right: 1rem;
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ const SingleLibraryItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="single-library-item">
|
<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" />
|
<div ref={svgRef} className="single-library-item__svg" />
|
||||||
<ToolButton
|
<ToolButton
|
||||||
aria-label={t("buttons.remove")}
|
aria-label={t("buttons.remove")}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const ColStack = ({
|
|||||||
align,
|
align,
|
||||||
justifyContent,
|
justifyContent,
|
||||||
className,
|
className,
|
||||||
|
style,
|
||||||
}: StackProps) => {
|
}: StackProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -49,6 +50,7 @@ const ColStack = ({
|
|||||||
"--gap": gap,
|
"--gap": gap,
|
||||||
justifyItems: align,
|
justifyItems: align,
|
||||||
justifyContent,
|
justifyContent,
|
||||||
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
right: 12px;
|
right: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 24px 8px 0;
|
margin: 0 24px 8px 0;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { getCommonBounds } from "../element/bounds";
|
import { getCommonBounds } from "../element/bounds";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDeviceType } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { getTargetElements } from "../scene";
|
import { getTargetElements } from "../scene";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import { AppState, ExcalidrawProps } from "../types";
|
||||||
import { close } from "./icons";
|
import { close } from "./icons";
|
||||||
@@ -16,16 +16,13 @@ export const Stats = (props: {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
}) => {
|
}) => {
|
||||||
const deviceType = useDeviceType();
|
const device = useDevice();
|
||||||
|
|
||||||
const boundingBox = getCommonBounds(props.elements);
|
const boundingBox = getCommonBounds(props.elements);
|
||||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||||
|
if (device.isMobile && props.appState.openMenu) {
|
||||||
if (deviceType.isMobile && props.appState.openMenu) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Stats">
|
<div className="Stats">
|
||||||
<Island padding={2}>
|
<Island padding={2}>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Toast {
|
.Toast {
|
||||||
|
$closeButtonSize: 1.2rem;
|
||||||
|
$closeButtonPadding: 0.4rem;
|
||||||
|
|
||||||
animation: fade-in 0.5s;
|
animation: fade-in 0.5s;
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -15,11 +18,24 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
z-index: 999999;
|
z-index: 999999;
|
||||||
}
|
|
||||||
|
|
||||||
.Toast__message {
|
.Toast__message {
|
||||||
color: var(--popup-text-color);
|
padding: 0 $closeButtonSize + ($closeButtonPadding);
|
||||||
white-space: pre-wrap;
|
color: var(--popup-text-color);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: $closeButtonPadding;
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: $closeButtonSize;
|
||||||
|
height: $closeButtonSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
|
|||||||
+37
-12
@@ -1,34 +1,59 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { TOAST_TIMEOUT } from "../constants";
|
import { close } from "./icons";
|
||||||
import "./Toast.scss";
|
import "./Toast.scss";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
const DEFAULT_TOAST_TIMEOUT = 5000;
|
||||||
|
|
||||||
export const Toast = ({
|
export const Toast = ({
|
||||||
message,
|
message,
|
||||||
clearToast,
|
onClose,
|
||||||
|
closable = false,
|
||||||
|
// To prevent autoclose, pass duration as Infinity
|
||||||
|
duration = DEFAULT_TOAST_TIMEOUT,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message: string;
|
||||||
clearToast: () => void;
|
onClose: () => void;
|
||||||
|
closable?: boolean;
|
||||||
|
duration?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const timerRef = useRef<number>(0);
|
const timerRef = useRef<number>(0);
|
||||||
|
const shouldAutoClose = duration !== Infinity;
|
||||||
const scheduleTimeout = useCallback(
|
const scheduleTimeout = useCallback(() => {
|
||||||
() =>
|
if (!shouldAutoClose) {
|
||||||
(timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
|
return;
|
||||||
[clearToast],
|
}
|
||||||
);
|
timerRef.current = window.setTimeout(() => onClose(), duration);
|
||||||
|
}, [onClose, duration, shouldAutoClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!shouldAutoClose) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
scheduleTimeout();
|
scheduleTimeout();
|
||||||
return () => clearTimeout(timerRef.current);
|
return () => clearTimeout(timerRef.current);
|
||||||
}, [scheduleTimeout, message]);
|
}, [scheduleTimeout, message, duration, shouldAutoClose]);
|
||||||
|
|
||||||
|
const onMouseEnter = shouldAutoClose
|
||||||
|
? () => clearTimeout(timerRef?.current)
|
||||||
|
: undefined;
|
||||||
|
const onMouseLeave = shouldAutoClose ? scheduleTimeout : undefined;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="Toast"
|
className="Toast"
|
||||||
onMouseEnter={() => clearTimeout(timerRef?.current)}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={scheduleTimeout}
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
<p className="Toast__message">{message}</p>
|
<p className="Toast__message">{message}</p>
|
||||||
|
{closable && (
|
||||||
|
<ToolButton
|
||||||
|
icon={close}
|
||||||
|
aria-label="close"
|
||||||
|
type="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="close"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -212,16 +212,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ToolIcon.ToolIcon__library {
|
.ToolIcon.ToolIcon__library {
|
||||||
top: 100px;
|
top: calc(var(--sat) + 100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
.ToolIcon.ToolIcon__lock {
|
||||||
margin-inline-end: 0;
|
top: calc(var(--sat) + 60px);
|
||||||
top: 60px;
|
|
||||||
}
|
}
|
||||||
.ToolIcon.ToolIcon__penMode {
|
.ToolIcon.ToolIcon__penMode {
|
||||||
margin-inline-end: 0;
|
top: calc(var(--sat) + 140px);
|
||||||
top: 140px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,5 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "open-color/open-color.scss";
|
||||||
|
@import "../css/variables.module";
|
||||||
@mixin toolbarButtonColorStates {
|
|
||||||
.ToolIcon_type_radio,
|
|
||||||
.ToolIcon_type_checkbox {
|
|
||||||
& + .ToolIcon__icon:active {
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
}
|
|
||||||
&:checked + .ToolIcon__icon {
|
|
||||||
background: var(--color-primary);
|
|
||||||
--icon-fill-color: #{$oc-white};
|
|
||||||
--keybinding-color: #{$oc-white};
|
|
||||||
}
|
|
||||||
&:checked + .ToolIcon__icon:active {
|
|
||||||
background: var(--color-primary-darker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__keybinding {
|
|
||||||
bottom: 4px;
|
|
||||||
right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.App-toolbar-container {
|
.App-toolbar-container {
|
||||||
@@ -53,7 +32,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
.ToolIcon.ToolIcon__lock {
|
||||||
margin-inline-end: var(--space-factor);
|
|
||||||
&.ToolIcon_type_floating {
|
&.ToolIcon_type_floating {
|
||||||
margin-left: 0.1rem;
|
margin-left: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
// container in body where the actual tooltip is appended to
|
// container in body where the actual tooltip is appended to
|
||||||
.excalidraw-tooltip {
|
.excalidraw-tooltip {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@@ -2,17 +2,51 @@ import "./UserList.scss";
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { AppState, Collaborator } from "../types";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
import { ActionManager } from "../actions/manager";
|
||||||
|
|
||||||
type UserListProps = {
|
export const UserList: React.FC<{
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
};
|
collaborators: AppState["collaborators"];
|
||||||
|
actionManager: ActionManager;
|
||||||
|
}> = ({ className, mobile, collaborators, actionManager }) => {
|
||||||
|
const uniqueCollaborators = new Map<string, Collaborator>();
|
||||||
|
|
||||||
|
collaborators.forEach((collaborator, socketId) => {
|
||||||
|
uniqueCollaborators.set(
|
||||||
|
// filter on user id, else fall back on unique socketId
|
||||||
|
collaborator.id || socketId,
|
||||||
|
collaborator,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatars =
|
||||||
|
uniqueCollaborators.size > 0 &&
|
||||||
|
Array.from(uniqueCollaborators)
|
||||||
|
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||||
|
.map(([clientId, collaborator]) => {
|
||||||
|
const avatarJSX = actionManager.renderAction("goToCollaborator", [
|
||||||
|
clientId,
|
||||||
|
collaborator,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return mobile ? (
|
||||||
|
<Tooltip
|
||||||
|
label={collaborator.username || "Unknown user"}
|
||||||
|
key={clientId}
|
||||||
|
>
|
||||||
|
{avatarJSX}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const UserList = ({ children, className, mobile }: UserListProps) => {
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
||||||
{children}
|
{avatars}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-2
@@ -108,14 +108,14 @@ export const EXPORT_DATA_TYPES = {
|
|||||||
excalidrawLibrary: "excalidrawlib",
|
excalidrawLibrary: "excalidrawlib",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const EXPORT_SOURCE = window.location.origin;
|
export const EXPORT_SOURCE =
|
||||||
|
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||||
|
|
||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
export const IMAGE_RENDER_TIMEOUT = 500;
|
export const IMAGE_RENDER_TIMEOUT = 500;
|
||||||
export const TAP_TWICE_TIMEOUT = 300;
|
export const TAP_TWICE_TIMEOUT = 300;
|
||||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||||
export const TITLE_TIMEOUT = 10000;
|
export const TITLE_TIMEOUT = 10000;
|
||||||
export const TOAST_TIMEOUT = 5000;
|
|
||||||
export const VERSION_TIMEOUT = 30000;
|
export const VERSION_TIMEOUT = 30000;
|
||||||
export const SCROLL_TIMEOUT = 100;
|
export const SCROLL_TIMEOUT = 100;
|
||||||
export const ZOOM_STEP = 0.1;
|
export const ZOOM_STEP = 0.1;
|
||||||
@@ -154,9 +154,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// breakpoints
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// sm screen
|
||||||
|
export const MQ_SM_MAX_WIDTH = 640;
|
||||||
|
// md screen
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||||
|
// sidebar
|
||||||
|
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth);
|
||||||
|
|
||||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||||
|
|
||||||
@@ -192,3 +202,7 @@ export const VERTICAL_ALIGN = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||||
|
|
||||||
|
export const COOKIES = {
|
||||||
|
AUTH_STATE_COOKIE: "excplus-auth",
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
export const createInverseContext = <T extends unknown = null>(
|
|
||||||
initialValue: T,
|
|
||||||
) => {
|
|
||||||
const Context = React.createContext(initialValue) as React.Context<T> & {
|
|
||||||
_updateProviderValue?: (value: T) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
class InverseConsumer extends React.Component {
|
|
||||||
state = { value: initialValue };
|
|
||||||
constructor(props: any) {
|
|
||||||
super(props);
|
|
||||||
Context._updateProviderValue = (value: T) => this.setState({ value });
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Context.Provider value={this.state.value}>
|
|
||||||
{this.props.children}
|
|
||||||
</Context.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class InverseProvider extends React.Component<{ value: T }> {
|
|
||||||
componentDidMount() {
|
|
||||||
Context._updateProviderValue?.(this.props.value);
|
|
||||||
}
|
|
||||||
componentDidUpdate() {
|
|
||||||
Context._updateProviderValue?.(this.props.value);
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
Context,
|
|
||||||
Consumer: InverseConsumer,
|
|
||||||
Provider: InverseProvider,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
+18
-1
@@ -350,7 +350,6 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
z-index: 100;
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
left: 0.25rem;
|
left: 0.25rem;
|
||||||
@@ -391,6 +390,7 @@
|
|||||||
|
|
||||||
.App-menu__left {
|
.App-menu__left {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-select {
|
.dropdown-select {
|
||||||
@@ -449,6 +449,7 @@
|
|||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-icon {
|
.help-icon {
|
||||||
@@ -567,6 +568,22 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// use custom, minimalistic scrollbar
|
||||||
|
// (doesn't work in Firefox)
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--button-gray-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--button-gray-3);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:active {
|
||||||
|
background: var(--button-gray-2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ErrorSplash.excalidraw {
|
.ErrorSplash.excalidraw {
|
||||||
|
|||||||
@@ -6,8 +6,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin toolbarButtonColorStates {
|
||||||
|
.ToolIcon_type_radio,
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
& + .ToolIcon__icon:active {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
}
|
||||||
|
&:checked + .ToolIcon__icon {
|
||||||
|
background: var(--color-primary);
|
||||||
|
--icon-fill-color: #{$oc-white};
|
||||||
|
--keybinding-color: #{$oc-white};
|
||||||
|
}
|
||||||
|
&:checked + .ToolIcon__icon:active {
|
||||||
|
background: var(--color-primary-darker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__keybinding {
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
||||||
|
$right-sidebar-width: "302px";
|
||||||
|
|
||||||
:export {
|
:export {
|
||||||
themeFilter: unquote($theme-filter);
|
themeFilter: unquote($theme-filter);
|
||||||
|
rightSidebarWidth: unquote($right-sidebar-width);
|
||||||
}
|
}
|
||||||
|
|||||||
+187
-27
@@ -8,7 +8,7 @@ import { t } from "../i18n";
|
|||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { AppState, DataURL, LibraryItem } from "../types";
|
import { AppState, DataURL, LibraryItem } from "../types";
|
||||||
import { bytesToHexString } from "../utils";
|
import { bytesToHexString } from "../utils";
|
||||||
import { FileSystemHandle } from "./filesystem";
|
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
|
||||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||||
import { restore, restoreLibraryItems } from "./restore";
|
import { restore, restoreLibraryItems } from "./restore";
|
||||||
import { ImportedLibraryData } from "./types";
|
import { ImportedLibraryData } from "./types";
|
||||||
@@ -123,48 +123,79 @@ export const isSupportedImageFile = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromBlob = async (
|
export const loadSceneOrLibraryFromBlob = async (
|
||||||
blob: Blob,
|
blob: Blob | File,
|
||||||
/** @see restore.localAppState */
|
/** @see restore.localAppState */
|
||||||
localAppState: AppState | null,
|
localAppState: AppState | null,
|
||||||
localElements: readonly ExcalidrawElement[] | null,
|
localElements: readonly ExcalidrawElement[] | null,
|
||||||
|
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||||
|
fileHandle?: FileSystemHandle | null,
|
||||||
) => {
|
) => {
|
||||||
const contents = await parseFileContents(blob);
|
const contents = await parseFileContents(blob);
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(contents);
|
const data = JSON.parse(contents);
|
||||||
if (!isValidExcalidrawData(data)) {
|
if (isValidExcalidrawData(data)) {
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const result = restore(
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||||
{
|
|
||||||
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) {
|
} catch (error: any) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadLibraryFromBlob = async (
|
export const loadFromBlob = async (
|
||||||
blob: Blob,
|
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",
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||||
) => {
|
) => {
|
||||||
const contents = await parseFileContents(blob);
|
const data: ImportedLibraryData | undefined = JSON.parse(json);
|
||||||
const data: ImportedLibraryData | undefined = JSON.parse(contents);
|
|
||||||
if (!isValidLibrary(data)) {
|
if (!isValidLibrary(data)) {
|
||||||
throw new Error("Invalid library");
|
throw new Error("Invalid library");
|
||||||
}
|
}
|
||||||
@@ -172,6 +203,13 @@ export const loadLibraryFromBlob = async (
|
|||||||
return restoreLibraryItems(libraryItems, defaultStatus);
|
return restoreLibraryItems(libraryItems, defaultStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadLibraryFromBlob = async (
|
||||||
|
blob: Blob,
|
||||||
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||||
|
) => {
|
||||||
|
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
|
||||||
|
};
|
||||||
|
|
||||||
export const canvasToBlob = async (
|
export const canvasToBlob = async (
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
): Promise<Blob> => {
|
): Promise<Blob> => {
|
||||||
@@ -200,7 +238,7 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
|
|||||||
try {
|
try {
|
||||||
const hashBuffer = await window.crypto.subtle.digest(
|
const hashBuffer = await window.crypto.subtle.digest(
|
||||||
"SHA-1",
|
"SHA-1",
|
||||||
await file.arrayBuffer(),
|
await blobToArrayBuffer(file),
|
||||||
);
|
);
|
||||||
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
|
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -289,3 +327,125 @@ export const SVGStringToFile = (SVGString: string, filename: string = "") => {
|
|||||||
type: MIME_TYPES.svg,
|
type: MIME_TYPES.svg,
|
||||||
}) as File & { type: typeof 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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ENCRYPTION_KEY_BITS } from "../constants";
|
import { ENCRYPTION_KEY_BITS } from "../constants";
|
||||||
|
import { blobToArrayBuffer } from "./blob";
|
||||||
|
|
||||||
export const IV_LENGTH_BYTES = 12;
|
export const IV_LENGTH_BYTES = 12;
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ export const encryptData = async (
|
|||||||
: data instanceof Uint8Array
|
: data instanceof Uint8Array
|
||||||
? data
|
? data
|
||||||
: data instanceof Blob
|
: data instanceof Blob
|
||||||
? await data.arrayBuffer()
|
? await blobToArrayBuffer(data)
|
||||||
: data;
|
: data;
|
||||||
|
|
||||||
// We use symmetric encryption. AES-GCM is the recommended algorithm and
|
// We use symmetric encryption. AES-GCM is the recommended algorithm and
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
FileWithHandle,
|
|
||||||
fileOpen as _fileOpen,
|
fileOpen as _fileOpen,
|
||||||
fileSave as _fileSave,
|
fileSave as _fileSave,
|
||||||
FileSystemHandle,
|
FileSystemHandle,
|
||||||
@@ -26,13 +25,9 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
extensions?: FILE_EXTENSION[];
|
extensions?: FILE_EXTENSION[];
|
||||||
description: string;
|
description: string;
|
||||||
multiple?: M;
|
multiple?: M;
|
||||||
}): Promise<
|
}): Promise<M extends false | undefined ? File : File[]> => {
|
||||||
M extends false | undefined ? FileWithHandle : FileWithHandle[]
|
|
||||||
> => {
|
|
||||||
// an unsafe TS hack, alas not much we can do AFAIK
|
// an unsafe TS hack, alas not much we can do AFAIK
|
||||||
type RetType = M extends false | undefined
|
type RetType = M extends false | undefined ? File : File[];
|
||||||
? FileWithHandle
|
|
||||||
: FileWithHandle[];
|
|
||||||
|
|
||||||
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||||
mimeTypes.push(MIME_TYPES[type]);
|
mimeTypes.push(MIME_TYPES[type]);
|
||||||
|
|||||||
+1
-17
@@ -3,28 +3,12 @@ import tEXt from "png-chunk-text";
|
|||||||
import encodePng from "png-chunks-encode";
|
import encodePng from "png-chunks-encode";
|
||||||
import { stringToBase64, encode, decode, base64ToString } from "./encode";
|
import { stringToBase64, encode, decode, base64ToString } from "./encode";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||||
|
import { blobToArrayBuffer } from "./blob";
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// PNG
|
// 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 (
|
export const getTEXtChunk = async (
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
): Promise<{ keyword: string; text: string } | null> => {
|
): Promise<{ keyword: string; text: string } | null> => {
|
||||||
|
|||||||
+8
-16
@@ -9,7 +9,7 @@ import {
|
|||||||
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ExportedDataState,
|
ExportedDataState,
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
ExportedLibraryData,
|
ExportedLibraryData,
|
||||||
ImportedLibraryData,
|
ImportedLibraryData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import Library from "./library";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips out files which are only referenced by deleted elements
|
* Strips out files which are only referenced by deleted elements
|
||||||
@@ -93,13 +92,18 @@ export const loadFromJSON = async (
|
|||||||
localAppState: AppState,
|
localAppState: AppState,
|
||||||
localElements: readonly ExcalidrawElement[] | null,
|
localElements: readonly ExcalidrawElement[] | null,
|
||||||
) => {
|
) => {
|
||||||
const blob = await fileOpen({
|
const file = await fileOpen({
|
||||||
description: "Excalidraw files",
|
description: "Excalidraw files",
|
||||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||||
// extensions: ["json", "excalidraw", "png", "svg"],
|
// extensions: ["json", "excalidraw", "png", "svg"],
|
||||||
});
|
});
|
||||||
return loadFromBlob(blob, localAppState, localElements);
|
return loadFromBlob(
|
||||||
|
await normalizeFile(file),
|
||||||
|
localAppState,
|
||||||
|
localElements,
|
||||||
|
file.handle,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidExcalidrawData = (data?: {
|
export const isValidExcalidrawData = (data?: {
|
||||||
@@ -147,15 +151,3 @@ 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);
|
|
||||||
};
|
|
||||||
|
|||||||
+375
-87
@@ -1,17 +1,26 @@
|
|||||||
import { loadLibraryFromBlob } from "./blob";
|
import { loadLibraryFromBlob } from "./blob";
|
||||||
import { LibraryItems, LibraryItem } from "../types";
|
import {
|
||||||
|
LibraryItems,
|
||||||
|
LibraryItem,
|
||||||
|
ExcalidrawImperativeAPI,
|
||||||
|
LibraryItemsSource,
|
||||||
|
} from "../types";
|
||||||
import { restoreLibraryItems } from "./restore";
|
import { restoreLibraryItems } from "./restore";
|
||||||
import type App from "../components/App";
|
import type App from "../components/App";
|
||||||
import { ImportedDataState } from "./types";
|
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { jotaiStore } from "../jotai";
|
import { jotaiStore } from "../jotai";
|
||||||
import { isPromiseLike } from "../utils";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
|
import { AbortError } from "../errors";
|
||||||
import { t } from "../i18n";
|
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<
|
export const libraryItemsAtom = atom<{
|
||||||
| { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
|
status: "loading" | "loaded";
|
||||||
| { status: "loaded"; libraryItems: LibraryItems }
|
isInitialized: boolean;
|
||||||
>({ status: "loaded", libraryItems: [] });
|
libraryItems: LibraryItems;
|
||||||
|
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
||||||
|
|
||||||
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
||||||
JSON.parse(JSON.stringify(libraryItems));
|
JSON.parse(JSON.stringify(libraryItems));
|
||||||
@@ -40,12 +49,28 @@ 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 {
|
class Library {
|
||||||
/** cache for currently active promise when initializing/updating libaries
|
/** latest libraryItems */
|
||||||
asynchronously */
|
|
||||||
private libraryItemsPromise: Promise<LibraryItems> | null = null;
|
|
||||||
/** last resolved libraryItems */
|
|
||||||
private lastLibraryItems: 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;
|
private app: App;
|
||||||
|
|
||||||
@@ -53,96 +78,359 @@ class Library {
|
|||||||
this.app = app;
|
this.app = app;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetLibrary = async () => {
|
private updateQueue: Promise<LibraryItems>[] = [];
|
||||||
this.saveLibrary([]);
|
|
||||||
|
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
|
||||||
|
return this.updateQueue[this.updateQueue.length - 1];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** imports library (currently merges, removing duplicates) */
|
private notifyListeners = () => {
|
||||||
async importLibrary(
|
if (this.updateQueue.length > 0) {
|
||||||
library:
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
| Blob
|
status: "loading",
|
||||||
| Required<ImportedDataState>["libraryItems"]
|
libraryItems: this.lastLibraryItems,
|
||||||
| Promise<Required<ImportedDataState>["libraryItems"]>,
|
isInitialized: this.isInitialized,
|
||||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
});
|
||||||
) {
|
} else {
|
||||||
return this.saveLibrary(
|
this.isInitialized = true;
|
||||||
new Promise<LibraryItems>(async (resolve, reject) => {
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
try {
|
status: "loaded",
|
||||||
let libraryItems: LibraryItems;
|
libraryItems: this.lastLibraryItems,
|
||||||
if (library instanceof Blob) {
|
isInitialized: this.isInitialized,
|
||||||
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
|
});
|
||||||
} else {
|
try {
|
||||||
libraryItems = restoreLibraryItems(await library, defaultStatus);
|
this.app.props.onLibraryChange?.(
|
||||||
}
|
cloneLibraryItems(this.lastLibraryItems),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const existingLibraryItems = this.lastLibraryItems;
|
resetLibrary = () => {
|
||||||
|
return this.setLibrary([]);
|
||||||
|
};
|
||||||
|
|
||||||
const filteredItems = [];
|
/**
|
||||||
for (const item of libraryItems) {
|
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
|
||||||
if (isUniqueItem(existingLibraryItems, item)) {
|
*/
|
||||||
filteredItems.push(item);
|
getLatestLibrary = (): Promise<LibraryItems> => {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve([...filteredItems, ...existingLibraryItems]);
|
|
||||||
} catch (error) {
|
|
||||||
reject(new Error(t("errors.importLibraryError")));
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadLibrary = (): Promise<LibraryItems> => {
|
|
||||||
return new Promise(async (resolve) => {
|
return new Promise(async (resolve) => {
|
||||||
try {
|
try {
|
||||||
resolve(
|
const libraryItems = await (this.getLastUpdateTask() ||
|
||||||
cloneLibraryItems(
|
this.lastLibraryItems);
|
||||||
await (this.libraryItemsPromise || this.lastLibraryItems),
|
if (this.updateQueue.length > 0) {
|
||||||
),
|
resolve(this.getLatestLibrary());
|
||||||
);
|
} else {
|
||||||
|
resolve(cloneLibraryItems(libraryItems));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return resolve(this.lastLibraryItems);
|
return resolve(this.lastLibraryItems);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
|
// NOTE this is a high-level public API (exposed on ExcalidrawAPI) with
|
||||||
const prevLibraryItems = this.lastLibraryItems;
|
// a slight overhead (always restoring library items). For internal use
|
||||||
try {
|
// where merging isn't needed, use `library.setLibrary()` directly.
|
||||||
let nextLibraryItems;
|
updateLibrary = async ({
|
||||||
if (isPromiseLike(items)) {
|
libraryItems,
|
||||||
const promise = items.then((items) => cloneLibraryItems(items));
|
prompt = false,
|
||||||
this.libraryItemsPromise = promise;
|
merge = false,
|
||||||
jotaiStore.set(libraryItemsAtom, {
|
openLibraryMenu = false,
|
||||||
status: "loading",
|
defaultStatus = "unpublished",
|
||||||
promise,
|
}: {
|
||||||
libraryItems: null,
|
libraryItems: LibraryItemsSource;
|
||||||
});
|
merge?: boolean;
|
||||||
nextLibraryItems = await promise;
|
prompt?: boolean;
|
||||||
} else {
|
openLibraryMenu?: boolean;
|
||||||
nextLibraryItems = cloneLibraryItems(items);
|
defaultStatus?: "unpublished" | "published";
|
||||||
}
|
}): Promise<LibraryItems> => {
|
||||||
|
if (openLibraryMenu) {
|
||||||
this.lastLibraryItems = nextLibraryItems;
|
this.app.setState({ isLibraryOpen: true });
|
||||||
this.libraryItemsPromise = null;
|
|
||||||
|
|
||||||
jotaiStore.set(libraryItemsAtom, {
|
|
||||||
status: "loaded",
|
|
||||||
libraryItems: nextLibraryItems,
|
|
||||||
});
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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.updateQueue.push(task);
|
||||||
|
this.notifyListeners();
|
||||||
|
|
||||||
|
return task;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Library;
|
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]);
|
||||||
|
};
|
||||||
|
|||||||
+16
-4
@@ -26,7 +26,7 @@ import {
|
|||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { bumpVersion } from "../element/mutateElement";
|
import { bumpVersion } from "../element/mutateElement";
|
||||||
import { getUpdatedTimestamp } from "../utils";
|
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
@@ -48,6 +48,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||||||
arrow: true,
|
arrow: true,
|
||||||
freedraw: true,
|
freedraw: true,
|
||||||
eraser: false,
|
eraser: false,
|
||||||
|
custom: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RestoredDataState = {
|
export type RestoredDataState = {
|
||||||
@@ -198,6 +199,7 @@ const restoreElement = (
|
|||||||
y,
|
y,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// generic elements
|
// generic elements
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return restoreElementWithProperties(element, {});
|
return restoreElementWithProperties(element, {});
|
||||||
@@ -255,6 +257,7 @@ export const restoreAppState = (
|
|||||||
? localValue
|
? localValue
|
||||||
: defaultValue;
|
: defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
cursorButton: localAppState?.cursorButton || "up",
|
cursorButton: localAppState?.cursorButton || "up",
|
||||||
@@ -263,11 +266,15 @@ export const restoreAppState = (
|
|||||||
localAppState?.penDetected ??
|
localAppState?.penDetected ??
|
||||||
(appState.penMode ? appState.penDetected ?? false : false),
|
(appState.penMode ? appState.penDetected ?? false : false),
|
||||||
activeTool: {
|
activeTool: {
|
||||||
|
...updateActiveTool(
|
||||||
|
defaultAppState,
|
||||||
|
nextAppState.activeTool.type &&
|
||||||
|
AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
|
||||||
|
? nextAppState.activeTool
|
||||||
|
: { type: "selection" },
|
||||||
|
),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
locked: nextAppState.activeTool.locked ?? false,
|
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
|
// Migrates from previous version where appState.zoom was a number
|
||||||
zoom:
|
zoom:
|
||||||
@@ -276,6 +283,11 @@ export const restoreAppState = (
|
|||||||
value: appState.zoom as NormalizedZoomValue,
|
value: appState.zoom as NormalizedZoomValue,
|
||||||
}
|
}
|
||||||
: appState.zoom || defaultAppState.zoom,
|
: appState.zoom || defaultAppState.zoom,
|
||||||
|
// when sidebar docked and user left it open in last session,
|
||||||
|
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||||
|
isLibraryOpen: nextAppState.isLibraryMenuDocked
|
||||||
|
? nextAppState.isLibraryOpen
|
||||||
|
: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -1,5 +1,10 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
|
import {
|
||||||
|
AppState,
|
||||||
|
BinaryFiles,
|
||||||
|
LibraryItems,
|
||||||
|
LibraryItems_anyVersion,
|
||||||
|
} from "../types";
|
||||||
import type { cleanAppStateForExport } from "../appState";
|
import type { cleanAppStateForExport } from "../appState";
|
||||||
import { VERSIONS } from "../constants";
|
import { VERSIONS } from "../constants";
|
||||||
|
|
||||||
@@ -19,7 +24,7 @@ export interface ImportedDataState {
|
|||||||
elements?: readonly ExcalidrawElement[] | null;
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
appState?: Readonly<Partial<AppState>> | null;
|
appState?: Readonly<Partial<AppState>> | null;
|
||||||
scrollToContent?: boolean;
|
scrollToContent?: boolean;
|
||||||
libraryItems?: LibraryItems | LibraryItems_v1;
|
libraryItems?: LibraryItems_anyVersion;
|
||||||
files?: BinaryFiles;
|
files?: BinaryFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+62
-45
@@ -47,6 +47,20 @@ export const isBindingEnabled = (appState: AppState): boolean => {
|
|||||||
return appState.isBindingEnabled;
|
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 = (
|
export const bindOrUnbindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startBindingElement: ExcalidrawBindableElement | null | "keep",
|
startBindingElement: ExcalidrawBindableElement | null | "keep",
|
||||||
@@ -74,16 +88,17 @@ export const bindOrUnbindLinearElement = (
|
|||||||
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||||
(id) => !boundToElementIds.has(id),
|
(id) => !boundToElementIds.has(id),
|
||||||
);
|
);
|
||||||
Scene.getScene(linearElement)!
|
|
||||||
.getNonDeletedElements(onlyUnbound)
|
getNonDeletedElements(Scene.getScene(linearElement)!, onlyUnbound).forEach(
|
||||||
.forEach((element) => {
|
(element) => {
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
boundElements: element.boundElements?.filter(
|
boundElements: element.boundElements?.filter(
|
||||||
(element) =>
|
(element) =>
|
||||||
element.type !== "arrow" || element.id !== linearElement.id,
|
element.type !== "arrow" || element.id !== linearElement.id,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const bindOrUnbindLinearElementEdge = (
|
const bindOrUnbindLinearElementEdge = (
|
||||||
@@ -253,7 +268,7 @@ export const getHoveredElementForBinding = (
|
|||||||
scene: Scene,
|
scene: Scene,
|
||||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
const hoveredElement = getElementAtPosition(
|
const hoveredElement = getElementAtPosition(
|
||||||
scene.getElements(),
|
scene.getNonDeletedElements(),
|
||||||
(element) =>
|
(element) =>
|
||||||
isBindableElement(element, false) &&
|
isBindableElement(element, false) &&
|
||||||
bindingBorderTest(element, pointerCoords),
|
bindingBorderTest(element, pointerCoords),
|
||||||
@@ -305,46 +320,48 @@ export const updateBoundElements = (
|
|||||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||||
simultaneouslyUpdated,
|
simultaneouslyUpdated,
|
||||||
);
|
);
|
||||||
Scene.getScene(changedElement)!
|
|
||||||
.getNonDeletedElements(boundLinearElements.map((el) => el.id))
|
|
||||||
.forEach((element) => {
|
|
||||||
if (!isLinearElement(element)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bindableElement = changedElement as ExcalidrawBindableElement;
|
getNonDeletedElements(
|
||||||
// In case the boundElements are stale
|
Scene.getScene(changedElement)!,
|
||||||
if (!doesNeedUpdate(element, bindableElement)) {
|
boundLinearElements.map((el) => el.id),
|
||||||
return;
|
).forEach((element) => {
|
||||||
}
|
if (!isLinearElement(element)) {
|
||||||
const startBinding = maybeCalculateNewGapWhenScaling(
|
return;
|
||||||
bindableElement,
|
}
|
||||||
element.startBinding,
|
|
||||||
newSize,
|
const bindableElement = changedElement as ExcalidrawBindableElement;
|
||||||
);
|
// In case the boundElements are stale
|
||||||
const endBinding = maybeCalculateNewGapWhenScaling(
|
if (!doesNeedUpdate(element, bindableElement)) {
|
||||||
bindableElement,
|
return;
|
||||||
element.endBinding,
|
}
|
||||||
newSize,
|
const startBinding = maybeCalculateNewGapWhenScaling(
|
||||||
);
|
bindableElement,
|
||||||
// `linearElement` is being moved/scaled already, just update the binding
|
element.startBinding,
|
||||||
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
newSize,
|
||||||
mutateElement(element, { startBinding, endBinding });
|
);
|
||||||
return;
|
const endBinding = maybeCalculateNewGapWhenScaling(
|
||||||
}
|
bindableElement,
|
||||||
updateBoundPoint(
|
element.endBinding,
|
||||||
element,
|
newSize,
|
||||||
"start",
|
);
|
||||||
startBinding,
|
// `linearElement` is being moved/scaled already, just update the binding
|
||||||
changedElement as ExcalidrawBindableElement,
|
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||||
);
|
mutateElement(element, { startBinding, endBinding });
|
||||||
updateBoundPoint(
|
return;
|
||||||
element,
|
}
|
||||||
"end",
|
updateBoundPoint(
|
||||||
endBinding,
|
element,
|
||||||
changedElement as ExcalidrawBindableElement,
|
"start",
|
||||||
);
|
startBinding,
|
||||||
});
|
changedElement as ExcalidrawBindableElement,
|
||||||
|
);
|
||||||
|
updateBoundPoint(
|
||||||
|
element,
|
||||||
|
"end",
|
||||||
|
endBinding,
|
||||||
|
changedElement as ExcalidrawBindableElement,
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const doesNeedUpdate = (
|
const doesNeedUpdate = (
|
||||||
@@ -507,7 +524,7 @@ const getElligibleElementsForBindableElementAndWhere = (
|
|||||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||||
): SuggestedPointBinding[] => {
|
): SuggestedPointBinding[] => {
|
||||||
return Scene.getScene(bindableElement)!
|
return Scene.getScene(bindableElement)!
|
||||||
.getElements()
|
.getNonDeletedElements()
|
||||||
.map((element) => {
|
.map((element) => {
|
||||||
if (!isBindingElement(element, false)) {
|
if (!isBindingElement(element, false)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ export const showSelectedShapeActions = (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
) =>
|
) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
!appState.viewModeEnabled &&
|
(!appState.viewModeEnabled &&
|
||||||
|
appState.activeTool.type !== "custom" &&
|
||||||
(appState.editingElement ||
|
(appState.editingElement ||
|
||||||
getSelectedElements(elements, appState).length ||
|
|
||||||
(appState.activeTool.type !== "selection" &&
|
(appState.activeTool.type !== "selection" &&
|
||||||
appState.activeTool.type !== "eraser")),
|
appState.activeTool.type !== "eraser"))) ||
|
||||||
|
getSelectedElements(elements, appState).length,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ describe("textWysiwyg", () => {
|
|||||||
height: textSize,
|
height: textSize,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
});
|
});
|
||||||
|
mutateElement(container, {
|
||||||
|
boundElements: [{ type: "text", id: text.id }],
|
||||||
|
});
|
||||||
|
|
||||||
h.elements = [container, text];
|
h.elements = [container, text];
|
||||||
|
|
||||||
@@ -544,6 +547,29 @@ describe("textWysiwyg", () => {
|
|||||||
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
|
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 () => {
|
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);
|
expect(h.elements.length).toBe(1);
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,14 @@ export const textWysiwyg = ({
|
|||||||
// using scrollHeight here since we need to calculate
|
// using scrollHeight here since we need to calculate
|
||||||
// number of lines so cannot use editable.style.height
|
// number of lines so cannot use editable.style.height
|
||||||
// as that gets updated below
|
// as that gets updated below
|
||||||
const lines = editable.scrollHeight / getApproxLineHeight(font);
|
// 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),
|
||||||
|
);
|
||||||
// auto increase height only when lines > 1 so its
|
// auto increase height only when lines > 1 so its
|
||||||
// measured correctly and vertically aligns for
|
// measured correctly and vertically aligns for
|
||||||
// first line as well as setting height to "auto"
|
// first line as well as setting height to "auto"
|
||||||
@@ -298,7 +305,6 @@ export const textWysiwyg = ({
|
|||||||
font,
|
font,
|
||||||
container!.width,
|
container!.width,
|
||||||
).split("\n").length;
|
).split("\n").length;
|
||||||
|
|
||||||
// This is browser behaviour when setting height to "auto"
|
// This is browser behaviour when setting height to "auto"
|
||||||
// It sets the height needed for 2 lines even if actual
|
// It sets the height needed for 2 lines even if actual
|
||||||
// line count is 1 as mentioned above as well
|
// line count is 1 as mentioned above as well
|
||||||
@@ -316,8 +322,6 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
editable.onkeydown = (event) => {
|
editable.onkeydown = (event) => {
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
if (!event.shiftKey && actionZoomIn.keyTest(event)) {
|
if (!event.shiftKey && actionZoomIn.keyTest(event)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
app.actionManager.executeAction(actionZoomIn);
|
app.actionManager.executeAction(actionZoomIn);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
|||||||
}, STORAGE_SIZE_TIMEOUT);
|
}, STORAGE_SIZE_TIMEOUT);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
setToastMessage: (message: string) => void;
|
setToast: (message: string) => void;
|
||||||
};
|
};
|
||||||
const CustomStats = (props: Props) => {
|
const CustomStats = (props: Props) => {
|
||||||
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||||
@@ -68,7 +68,7 @@ const CustomStats = (props: Props) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await copyTextToSystemClipboard(getVersion());
|
await copyTextToSystemClipboard(getVersion());
|
||||||
props.setToastMessage(t("toast.copyToClipboard"));
|
props.setToast(t("toast.copyToClipboard"));
|
||||||
} catch {}
|
} catch {}
|
||||||
}}
|
}}
|
||||||
title={t("stats.versionCopy")}
|
title={t("stats.versionCopy")}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const LOAD_IMAGES_TIMEOUT = 500;
|
|||||||
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
||||||
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
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
|
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||||
|
|||||||
+127
-124
@@ -8,10 +8,12 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { getSceneVersion } from "../../packages/excalidraw/index";
|
import {
|
||||||
|
getSceneVersion,
|
||||||
|
restoreElements,
|
||||||
|
} from "../../packages/excalidraw/index";
|
||||||
import { Collaborator, Gesture } from "../../types";
|
import { Collaborator, Gesture } from "../../types";
|
||||||
import {
|
import {
|
||||||
getFrame,
|
|
||||||
preventUnload,
|
preventUnload,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
withBatchedUpdates,
|
withBatchedUpdates,
|
||||||
@@ -30,7 +32,9 @@ import {
|
|||||||
generateCollaborationLinkData,
|
generateCollaborationLinkData,
|
||||||
getCollaborationLink,
|
getCollaborationLink,
|
||||||
getCollabServer,
|
getCollabServer,
|
||||||
|
getSyncableElements,
|
||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
|
SyncableExcalidrawElement,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import {
|
import {
|
||||||
isSavedToFirebase,
|
isSavedToFirebase,
|
||||||
@@ -45,12 +49,9 @@ import {
|
|||||||
} from "../data/localStorage";
|
} from "../data/localStorage";
|
||||||
import Portal from "./Portal";
|
import Portal from "./Portal";
|
||||||
import RoomDialog from "./RoomDialog";
|
import RoomDialog from "./RoomDialog";
|
||||||
import { createInverseContext } from "../../createInverseContext";
|
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { UserIdleState } from "../../types";
|
import { UserIdleState } from "../../types";
|
||||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||||
import { trackEvent } from "../../analytics";
|
|
||||||
import { isInvisiblySmallElement } from "../../element";
|
|
||||||
import {
|
import {
|
||||||
encodeFilesForUpload,
|
encodeFilesForUpload,
|
||||||
FileManager,
|
FileManager,
|
||||||
@@ -69,52 +70,45 @@ import {
|
|||||||
import { decryptData } from "../../data/encryption";
|
import { decryptData } from "../../data/encryption";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { jotaiStore } from "../../jotai";
|
||||||
|
|
||||||
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
|
export const collabDialogShownAtom = atom(false);
|
||||||
|
export const isCollaboratingAtom = atom(false);
|
||||||
|
|
||||||
interface CollabState {
|
interface CollabState {
|
||||||
modalIsShown: boolean;
|
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
username: string;
|
username: string;
|
||||||
userState: UserIdleState;
|
|
||||||
activeRoomLink: string;
|
activeRoomLink: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
type CollabInstance = InstanceType<typeof Collab>;
|
||||||
|
|
||||||
export interface CollabAPI {
|
export interface CollabAPI {
|
||||||
/** function so that we can access the latest value from stale callbacks */
|
/** function so that we can access the latest value from stale callbacks */
|
||||||
isCollaborating: () => boolean;
|
isCollaborating: () => boolean;
|
||||||
username: CollabState["username"];
|
|
||||||
userState: CollabState["userState"];
|
|
||||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
startCollaboration: CollabInstance["startCollaboration"];
|
||||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||||
syncElements: CollabInstance["syncElements"];
|
syncElements: CollabInstance["syncElements"];
|
||||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||||
setUsername: (username: string) => void;
|
setUsername: (username: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface PublicProps {
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
onRoomClose?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
type Props = PublicProps & { modalIsShown: boolean };
|
||||||
Context: CollabContext,
|
|
||||||
Consumer: CollabContextConsumer,
|
|
||||||
Provider: CollabContextProvider,
|
|
||||||
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
|
|
||||||
|
|
||||||
export { CollabContext, CollabContextConsumer };
|
class Collab extends PureComponent<Props, CollabState> {
|
||||||
|
|
||||||
class CollabWrapper extends PureComponent<Props, CollabState> {
|
|
||||||
portal: Portal;
|
portal: Portal;
|
||||||
fileManager: FileManager;
|
fileManager: FileManager;
|
||||||
excalidrawAPI: Props["excalidrawAPI"];
|
excalidrawAPI: Props["excalidrawAPI"];
|
||||||
activeIntervalId: number | null;
|
activeIntervalId: number | null;
|
||||||
idleTimeoutId: number | null;
|
idleTimeoutId: number | null;
|
||||||
|
|
||||||
// marked as private to ensure we don't change it outside this class
|
|
||||||
private _isCollaborating: boolean = false;
|
|
||||||
private socketInitializationTimer?: number;
|
private socketInitializationTimer?: number;
|
||||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||||
private collaborators = new Map<string, Collaborator>();
|
private collaborators = new Map<string, Collaborator>();
|
||||||
@@ -122,10 +116,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
modalIsShown: false,
|
|
||||||
errorMessage: "",
|
errorMessage: "",
|
||||||
username: importUsernameFromLocalStorage() || "",
|
username: importUsernameFromLocalStorage() || "",
|
||||||
userState: UserIdleState.ACTIVE,
|
|
||||||
activeRoomLink: "",
|
activeRoomLink: "",
|
||||||
};
|
};
|
||||||
this.portal = new Portal(this);
|
this.portal = new Portal(this);
|
||||||
@@ -163,6 +155,18 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||||
|
|
||||||
|
const collabAPI: CollabAPI = {
|
||||||
|
isCollaborating: this.isCollaborating,
|
||||||
|
onPointerUpdate: this.onPointerUpdate,
|
||||||
|
startCollaboration: this.startCollaboration,
|
||||||
|
syncElements: this.syncElements,
|
||||||
|
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||||
|
stopCollaboration: this.stopCollaboration,
|
||||||
|
setUsername: this.setUsername,
|
||||||
|
};
|
||||||
|
|
||||||
|
jotaiStore.set(collabAPIAtom, collabAPI);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.env.NODE_ENV === ENV.TEST ||
|
process.env.NODE_ENV === ENV.TEST ||
|
||||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||||
@@ -195,19 +199,23 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isCollaborating = () => this._isCollaborating;
|
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
|
||||||
|
|
||||||
|
private setIsCollaborating = (isCollaborating: boolean) => {
|
||||||
|
jotaiStore.set(isCollaboratingAtom, isCollaborating);
|
||||||
|
};
|
||||||
|
|
||||||
private onUnload = () => {
|
private onUnload = () => {
|
||||||
this.destroySocketClient({ isUnload: true });
|
this.destroySocketClient({ isUnload: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||||
const syncableElements = this.getSyncableElements(
|
const syncableElements = getSyncableElements(
|
||||||
this.getSceneElementsIncludingDeleted(),
|
this.getSceneElementsIncludingDeleted(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this._isCollaborating &&
|
this.isCollaborating() &&
|
||||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||||
!isSavedToFirebase(this.portal, syncableElements))
|
!isSavedToFirebase(this.portal, syncableElements))
|
||||||
) {
|
) {
|
||||||
@@ -232,7 +240,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
saveCollabRoomToFirebase = async (
|
saveCollabRoomToFirebase = async (
|
||||||
syncableElements: readonly ExcalidrawElement[],
|
syncableElements: readonly SyncableExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const savedData = await saveToFirebase(
|
const savedData = await saveToFirebase(
|
||||||
@@ -251,31 +259,36 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
openPortal = async () => {
|
stopCollaboration = (keepRemoteState = true) => {
|
||||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
|
||||||
return this.initializeSocketClient(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
closePortal = () => {
|
|
||||||
this.queueBroadcastAllElements.cancel();
|
this.queueBroadcastAllElements.cancel();
|
||||||
this.queueSaveToFirebase.cancel();
|
this.queueSaveToFirebase.cancel();
|
||||||
this.loadImageFiles.cancel();
|
this.loadImageFiles.cancel();
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(
|
this.saveCollabRoomToFirebase(
|
||||||
this.getSyncableElements(
|
getSyncableElements(
|
||||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
|
||||||
|
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||||
|
this.portal.socket.off(
|
||||||
|
"connect_error",
|
||||||
|
this.fallbackInitializationHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keepRemoteState) {
|
||||||
|
LocalData.fileStorage.reset();
|
||||||
|
this.destroySocketClient();
|
||||||
|
} else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||||
// hack to ensure that we prefer we disregard any new browser state
|
// hack to ensure that we prefer we disregard any new browser state
|
||||||
// that could have been saved in other tabs while we were collaborating
|
// that could have been saved in other tabs while we were collaborating
|
||||||
resetBrowserStateVersions();
|
resetBrowserStateVersions();
|
||||||
|
|
||||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||||
this.destroySocketClient();
|
this.destroySocketClient();
|
||||||
trackEvent("share", "room closed");
|
|
||||||
|
|
||||||
this.props.onRoomClose?.();
|
LocalData.fileStorage.reset();
|
||||||
|
|
||||||
const elements = this.excalidrawAPI
|
const elements = this.excalidrawAPI
|
||||||
.getSceneElementsIncludingDeleted()
|
.getSceneElementsIncludingDeleted()
|
||||||
@@ -294,20 +307,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||||
|
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||||
|
this.portal.close();
|
||||||
|
this.fileManager.reset();
|
||||||
if (!opts?.isUnload) {
|
if (!opts?.isUnload) {
|
||||||
|
this.setIsCollaborating(false);
|
||||||
|
this.setState({
|
||||||
|
activeRoomLink: "",
|
||||||
|
});
|
||||||
this.collaborators = new Map();
|
this.collaborators = new Map();
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
collaborators: this.collaborators,
|
collaborators: this.collaborators,
|
||||||
});
|
});
|
||||||
this.setState({
|
|
||||||
activeRoomLink: "",
|
|
||||||
});
|
|
||||||
this._isCollaborating = false;
|
|
||||||
LocalData.resumeSave("collaboration");
|
LocalData.resumeSave("collaboration");
|
||||||
}
|
}
|
||||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
|
||||||
this.portal.close();
|
|
||||||
this.fileManager.reset();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private fetchImageFilesFromFirebase = async (scene: {
|
private fetchImageFilesFromFirebase = async (scene: {
|
||||||
@@ -348,7 +361,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private initializeSocketClient = async (
|
private fallbackInitializationHandler: null | (() => any) = null;
|
||||||
|
|
||||||
|
startCollaboration = async (
|
||||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||||
): Promise<ImportedDataState | null> => {
|
): Promise<ImportedDataState | null> => {
|
||||||
if (this.portal.socket) {
|
if (this.portal.socket) {
|
||||||
@@ -371,13 +386,23 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||||
|
|
||||||
this._isCollaborating = true;
|
this.setIsCollaborating(true);
|
||||||
LocalData.pauseSave("collaboration");
|
LocalData.pauseSave("collaboration");
|
||||||
|
|
||||||
const { default: socketIOClient } = await import(
|
const { default: socketIOClient } = await import(
|
||||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fallbackInitializationHandler = () => {
|
||||||
|
this.initializeRoom({
|
||||||
|
roomLinkData: existingRoomLinkData,
|
||||||
|
fetchScene: true,
|
||||||
|
}).then((scene) => {
|
||||||
|
scenePromise.resolve(scene);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.fallbackInitializationHandler = fallbackInitializationHandler;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const socketServerData = await getCollabServer();
|
const socketServerData = await getCollabServer();
|
||||||
|
|
||||||
@@ -390,6 +415,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
roomId,
|
roomId,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
this.setState({ errorMessage: error.message });
|
this.setState({ errorMessage: error.message });
|
||||||
@@ -413,18 +440,15 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
|
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback in case you're not alone in the room but still don't receive
|
// fallback in case you're not alone in the room but still don't receive
|
||||||
// initial SCENE_INIT message
|
// initial SCENE_INIT message
|
||||||
this.socketInitializationTimer = window.setTimeout(() => {
|
this.socketInitializationTimer = window.setTimeout(
|
||||||
this.initializeRoom({
|
fallbackInitializationHandler,
|
||||||
roomLinkData: existingRoomLinkData,
|
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||||
fetchScene: true,
|
);
|
||||||
});
|
|
||||||
scenePromise.resolve(null);
|
|
||||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
|
||||||
|
|
||||||
// All socket listeners are moving to Portal
|
// All socket listeners are moving to Portal
|
||||||
this.portal.socket.on(
|
this.portal.socket.on(
|
||||||
@@ -529,6 +553,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
| { fetchScene: false; roomLinkData?: null }) => {
|
| { fetchScene: false; roomLinkData?: null }) => {
|
||||||
clearTimeout(this.socketInitializationTimer!);
|
clearTimeout(this.socketInitializationTimer!);
|
||||||
|
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||||
|
this.portal.socket.off(
|
||||||
|
"connect_error",
|
||||||
|
this.fallbackInitializationHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (fetchScene && roomLinkData && this.portal.socket) {
|
if (fetchScene && roomLinkData && this.portal.socket) {
|
||||||
this.excalidrawAPI.resetScene();
|
this.excalidrawAPI.resetScene();
|
||||||
|
|
||||||
@@ -566,6 +596,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
const localElements = this.getSceneElementsIncludingDeleted();
|
const localElements = this.getSceneElementsIncludingDeleted();
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
|
|
||||||
|
remoteElements = restoreElements(remoteElements, null);
|
||||||
|
|
||||||
const reconciledElements = _reconcileElements(
|
const reconciledElements = _reconcileElements(
|
||||||
localElements,
|
localElements,
|
||||||
remoteElements,
|
remoteElements,
|
||||||
@@ -671,19 +703,17 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setCollaborators(sockets: string[]) {
|
setCollaborators(sockets: string[]) {
|
||||||
this.setState((state) => {
|
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||||
const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
|
new Map();
|
||||||
new Map();
|
for (const socketId of sockets) {
|
||||||
for (const socketId of sockets) {
|
if (this.collaborators.has(socketId)) {
|
||||||
if (this.collaborators.has(socketId)) {
|
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
} else {
|
||||||
} else {
|
collaborators.set(socketId, {});
|
||||||
collaborators.set(socketId, {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.collaborators = collaborators;
|
}
|
||||||
this.excalidrawAPI.updateScene({ collaborators });
|
this.collaborators = collaborators;
|
||||||
});
|
this.excalidrawAPI.updateScene({ collaborators });
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||||
@@ -712,7 +742,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
onIdleStateChange = (userState: UserIdleState) => {
|
onIdleStateChange = (userState: UserIdleState) => {
|
||||||
this.setState({ userState });
|
|
||||||
this.portal.broadcastIdleChange(userState);
|
this.portal.broadcastIdleChange(userState);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -746,18 +775,22 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||||
|
|
||||||
queueSaveToFirebase = throttle(() => {
|
queueSaveToFirebase = throttle(
|
||||||
if (this.portal.socketInitialized) {
|
() => {
|
||||||
this.saveCollabRoomToFirebase(
|
if (this.portal.socketInitialized) {
|
||||||
this.getSyncableElements(
|
this.saveCollabRoomToFirebase(
|
||||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
getSyncableElements(
|
||||||
),
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
}
|
||||||
|
},
|
||||||
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||||
|
{ leading: false },
|
||||||
|
);
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = () => {
|
||||||
this.setState({ modalIsShown: false });
|
jotaiStore.set(collabDialogShownAtom, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
setUsername = (username: string) => {
|
setUsername = (username: string) => {
|
||||||
@@ -769,42 +802,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
saveUsernameToLocalStorage(username);
|
saveUsernameToLocalStorage(username);
|
||||||
};
|
};
|
||||||
|
|
||||||
onCollabButtonClick = () => {
|
|
||||||
this.setState({
|
|
||||||
modalIsShown: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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() {
|
render() {
|
||||||
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
const { username, errorMessage, activeRoomLink } = this.state;
|
||||||
|
|
||||||
|
const { modalIsShown } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -814,8 +815,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
activeRoomLink={activeRoomLink}
|
activeRoomLink={activeRoomLink}
|
||||||
username={username}
|
username={username}
|
||||||
onUsernameChange={this.onUsernameChange}
|
onUsernameChange={this.onUsernameChange}
|
||||||
onRoomCreate={this.openPortal}
|
onRoomCreate={() => this.startCollaboration(null)}
|
||||||
onRoomDestroy={this.closePortal}
|
onRoomDestroy={this.stopCollaboration}
|
||||||
setErrorMessage={(errorMessage) => {
|
setErrorMessage={(errorMessage) => {
|
||||||
this.setState({ errorMessage });
|
this.setState({ errorMessage });
|
||||||
}}
|
}}
|
||||||
@@ -828,11 +829,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
onClose={() => this.setState({ errorMessage: "" })}
|
onClose={() => this.setState({ errorMessage: "" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CollabContextProvider
|
|
||||||
value={{
|
|
||||||
api: this.getContextValue(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -840,7 +836,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
collab: InstanceType<typeof CollabWrapper>;
|
collab: InstanceType<typeof Collab>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,4 +847,11 @@ if (
|
|||||||
window.collab = window.collab || ({} as Window["collab"]);
|
window.collab = window.collab || ({} as Window["collab"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CollabWrapper;
|
const _Collab: React.FC<PublicProps> = (props) => {
|
||||||
|
const [collabDialogShown] = useAtom(collabDialogShownAtom);
|
||||||
|
return <Collab {...props} modalIsShown={collabDialogShown} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default _Collab;
|
||||||
|
|
||||||
|
export type TCollabClass = Collab;
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { SocketUpdateData, SocketUpdateDataSource } from "../data";
|
import {
|
||||||
|
isSyncableElement,
|
||||||
|
SocketUpdateData,
|
||||||
|
SocketUpdateDataSource,
|
||||||
|
} from "../data";
|
||||||
|
|
||||||
import CollabWrapper from "./CollabWrapper";
|
import { TCollabClass } from "./Collab";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import {
|
import {
|
||||||
@@ -16,14 +20,14 @@ import { BroadcastedExcalidrawElement } from "./reconciliation";
|
|||||||
import { encryptData } from "../../data/encryption";
|
import { encryptData } from "../../data/encryption";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
collab: CollabWrapper;
|
collab: TCollabClass;
|
||||||
socket: SocketIOClient.Socket | null = null;
|
socket: SocketIOClient.Socket | null = null;
|
||||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||||
roomId: string | null = null;
|
roomId: string | null = null;
|
||||||
roomKey: string | null = null;
|
roomKey: string | null = null;
|
||||||
broadcastedElementVersions: Map<string, number> = new Map();
|
broadcastedElementVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
constructor(collab: CollabWrapper) {
|
constructor(collab: TCollabClass) {
|
||||||
this.collab = collab;
|
this.collab = collab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +147,7 @@ class Portal {
|
|||||||
!this.broadcastedElementVersions.has(element.id) ||
|
!this.broadcastedElementVersions.has(element.id) ||
|
||||||
element.version >
|
element.version >
|
||||||
this.broadcastedElementVersions.get(element.id)!) &&
|
this.broadcastedElementVersions.get(element.id)!) &&
|
||||||
this.collab.isSyncableElement(element)
|
isSyncableElement(element)
|
||||||
) {
|
) {
|
||||||
acc.push({
|
acc.push({
|
||||||
...element,
|
...element,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { t } from "../../i18n";
|
|||||||
import "./RoomDialog.scss";
|
import "./RoomDialog.scss";
|
||||||
import Stack from "../../components/Stack";
|
import Stack from "../../components/Stack";
|
||||||
import { AppState } from "../../types";
|
import { AppState } from "../../types";
|
||||||
|
import { trackEvent } from "../../analytics";
|
||||||
|
import { getFrame } from "../../utils";
|
||||||
|
|
||||||
const getShareIcon = () => {
|
const getShareIcon = () => {
|
||||||
const navigator = window.navigator as any;
|
const navigator = window.navigator as any;
|
||||||
@@ -95,7 +97,10 @@ const RoomDialog = ({
|
|||||||
title={t("roomDialog.button_startSession")}
|
title={t("roomDialog.button_startSession")}
|
||||||
aria-label={t("roomDialog.button_startSession")}
|
aria-label={t("roomDialog.button_startSession")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={onRoomCreate}
|
onClick={() => {
|
||||||
|
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||||
|
onRoomCreate();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -160,7 +165,10 @@ const RoomDialog = ({
|
|||||||
title={t("roomDialog.button_stopSession")}
|
title={t("roomDialog.button_stopSession")}
|
||||||
aria-label={t("roomDialog.button_stopSession")}
|
aria-label={t("roomDialog.button_stopSession")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={onRoomDestroy}
|
onClick={() => {
|
||||||
|
trackEvent("share", "room closed");
|
||||||
|
onRoomDestroy();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { decompressData } from "../../data/encode";
|
|||||||
import { encryptData, decryptData } from "../../data/encryption";
|
import { encryptData, decryptData } from "../../data/encryption";
|
||||||
import { MIME_TYPES } from "../../constants";
|
import { MIME_TYPES } from "../../constants";
|
||||||
import { reconcileElements } from "../collab/reconciliation";
|
import { reconcileElements } from "../collab/reconciliation";
|
||||||
|
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -127,7 +128,18 @@ const decryptElements = async (
|
|||||||
return JSON.parse(decodedData);
|
return JSON.parse(decodedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const firebaseSceneVersionCache = new WeakMap<SocketIOClient.Socket, number>();
|
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));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const isSavedToFirebase = (
|
export const isSavedToFirebase = (
|
||||||
portal: Portal,
|
portal: Portal,
|
||||||
@@ -136,7 +148,7 @@ export const isSavedToFirebase = (
|
|||||||
if (portal.socket && portal.roomId && portal.roomKey) {
|
if (portal.socket && portal.roomId && portal.roomKey) {
|
||||||
const sceneVersion = getSceneVersion(elements);
|
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
|
// 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)
|
// prevent unload (there's nothing we could do at that point anyway)
|
||||||
@@ -181,7 +193,7 @@ export const saveFilesToFirebase = async ({
|
|||||||
|
|
||||||
const createFirebaseSceneDocument = async (
|
const createFirebaseSceneDocument = async (
|
||||||
firebase: ResolutionType<typeof loadFirestore>,
|
firebase: ResolutionType<typeof loadFirestore>,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly SyncableExcalidrawElement[],
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
) => {
|
) => {
|
||||||
const sceneVersion = getSceneVersion(elements);
|
const sceneVersion = getSceneVersion(elements);
|
||||||
@@ -197,7 +209,7 @@ const createFirebaseSceneDocument = async (
|
|||||||
|
|
||||||
export const saveToFirebase = async (
|
export const saveToFirebase = async (
|
||||||
portal: Portal,
|
portal: Portal,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly SyncableExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
const { roomId, roomKey, socket } = portal;
|
const { roomId, roomKey, socket } = portal;
|
||||||
@@ -229,18 +241,18 @@ export const saveToFirebase = async (
|
|||||||
transaction.set(docRef, sceneDocument);
|
transaction.set(docRef, sceneDocument);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sceneVersion: sceneDocument.sceneVersion,
|
elements,
|
||||||
reconciledElements: null,
|
reconciledElements: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
||||||
const prevElements = await decryptElements(prevDocData, roomKey);
|
const prevElements = getSyncableElements(
|
||||||
|
await decryptElements(prevDocData, roomKey),
|
||||||
|
);
|
||||||
|
|
||||||
const reconciledElements = reconcileElements(
|
const reconciledElements = getSyncableElements(
|
||||||
elements,
|
reconcileElements(elements, prevElements, appState),
|
||||||
prevElements,
|
|
||||||
appState,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sceneDocument = await createFirebaseSceneDocument(
|
const sceneDocument = await createFirebaseSceneDocument(
|
||||||
@@ -251,14 +263,14 @@ export const saveToFirebase = async (
|
|||||||
|
|
||||||
transaction.update(docRef, sceneDocument);
|
transaction.update(docRef, sceneDocument);
|
||||||
return {
|
return {
|
||||||
|
elements,
|
||||||
reconciledElements,
|
reconciledElements,
|
||||||
sceneVersion: sceneDocument.sceneVersion,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
|
FirebaseSceneVersionCache.set(socket, savedData.elements);
|
||||||
|
|
||||||
return savedData;
|
return { reconciledElements: savedData.reconciledElements };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromFirebase = async (
|
export const loadFromFirebase = async (
|
||||||
@@ -275,10 +287,12 @@ export const loadFromFirebase = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const storedScene = doc.data() as FirebaseStoredScene;
|
const storedScene = doc.data() as FirebaseStoredScene;
|
||||||
const elements = await decryptElements(storedScene, roomKey);
|
const elements = getSyncableElements(
|
||||||
|
await decryptElements(storedScene, roomKey),
|
||||||
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
|
FirebaseSceneVersionCache.set(socket, elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreElements(elements, null);
|
return restoreElements(elements, null);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { serializeAsJSON } from "../../data/json";
|
import { serializeAsJSON } from "../../data/json";
|
||||||
import { restore } from "../../data/restore";
|
import { restore } from "../../data/restore";
|
||||||
import { ImportedDataState } from "../../data/types";
|
import { ImportedDataState } from "../../data/types";
|
||||||
|
import { isInvisiblySmallElement } from "../../element/sizeHelpers";
|
||||||
import { isInitializedImageElement } from "../../element/typeChecks";
|
import { isInitializedImageElement } from "../../element/typeChecks";
|
||||||
import { ExcalidrawElement, FileId } from "../../element/types";
|
import { ExcalidrawElement, FileId } from "../../element/types";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
@@ -17,10 +18,35 @@ import {
|
|||||||
UserIdleState,
|
UserIdleState,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
import { bytesToHexString } from "../../utils";
|
import { bytesToHexString } from "../../utils";
|
||||||
import { FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES } from "../app_constants";
|
import {
|
||||||
|
DELETED_ELEMENT_TIMEOUT,
|
||||||
|
FILE_UPLOAD_MAX_BYTES,
|
||||||
|
ROOM_ID_BYTES,
|
||||||
|
} from "../app_constants";
|
||||||
import { encodeFilesForUpload } from "./FileManager";
|
import { encodeFilesForUpload } from "./FileManager";
|
||||||
import { saveFilesToFirebase } from "./firebase";
|
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_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
|
||||||
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
|
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
|
||||||
|
|
||||||
@@ -108,9 +134,16 @@ export type SocketUpdateData =
|
|||||||
_brand: "socketUpdateData";
|
_brand: "socketUpdateData";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
|
||||||
|
|
||||||
|
export const isCollaborationLink = (link: string) => {
|
||||||
|
const hash = new URL(link).hash;
|
||||||
|
return RE_COLLAB_LINK.test(hash);
|
||||||
|
};
|
||||||
|
|
||||||
export const getCollaborationLinkData = (link: string) => {
|
export const getCollaborationLinkData = (link: string) => {
|
||||||
const hash = new URL(link).hash;
|
const hash = new URL(link).hash;
|
||||||
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
const match = hash.match(RE_COLLAB_LINK);
|
||||||
if (match && match[2].length !== 22) {
|
if (match && match[2].length !== 22) {
|
||||||
window.alert(t("alerts.invalidEncryptionKey"));
|
window.alert(t("alerts.invalidEncryptionKey"));
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -32,3 +32,25 @@
|
|||||||
pointer-events: none;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { ErrorDialog } from "../components/ErrorDialog";
|
import { ErrorDialog } from "../components/ErrorDialog";
|
||||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||||
import {
|
import {
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
|
COOKIES,
|
||||||
EVENT,
|
EVENT,
|
||||||
TITLE_TIMEOUT,
|
TITLE_TIMEOUT,
|
||||||
URL_HASH_KEYS,
|
|
||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { loadFromBlob } from "../data/blob";
|
import { loadFromBlob } from "../data/blob";
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { Language, t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
defaultLang,
|
defaultLang,
|
||||||
@@ -45,20 +45,26 @@ import {
|
|||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
SYNC_BROWSER_TABS_TIMEOUT,
|
SYNC_BROWSER_TABS_TIMEOUT,
|
||||||
} from "./app_constants";
|
} from "./app_constants";
|
||||||
import CollabWrapper, {
|
import Collab, {
|
||||||
CollabAPI,
|
CollabAPI,
|
||||||
CollabContext,
|
collabAPIAtom,
|
||||||
CollabContextConsumer,
|
collabDialogShownAtom,
|
||||||
} from "./collab/CollabWrapper";
|
isCollaboratingAtom,
|
||||||
|
} from "./collab/Collab";
|
||||||
import { LanguageList } from "./components/LanguageList";
|
import { LanguageList } from "./components/LanguageList";
|
||||||
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
|
import {
|
||||||
|
exportToBackend,
|
||||||
|
getCollaborationLinkData,
|
||||||
|
isCollaborationLink,
|
||||||
|
loadScene,
|
||||||
|
} from "./data";
|
||||||
import {
|
import {
|
||||||
getLibraryItemsFromStorage,
|
getLibraryItemsFromStorage,
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
} from "./data/localStorage";
|
} from "./data/localStorage";
|
||||||
import CustomStats from "./CustomStats";
|
import CustomStats from "./CustomStats";
|
||||||
import { restoreAppState, RestoredDataState } from "../data/restore";
|
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { shield } from "../components/icons";
|
import { shield } from "../components/icons";
|
||||||
|
|
||||||
@@ -72,14 +78,20 @@ import { loadFilesFromFirebase } from "./data/firebase";
|
|||||||
import { LocalData } from "./data/LocalData";
|
import { LocalData } from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Provider, useAtom } from "jotai";
|
||||||
|
import { jotaiStore, useAtomWithInitialValue } from "../jotai";
|
||||||
|
import { reconcileElements } from "./collab/reconciliation";
|
||||||
|
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
||||||
|
|
||||||
|
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||||
|
|
||||||
|
const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||||
|
COOKIES.AUTH_STATE_COOKIE,
|
||||||
|
);
|
||||||
|
|
||||||
const languageDetector = new LanguageDetector();
|
const languageDetector = new LanguageDetector();
|
||||||
languageDetector.init({
|
languageDetector.init({
|
||||||
languageUtils: {
|
languageUtils: {},
|
||||||
formatLanguageCode: (langCode: Language["code"]) => langCode,
|
|
||||||
isWhitelisted: () => true,
|
|
||||||
},
|
|
||||||
checkWhitelist: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const initializeScene = async (opts: {
|
const initializeScene = async (opts: {
|
||||||
@@ -169,7 +181,7 @@ const initializeScene = async (opts: {
|
|||||||
|
|
||||||
if (roomLinkData) {
|
if (roomLinkData) {
|
||||||
return {
|
return {
|
||||||
scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
|
scene: await opts.collabAPI.startCollaboration(roomLinkData),
|
||||||
isExternalScene: true,
|
isExternalScene: true,
|
||||||
id: roomLinkData.roomId,
|
id: roomLinkData.roomId,
|
||||||
key: roomLinkData.roomKey,
|
key: roomLinkData.roomKey,
|
||||||
@@ -187,7 +199,7 @@ const initializeScene = async (opts: {
|
|||||||
return { scene: null, isExternalScene: false };
|
return { scene: null, isExternalScene: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
const PlusLinkJSX = (
|
const PlusLPLinkJSX = (
|
||||||
<p style={{ direction: "ltr", unicodeBidi: "embed" }}>
|
<p style={{ direction: "ltr", unicodeBidi: "embed" }}>
|
||||||
Introducing Excalidraw+
|
Introducing Excalidraw+
|
||||||
<br />
|
<br />
|
||||||
@@ -201,6 +213,17 @@ const PlusLinkJSX = (
|
|||||||
</p>
|
</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 ExcalidrawWrapper = () => {
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
let currentLangCode = languageDetector.detect() || defaultLang.code;
|
let currentLangCode = languageDetector.detect() || defaultLang.code;
|
||||||
@@ -230,7 +253,16 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [excalidrawAPI, excalidrawRefCallback] =
|
const [excalidrawAPI, excalidrawRefCallback] =
|
||||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||||
|
|
||||||
const collabAPI = useContext(CollabContext)?.api;
|
const [collabAPI] = useAtom(collabAPIAtom);
|
||||||
|
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||||
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||||
|
return isCollaborationLink(window.location.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
useHandleLibrary({
|
||||||
|
excalidrawAPI,
|
||||||
|
getInitialLibraryItems: getLibraryItemsFromStorage,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!collabAPI || !excalidrawAPI) {
|
if (!collabAPI || !excalidrawAPI) {
|
||||||
@@ -301,33 +333,46 @@ const ExcalidrawWrapper = () => {
|
|||||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.scene.libraryItems = getLibraryItemsFromStorage();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeScene({ collabAPI }).then((data) => {
|
initializeScene({ collabAPI }).then(async (data) => {
|
||||||
loadImages(data, /* isInitialLoad */ true);
|
loadImages(data, /* isInitialLoad */ true);
|
||||||
initialStatePromiseRef.current.promise.resolve(data.scene);
|
|
||||||
|
initialStatePromiseRef.current.promise.resolve({
|
||||||
|
...data.scene,
|
||||||
|
// at this point the state may have already been updated (e.g. when
|
||||||
|
// collaborating, we may have received updates from other clients)
|
||||||
|
appState: restoreAppState(
|
||||||
|
data.scene?.appState,
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
),
|
||||||
|
elements: reconcileElements(
|
||||||
|
data.scene?.elements || [],
|
||||||
|
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const onHashChange = (event: HashChangeEvent) => {
|
const onHashChange = async (event: HashChangeEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||||
const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
|
if (!libraryUrlTokens) {
|
||||||
if (libraryUrl) {
|
if (
|
||||||
// If hash changed and it contains library url, import it and replace
|
collabAPI.isCollaborating() &&
|
||||||
// the url to its previous state (important in case of collaboration
|
!isCollaborationLink(window.location.href)
|
||||||
// and similar).
|
) {
|
||||||
// Using history API won't trigger another hashchange.
|
collabAPI.stopCollaboration(false);
|
||||||
window.history.replaceState({}, "", event.oldURL);
|
}
|
||||||
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
|
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
||||||
} else {
|
|
||||||
initializeScene({ collabAPI }).then((data) => {
|
initializeScene({ collabAPI }).then((data) => {
|
||||||
loadImages(data);
|
loadImages(data);
|
||||||
if (data.scene) {
|
if (data.scene) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...data.scene,
|
...data.scene,
|
||||||
appState: restoreAppState(data.scene.appState, null),
|
...restore(data.scene, null, null),
|
||||||
|
commitToHistory: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -355,6 +400,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
setLangCode(langCode);
|
setLangCode(langCode);
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
|
});
|
||||||
|
excalidrawAPI.updateLibrary({
|
||||||
libraryItems: getLibraryItemsFromStorage(),
|
libraryItems: getLibraryItemsFromStorage(),
|
||||||
});
|
});
|
||||||
collabAPI.setUsername(username || "");
|
collabAPI.setUsername(username || "");
|
||||||
@@ -466,19 +513,17 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (excalidrawAPI) {
|
if (excalidrawAPI) {
|
||||||
let didChange = false;
|
let didChange = false;
|
||||||
|
|
||||||
let pendingImageElement = appState.pendingImageElement;
|
|
||||||
const elements = excalidrawAPI
|
const elements = excalidrawAPI
|
||||||
.getSceneElementsIncludingDeleted()
|
.getSceneElementsIncludingDeleted()
|
||||||
.map((element) => {
|
.map((element) => {
|
||||||
if (
|
if (
|
||||||
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
|
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
|
||||||
) {
|
) {
|
||||||
didChange = true;
|
const newElement = newElementWith(element, { status: "saved" });
|
||||||
const newEl = newElementWith(element, { status: "saved" });
|
if (newElement !== element) {
|
||||||
if (pendingImageElement === element) {
|
didChange = true;
|
||||||
pendingImageElement = newEl;
|
|
||||||
}
|
}
|
||||||
return newEl;
|
return newElement;
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
});
|
});
|
||||||
@@ -486,9 +531,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (didChange) {
|
if (didChange) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
appState: {
|
|
||||||
pendingImageElement,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,17 +574,16 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "24ch",
|
width: isExcalidrawPlusSignedUser ? "21ch" : "23ch",
|
||||||
fontSize: "0.7em",
|
fontSize: "0.7em",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* <GitHubCorner theme={appState.theme} dir={document.dir} /> */}
|
{isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
|
||||||
{/* FIXME remove after 2021-05-20 */}
|
|
||||||
{PlusLinkJSX}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -594,12 +635,14 @@ const ExcalidrawWrapper = () => {
|
|||||||
marginTop: isTinyDevice ? 16 : undefined,
|
marginTop: isTinyDevice ? 16 : undefined,
|
||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
marginRight: isTinyDevice ? "auto" : undefined,
|
marginRight: isTinyDevice ? "auto" : undefined,
|
||||||
padding: "4px 2px",
|
padding: isExcalidrawPlusSignedUser ? undefined : "4px 2px",
|
||||||
border: "1px dashed #aaa",
|
border: isExcalidrawPlusSignedUser
|
||||||
|
? undefined
|
||||||
|
: "1px dashed #aaa",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PlusLinkJSX}
|
{isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -617,7 +660,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
const renderCustomStats = () => {
|
const renderCustomStats = () => {
|
||||||
return (
|
return (
|
||||||
<CustomStats
|
<CustomStats
|
||||||
setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
|
setToast={(message) => excalidrawAPI!.setToast({ message })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -631,23 +674,19 @@ const ExcalidrawWrapper = () => {
|
|||||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRoomClose = useCallback(() => {
|
|
||||||
LocalData.fileStorage.reset();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
className={clsx("excalidraw-app", {
|
className={clsx("excalidraw-app", {
|
||||||
"is-collaborating": collabAPI?.isCollaborating(),
|
"is-collaborating": isCollaborating,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
ref={excalidrawRefCallback}
|
ref={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
onCollabButtonClick={() => setCollabDialogShown(true)}
|
||||||
isCollaborating={collabAPI?.isCollaborating()}
|
isCollaborating={isCollaborating}
|
||||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||||
UIOptions={{
|
UIOptions={{
|
||||||
canvasActions: {
|
canvasActions: {
|
||||||
@@ -681,12 +720,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
onLibraryChange={onLibraryChange}
|
onLibraryChange={onLibraryChange}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
/>
|
/>
|
||||||
{excalidrawAPI && (
|
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||||
<CollabWrapper
|
|
||||||
excalidrawAPI={excalidrawAPI}
|
|
||||||
onRoomClose={onRoomClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
@@ -700,9 +734,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
const ExcalidrawApp = () => {
|
const ExcalidrawApp = () => {
|
||||||
return (
|
return (
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<CollabContextConsumer>
|
<Provider unstable_createStore={() => jotaiStore}>
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawWrapper />
|
||||||
</CollabContextConsumer>
|
</Provider>
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user