Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef7330243f |
@@ -11,12 +11,3 @@ 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,5 +13,3 @@ 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
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
reviewers:
|
||||||
|
- lipis
|
||||||
|
assignees:
|
||||||
|
- lipis
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /src/packages/excalidraw/
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
reviewers:
|
||||||
|
- ad1992
|
||||||
|
assignees:
|
||||||
|
- ad1992
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /src/packages/utils/
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
reviewers:
|
||||||
|
- ad1992
|
||||||
|
assignees:
|
||||||
|
- ad1992
|
||||||
|
open-pull-requests-limit: 20
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Auto release excalidraw next
|
name: Auto release @excalidraw/excalidraw-next
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Auto release excalidraw preview
|
name: Auto release preview @excalidraw/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 trigger release' && github.event.issue.pull_request
|
if: github.event.comment.body == '@excalibot release package' && 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
|
||||||
|
|||||||
+9
-12
@@ -22,14 +22,14 @@
|
|||||||
"@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.5",
|
"@testing-library/react": "12.1.2",
|
||||||
"@tldraw/vec": "1.4.3",
|
"@tldraw/vec": "1.4.3",
|
||||||
"@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.29.1",
|
"browser-fs-access": "0.24.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",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"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.3.3",
|
"nanoid": "3.1.32",
|
||||||
"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.51.0",
|
"sass": "1.49.7",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.5.5"
|
"typescript": "4.5.5"
|
||||||
},
|
},
|
||||||
@@ -64,13 +64,13 @@
|
|||||||
"@types/resize-observer-browser": "0.1.6",
|
"@types/resize-observer-browser": "0.1.6",
|
||||||
"chai": "4.3.6",
|
"chai": "4.3.6",
|
||||||
"dotenv": "10.0.0",
|
"dotenv": "10.0.0",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.3.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.4.0",
|
"jest-canvas-mock": "2.3.1",
|
||||||
"lint-staged": "12.3.7",
|
"lint-staged": "12.3.7",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.5.1",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
@@ -94,8 +94,7 @@
|
|||||||
"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:prebuild": "node ./scripts/prebuild.js",
|
"build": "yarn build:app && yarn build:version",
|
||||||
"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",
|
||||||
@@ -113,8 +112,6 @@
|
|||||||
"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,25 +52,6 @@
|
|||||||
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 -->
|
||||||
@@ -98,22 +79,6 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<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.
|
||||||
|
|||||||
+11
-6
@@ -5,25 +5,22 @@ 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 --tag ${tag}`);
|
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||||
console.info(`Published ${pkg.name}@${tag}🎉`);
|
console.info("Published 🎉");
|
||||||
core.setOutput(
|
core.setOutput(
|
||||||
"result",
|
"result",
|
||||||
`**Preview version has been shipped** :rocket:
|
`**Preview version has been shipped** :rocket:
|
||||||
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
|
You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
core.setOutput("result", "package couldn't be published :warning:!");
|
core.setOutput("result", "package couldn't be published :warning:!");
|
||||||
@@ -54,19 +51,27 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update package.json
|
// 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
// for development purposes we want to have the service-worker.js file
|
|
||||||
// accessible from the public folder. On build though, we need to compile it
|
|
||||||
// and CRA expects that file to be in src/ folder.
|
|
||||||
const moveServiceWorkerScript = () => {
|
|
||||||
const oldPath = "./public/service-worker.js";
|
|
||||||
const newPath = "./src/service-worker.js";
|
|
||||||
|
|
||||||
fs.rename(oldPath, newPath, (error) => {
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
console.info("public/service-worker.js moved to src/");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
moveServiceWorkerScript();
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const util = require("util");
|
|
||||||
const exec = util.promisify(require("child_process").exec);
|
|
||||||
const updateChangelog = require("./updateChangelog");
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
|
||||||
|
|
||||||
const updatePackageVersion = (nextVersion) => {
|
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
pkg.version = nextVersion;
|
|
||||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
|
||||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
|
||||||
};
|
|
||||||
|
|
||||||
const prerelease = async (nextVersion) => {
|
|
||||||
try {
|
|
||||||
await updateChangelog(nextVersion);
|
|
||||||
updatePackageVersion(nextVersion);
|
|
||||||
await exec(`git add -u`);
|
|
||||||
await exec(
|
|
||||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
|
||||||
);
|
|
||||||
|
|
||||||
console.info("Done!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextVersion = process.argv.slice(2)[0];
|
|
||||||
if (!nextVersion) {
|
|
||||||
console.error("Pass the next version to release!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
prerelease(nextVersion);
|
|
||||||
+25
-30
@@ -1,44 +1,39 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { execSync } = require("child_process");
|
const util = require("util");
|
||||||
|
const exec = util.promisify(require("child_process").exec);
|
||||||
|
const updateReadme = require("./updateReadme");
|
||||||
|
const updateChangelog = require("./updateChangelog");
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
|
|
||||||
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
|
const updatePackageVersion = (nextVersion) => {
|
||||||
|
const pkg = require(excalidrawPackage);
|
||||||
const updateReadme = () => {
|
pkg.version = nextVersion;
|
||||||
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
|
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||||
|
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 publish = () => {
|
const release = async (nextVersion) => {
|
||||||
try {
|
try {
|
||||||
execSync(`yarn --frozen-lockfile`);
|
updateReadme();
|
||||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
await updateChangelog(nextVersion);
|
||||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
updatePackageVersion(nextVersion);
|
||||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
await exec(`git add -u`);
|
||||||
|
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 release = () => {
|
const nextVersion = process.argv.slice(2)[0];
|
||||||
updateReadme();
|
if (!nextVersion) {
|
||||||
console.info("Note for stable readme removed");
|
console.error("Pass the next version to release!");
|
||||||
|
process.exit(1);
|
||||||
publish();
|
}
|
||||||
console.info(`Published ${pkg.version}!`);
|
release(nextVersion);
|
||||||
|
|
||||||
// revert readme after release
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
|
|
||||||
console.info("Readme reverted");
|
|
||||||
};
|
|
||||||
|
|
||||||
release();
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const updateReadme = () => {
|
||||||
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
|
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||||
|
|
||||||
|
// remove note for unstable release
|
||||||
|
data = data.replace(
|
||||||
|
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
// replace "excalidraw-next" with "excalidraw"
|
||||||
|
data = data.replace(/excalidraw-next/g, "excalidraw");
|
||||||
|
data = data.trim();
|
||||||
|
|
||||||
|
const demoIndex = data.indexOf("### Demo");
|
||||||
|
const excalidrawNextNote =
|
||||||
|
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
||||||
|
// Add excalidraw next note to try out for unreleased changes
|
||||||
|
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
||||||
|
|
||||||
|
// update readme
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = updateReadme;
|
||||||
@@ -25,9 +25,9 @@ export const actionAddToLibrary = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return app.library
|
return app.library
|
||||||
.getLatestLibrary()
|
.loadLibrary()
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
return app.library.setLibrary([
|
return app.library.saveLibrary([
|
||||||
{
|
{
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
status: "unpublished",
|
status: "unpublished",
|
||||||
|
|||||||
@@ -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, updateActiveTool } from "../utils";
|
import { getShortcutKey } 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,28 +304,21 @@ 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,9 +15,7 @@ export const actionCopy = register({
|
|||||||
name: "copy",
|
name: "copy",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||||
|
|
||||||
copyToClipboard(selectedElements, appState, app.files);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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[],
|
||||||
@@ -135,7 +134,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
activeTool: { ...appState.activeTool, 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 "../distribute";
|
import { distributeElements, Distribution } from "../disitrubte";
|
||||||
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";
|
||||||
|
|||||||
@@ -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 { useDevice } from "../components/App";
|
import { useDeviceType } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
import { CheckboxItem } from "../components/CheckboxItem";
|
||||||
@@ -204,7 +204,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
icon={saveAs}
|
icon={saveAs}
|
||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
hidden={!nativeFileSystemSupported}
|
hidden={!nativeFileSystemSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
@@ -248,7 +248,7 @@ export const actionLoadScene = register({
|
|||||||
icon={load}
|
icon={load}
|
||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useDeviceType().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 { updateActiveTool, resetCursor } from "../utils";
|
import { 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,12 +14,11 @@ 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, scene }) => {
|
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.editingLinearElement;
|
appState.editingLinearElement;
|
||||||
@@ -50,12 +49,8 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
const pendingImageElement =
|
if (appState.pendingImageElement) {
|
||||||
appState.pendingImageElementId &&
|
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||||
scene.getElement(appState.pendingImageElementId);
|
|
||||||
|
|
||||||
if (pendingImageElement) {
|
|
||||||
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
@@ -142,20 +137,6 @@ 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: {
|
||||||
@@ -166,7 +147,14 @@ 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,
|
||||||
@@ -181,7 +169,7 @@ export const actionFinalize = register({
|
|||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
pendingImageElementId: null,
|
pendingImageElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.activeTool.type === "freedraw",
|
commitToHistory: appState.activeTool.type === "freedraw",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getClientColors } from "../clients";
|
import { getClientColors, getClientInitials } 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,18 +31,28 @@ export const actionGoToCollaborator = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, data }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const [clientId, collaborator] = data as [string, Collaborator];
|
const clientId: string | undefined = data?.id;
|
||||||
|
if (!clientId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collaborator = appState.collaborators.get(clientId);
|
||||||
|
|
||||||
|
if (!collaborator) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const { background, stroke } = getClientColors(clientId, appState);
|
const { 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 || ""}
|
>
|
||||||
src={collaborator.avatarUrl}
|
{shortName}
|
||||||
/>
|
</Avatar>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -485,14 +485,10 @@ export const actionChangeOpacity = register({
|
|||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
elements,
|
newElementWith(el, {
|
||||||
appState,
|
opacity: value,
|
||||||
(el) =>
|
}),
|
||||||
newElementWith(el, {
|
|
||||||
opacity: value,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemOpacity: value },
|
appState: { ...appState, currentItemOpacity: value },
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -507,6 +503,20 @@ 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,
|
||||||
|
|||||||
@@ -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)[0];
|
const secondRect = JSON.parse(copiedStyles);
|
||||||
expect(secondRect.id).toBe(h.elements[1].id);
|
expect(secondRect.id).toBe(h.elements[1].id);
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
|
|||||||
+19
-58
@@ -6,15 +6,13 @@ 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 { newElementWith } from "../element/mutateElement";
|
import { mutateElement, 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 { getBoundTextElement } from "../element/textElement";
|
import { getContainerElement } 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 = "{}";
|
||||||
@@ -23,15 +21,9 @@ 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(elementsCopied);
|
copiedStyles = JSON.stringify(element);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -50,62 +42,31 @@ export const actionPasteStyles = register({
|
|||||||
name: "pasteStyles",
|
name: "pasteStyles",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const elementsCopied = JSON.parse(copiedStyles);
|
const pastedElement = 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 (selectedElementIds.includes(element.id)) {
|
if (appState.selectedElementIds[element.id]) {
|
||||||
let elementStylesToCopyFrom = pastedElement;
|
const newElement = newElementWith(element, {
|
||||||
if (isTextElement(element) && element.containerId) {
|
backgroundColor: pastedElement?.backgroundColor,
|
||||||
elementStylesToCopyFrom = boundTextElement;
|
strokeWidth: pastedElement?.strokeWidth,
|
||||||
}
|
strokeColor: pastedElement?.strokeColor,
|
||||||
if (!elementStylesToCopyFrom) {
|
strokeStyle: pastedElement?.strokeStyle,
|
||||||
return element;
|
fillStyle: pastedElement?.fillStyle,
|
||||||
}
|
opacity: pastedElement?.opacity,
|
||||||
let newElement = newElementWith(element, {
|
roughness: pastedElement?.roughness,
|
||||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
|
||||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
|
||||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
|
||||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
|
||||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
|
||||||
opacity: elementStylesToCopyFrom?.opacity,
|
|
||||||
roughness: elementStylesToCopyFrom?.roughness,
|
|
||||||
});
|
});
|
||||||
|
if (isTextElement(newElement) && isTextElement(element)) {
|
||||||
if (isTextElement(newElement)) {
|
mutateElement(newElement, {
|
||||||
newElement = newElementWith(newElement, {
|
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||||
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||||
fontFamily:
|
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
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") {
|
redrawTextBoundingBox(newElement, getContainerElement(newElement));
|
||||||
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.device.isMobile ? "mobile" : "desktop"})`,
|
`${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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";
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ export type PanelComponentProps = {
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
appProps: ExcalidrawProps;
|
appProps: ExcalidrawProps;
|
||||||
data?: Record<string, any>;
|
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
|
|||||||
+3
-6
@@ -43,7 +43,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
activeTool: {
|
activeTool: {
|
||||||
type: "selection",
|
type: "selection",
|
||||||
customType: null,
|
|
||||||
locked: false,
|
locked: false,
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
},
|
},
|
||||||
@@ -58,7 +57,6 @@ 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,
|
||||||
@@ -88,7 +86,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
value: 1 as NormalizedZoomValue,
|
value: 1 as NormalizedZoomValue,
|
||||||
},
|
},
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElementId: null,
|
pendingImageElement: null,
|
||||||
showHyperlinkPopup: false,
|
showHyperlinkPopup: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -147,8 +145,7 @@ 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: true, export: false, server: false },
|
isLibraryOpen: { browser: false, 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 },
|
||||||
@@ -179,7 +176,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
zenModeEnabled: { browser: true, export: false, server: false },
|
zenModeEnabled: { browser: true, export: false, server: false },
|
||||||
zoom: { browser: true, export: false, server: false },
|
zoom: { browser: true, export: false, server: false },
|
||||||
viewModeEnabled: { browser: false, export: false, server: false },
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
pendingImageElementId: { browser: false, export: false, server: false },
|
pendingImageElement: { browser: false, export: false, server: false },
|
||||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import {
|
|
||||||
Spreadsheet,
|
|
||||||
tryParseCells,
|
|
||||||
tryParseNumber,
|
|
||||||
VALID_SPREADSHEET,
|
|
||||||
} from "./charts";
|
|
||||||
|
|
||||||
describe("charts", () => {
|
|
||||||
describe("tryParseNumber", () => {
|
|
||||||
it.each<[string, number]>([
|
|
||||||
["1", 1],
|
|
||||||
["0", 0],
|
|
||||||
["-1", -1],
|
|
||||||
["0.1", 0.1],
|
|
||||||
[".1", 0.1],
|
|
||||||
["1.", 1],
|
|
||||||
["424.", 424],
|
|
||||||
["$1", 1],
|
|
||||||
["-.1", -0.1],
|
|
||||||
["-$1", -1],
|
|
||||||
["$-1", -1],
|
|
||||||
])("should correctly identify %s as numbers", (given, expected) => {
|
|
||||||
expect(tryParseNumber(given)).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
|
|
||||||
"should correctly identify %s as not a number",
|
|
||||||
(given) => {
|
|
||||||
expect(tryParseNumber(given)).toBeNull();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("tryParseCells", () => {
|
|
||||||
it("Successfully parses a spreadsheet", () => {
|
|
||||||
const spreadsheet = [
|
|
||||||
["time", "value"],
|
|
||||||
["01:00", "61"],
|
|
||||||
["02:00", "-60"],
|
|
||||||
["03:00", "85"],
|
|
||||||
["04:00", "-67"],
|
|
||||||
["05:00", "54"],
|
|
||||||
["06:00", "95"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = tryParseCells(spreadsheet);
|
|
||||||
|
|
||||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
|
||||||
|
|
||||||
const { title, labels, values } = (
|
|
||||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
|
||||||
).spreadsheet;
|
|
||||||
|
|
||||||
expect(title).toEqual("value");
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"01:00",
|
|
||||||
"02:00",
|
|
||||||
"03:00",
|
|
||||||
"04:00",
|
|
||||||
"05:00",
|
|
||||||
"06:00",
|
|
||||||
]);
|
|
||||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Uses the second column as the label if it is not a number", () => {
|
|
||||||
const spreadsheet = [
|
|
||||||
["time", "value"],
|
|
||||||
["01:00", "61"],
|
|
||||||
["02:00", "-60"],
|
|
||||||
["03:00", "85"],
|
|
||||||
["04:00", "-67"],
|
|
||||||
["05:00", "54"],
|
|
||||||
["06:00", "95"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = tryParseCells(spreadsheet);
|
|
||||||
|
|
||||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
|
||||||
|
|
||||||
const { title, labels, values } = (
|
|
||||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
|
||||||
).spreadsheet;
|
|
||||||
|
|
||||||
expect(title).toEqual("value");
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"01:00",
|
|
||||||
"02:00",
|
|
||||||
"03:00",
|
|
||||||
"04:00",
|
|
||||||
"05:00",
|
|
||||||
"06:00",
|
|
||||||
]);
|
|
||||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats the first column as labels if both columns are numbers", () => {
|
|
||||||
const spreadsheet = [
|
|
||||||
["time", "value"],
|
|
||||||
["01", "61"],
|
|
||||||
["02", "-60"],
|
|
||||||
["03", "85"],
|
|
||||||
["04", "-67"],
|
|
||||||
["05", "54"],
|
|
||||||
["06", "95"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = tryParseCells(spreadsheet);
|
|
||||||
|
|
||||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
|
||||||
|
|
||||||
const { title, labels, values } = (
|
|
||||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
|
||||||
).spreadsheet;
|
|
||||||
|
|
||||||
expect(title).toEqual("value");
|
|
||||||
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
|
|
||||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+7
-16
@@ -29,24 +29,18 @@ 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 => {
|
||||||
* @private exported for testing
|
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
||||||
*/
|
|
||||||
export const tryParseNumber = (s: string): number | null => {
|
|
||||||
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
return parseFloat(match[1].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) {
|
||||||
@@ -77,16 +71,13 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelColumnNumeric = isNumericColumn(cells, 0);
|
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
||||||
const valueColumnNumeric = isNumericColumn(cells, 1);
|
|
||||||
|
|
||||||
if (!labelColumnNumeric && !valueColumnNumeric) {
|
if (!isNumericColumn(cells, valueColumnIndex)) {
|
||||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
|
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
||||||
? [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,6 +2,7 @@ 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";
|
||||||
@@ -11,7 +12,7 @@ import { isPromiseLike } from "./utils";
|
|||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
files: BinaryFiles | undefined;
|
files: BinaryFiles | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,20 +57,19 @@ const clipboardContainsElements = (
|
|||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles | null,
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
// 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,
|
elements: selectedElements,
|
||||||
files: files
|
files: selectedElements.reduce((acc, element) => {
|
||||||
? elements.reduce((acc, element) => {
|
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
acc[element.fileId] = files[element.fileId];
|
||||||
acc[element.fileId] = files[element.fileId];
|
}
|
||||||
}
|
return acc;
|
||||||
return acc;
|
}, {} as BinaryFiles),
|
||||||
}, {} 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 { useDevice } from "../components/App";
|
import { useDeviceType } from "../components/App";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
@@ -15,12 +15,7 @@ import {
|
|||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
import { AppState, Zoom } from "../types";
|
import { AppState, Zoom } from "../types";
|
||||||
import {
|
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||||
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";
|
||||||
@@ -52,7 +47,7 @@ export const SelectedShapeActions = ({
|
|||||||
isSingleElementBoundContainer = true;
|
isSingleElementBoundContainer = true;
|
||||||
}
|
}
|
||||||
const isEditing = Boolean(appState.editingElement);
|
const isEditing = Boolean(appState.editingElement);
|
||||||
const device = useDevice();
|
const deviceType = useDeviceType();
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
const showFillIcons =
|
const showFillIcons =
|
||||||
@@ -177,8 +172,8 @@ export const SelectedShapeActions = ({
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{!device.isMobile && renderAction("duplicateSelection")}
|
{!deviceType.isMobile && renderAction("duplicateSelection")}
|
||||||
{!device.isMobile && renderAction("deleteSelectedElements")}
|
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
|
||||||
{renderAction("group")}
|
{renderAction("group")}
|
||||||
{renderAction("ungroup")}
|
{renderAction("ungroup")}
|
||||||
{showLinkIcon && renderAction("hyperlink")}
|
{showLinkIcon && renderAction("hyperlink")}
|
||||||
@@ -234,9 +229,7 @@ export const ShapesSwitcher = ({
|
|||||||
if (appState.activeTool.type !== value) {
|
if (appState.activeTool.type !== value) {
|
||||||
trackEvent("toolbar", value, "ui");
|
trackEvent("toolbar", value, "ui");
|
||||||
}
|
}
|
||||||
const nextActiveTool = updateActiveTool(appState, {
|
const nextActiveTool = { ...activeTool, type: value };
|
||||||
type: value,
|
|
||||||
});
|
|
||||||
setAppState({
|
setAppState({
|
||||||
activeTool: nextActiveTool,
|
activeTool: nextActiveTool,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
|
|||||||
+365
-401
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,5 @@
|
|||||||
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%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-27
@@ -1,36 +1,20 @@
|
|||||||
import "./Avatar.scss";
|
import "./Avatar.scss";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React 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 = ({ color, border, onClick, name, src }: AvatarProps) => {
|
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
|
||||||
const shortName = getClientInitials(name);
|
<div
|
||||||
const [error, setError] = useState(false);
|
className="Avatar"
|
||||||
const loadImg = !error && src;
|
style={{ background: color, border: `1px solid ${border}` }}
|
||||||
const style = loadImg
|
onClick={onClick}
|
||||||
? undefined
|
>
|
||||||
: { background: color, border: `1px solid ${border}` };
|
{children}
|
||||||
return (
|
</div>
|
||||||
<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 { useDevice } from "./App";
|
import { useDeviceType } 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={useDevice().isMobile}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
onClick={toggleDialog}
|
onClick={toggleDialog}
|
||||||
data-testid="clear-canvas-button"
|
data-testid="clear-canvas-button"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,15 +18,13 @@
|
|||||||
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.6em;
|
font-size: 0.7em;
|
||||||
font-family: "Cascadia";
|
font-family: var(--ui-font);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useDevice } from "../components/App";
|
import { useDeviceType } 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={useDevice().isMobile}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
>
|
>
|
||||||
{isCollaborating && (
|
{collaboratorCount > 0 && (
|
||||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||||
)}
|
)}
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
|
|||||||
@@ -128,33 +128,45 @@ const Picker = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
let handled = false;
|
if (event.key === KEYS.TAB) {
|
||||||
if (isArrowKey(event.key)) {
|
const { activeElement } = document;
|
||||||
handled = true;
|
if (event.shiftKey) {
|
||||||
|
if (activeElement === firstItem.current) {
|
||||||
|
colorInput.current?.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
} else if (activeElement === colorInput.current) {
|
||||||
|
firstItem.current?.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
} else if (isArrowKey(event.key)) {
|
||||||
const { activeElement } = document;
|
const { 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(".color-picker-content--canvas-colors")
|
gallery!.current!.querySelector(
|
||||||
?.children,
|
".color-picker-content--canvas-colors",
|
||||||
|
)!.children,
|
||||||
activeElement,
|
activeElement,
|
||||||
);
|
);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
isCustom = true;
|
isCustom = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const parentElement = isCustom
|
const parentSelector = isCustom
|
||||||
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
|
? gallery!.current!.querySelector(
|
||||||
: gallery.current?.querySelector(".color-picker-content--default");
|
".color-picker-content--canvas-colors",
|
||||||
|
)!
|
||||||
|
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
||||||
|
|
||||||
if (parentElement && index !== -1) {
|
if (index !== -1) {
|
||||||
const length = parentElement.children.length - (showInput ? 1 : 0);
|
const length = parentSelector!.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
|
||||||
@@ -165,38 +177,30 @@ const Picker = ({
|
|||||||
: !isCustom && event.key === KEYS.ARROW_UP
|
: !isCustom && event.key === KEYS.ARROW_UP
|
||||||
? (length + index - 5) % length
|
? (length + index - 5) % length
|
||||||
: index;
|
: index;
|
||||||
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
|
(parentSelector!.children![nextIndex] as HTMLElement)?.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 parentElement = isCustom
|
const parentSelector = 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();
|
||||||
}
|
}
|
||||||
if (handled) {
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
event.nativeEvent.stopImmediatePropagation();
|
event.stopPropagation();
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderColors = (colors: Array<string>, custom: boolean = false) => {
|
const renderColors = (colors: Array<string>, custom: boolean = false) => {
|
||||||
@@ -260,8 +264,7 @@ const Picker = ({
|
|||||||
gallery.current = el;
|
gallery.current = el;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
// to allow focusing by clicking but not by tabbing
|
tabIndex={0}
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<div className="color-picker-content--default">
|
<div className="color-picker-content--default">
|
||||||
{renderColors(colors)}
|
{renderColors(colors)}
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ 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, useDevice } from "../components/App";
|
import { useExcalidrawContainer, useDeviceType } 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;
|
||||||
@@ -65,6 +64,14 @@ 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();
|
||||||
@@ -87,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{useDevice().isMobile ? back : close}
|
{useDeviceType().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.pendingImageElementId) {
|
if (appState.activeTool.type === "image" && appState.pendingImageElement) {
|
||||||
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 { useDevice } from "./App";
|
import { useDeviceType } from "./App";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../scene/export";
|
import { exportToCanvas } from "../scene/export";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
|
|||||||
icon={exportImage}
|
icon={exportImage}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.exportImage")}
|
aria-label={t("buttons.exportImage")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useDeviceType().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 { useDevice } from "./App";
|
import { useDeviceType } 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={useDevice().isMobile}
|
showAriaLabel={useDeviceType().isMobile}
|
||||||
title={t("buttons.export")}
|
title={t("buttons.export")}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
|
|||||||
@@ -1,63 +1,9 @@
|
|||||||
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-76
@@ -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, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
import { CLASSES } 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,8 +25,9 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
|||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import Library 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";
|
||||||
@@ -36,9 +37,7 @@ 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 { useDevice } from "../components/App";
|
import { useDeviceType } from "../components/App";
|
||||||
import { Stats } from "./Stats";
|
|
||||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -57,9 +56,11 @@ interface LayerUIProps {
|
|||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
renderTopRightUI?: (
|
||||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
isMobile: boolean,
|
||||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
appState: AppState,
|
||||||
|
) => JSX.Element | null;
|
||||||
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
UIOptions: AppProps["UIOptions"];
|
UIOptions: AppProps["UIOptions"];
|
||||||
@@ -68,6 +69,7 @@ interface LayerUIProps {
|
|||||||
id: string;
|
id: string;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
@@ -86,7 +88,6 @@ const LayerUI = ({
|
|||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
renderCustomStats,
|
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
UIOptions,
|
UIOptions,
|
||||||
@@ -95,7 +96,7 @@ const LayerUI = ({
|
|||||||
id,
|
id,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const device = useDevice();
|
const deviceType = useDeviceType();
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
@@ -276,9 +277,7 @@ const LayerUI = ({
|
|||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState, true)}
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
onClose={closeLibrary}
|
onClose={closeLibrary}
|
||||||
onInsertLibraryItems={(libraryItems) => {
|
onInsertShape={onInsertElements}
|
||||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
|
||||||
}}
|
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
@@ -341,7 +340,7 @@ const LayerUI = ({
|
|||||||
<HintViewer
|
<HintViewer
|
||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
isMobile={device.isMobile}
|
isMobile={deviceType.isMobile}
|
||||||
/>
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
@@ -363,6 +362,7 @@ const LayerUI = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
@@ -375,11 +375,23 @@ const LayerUI = ({
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<UserList
|
<UserList>
|
||||||
collaborators={appState.collaborators}
|
{appState.collaborators.size > 0 &&
|
||||||
actionManager={actionManager}
|
Array.from(appState.collaborators)
|
||||||
/>
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
{renderTopRightUI?.(device.isMobile, appState)}
|
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||||
|
.map(([clientId, client]) => (
|
||||||
|
<Tooltip
|
||||||
|
label={client.username || "Unknown user"}
|
||||||
|
key={clientId}
|
||||||
|
>
|
||||||
|
{actionManager.renderAction("goToCollaborator", {
|
||||||
|
id: clientId,
|
||||||
|
})}
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</UserList>
|
||||||
|
{renderTopRightUI?.(deviceType.isMobile, appState)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
@@ -432,7 +444,7 @@ const LayerUI = ({
|
|||||||
)}
|
)}
|
||||||
{!viewModeEnabled &&
|
{!viewModeEnabled &&
|
||||||
appState.multiElement &&
|
appState.multiElement &&
|
||||||
device.isTouchScreen && (
|
deviceType.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":
|
||||||
@@ -509,24 +521,7 @@ const LayerUI = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStats = () => {
|
return deviceType.isMobile ? (
|
||||||
if (!appState.showStats) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Stats
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
elements={elements}
|
|
||||||
onClose={() => {
|
|
||||||
actionManager.executeAction(actionToggleStats);
|
|
||||||
}}
|
|
||||||
renderCustomStats={renderCustomStats}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return device.isMobile ? (
|
|
||||||
<>
|
<>
|
||||||
{dialogs}
|
{dialogs}
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
@@ -547,48 +542,33 @@ const LayerUI = ({
|
|||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
onImageAction={onImageAction}
|
onImageAction={onImageAction}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
renderStats={renderStats}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div
|
||||||
<div
|
className={clsx("layer-ui__wrapper", {
|
||||||
className={clsx("layer-ui__wrapper", {
|
"disable-pointerEvents":
|
||||||
"disable-pointerEvents":
|
appState.draggingElement ||
|
||||||
appState.draggingElement ||
|
appState.resizingElement ||
|
||||||
appState.resizingElement ||
|
(appState.editingElement && !isTextElement(appState.editingElement)),
|
||||||
(appState.editingElement &&
|
})}
|
||||||
!isTextElement(appState.editingElement)),
|
>
|
||||||
})}
|
{dialogs}
|
||||||
style={
|
{renderFixedSideContainer()}
|
||||||
appState.isLibraryOpen &&
|
{renderBottomAppMenu()}
|
||||||
appState.isLibraryMenuDocked &&
|
{appState.scrolledOutside && (
|
||||||
device.canDeviceFitSidebar
|
<button
|
||||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
className="scroll-back-to-content"
|
||||||
: {}
|
onClick={() => {
|
||||||
}
|
setAppState({
|
||||||
>
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
{dialogs}
|
});
|
||||||
{renderFixedSideContainer()}
|
}}
|
||||||
{renderBottomAppMenu()}
|
>
|
||||||
{renderStats()}
|
{t("buttons.scrollBackToContent")}
|
||||||
{appState.scrolledOutside && (
|
</button>
|
||||||
<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,8 +3,6 @@ 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">
|
||||||
@@ -20,7 +18,6 @@ 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(
|
||||||
@@ -37,19 +34,7 @@ export const LibraryButton: React.FC<{
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="editor-library"
|
name="editor-library"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
document
|
setAppState({ isLibraryOpen: event.target.checked });
|
||||||
.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,6 +2,7 @@
|
|||||||
|
|
||||||
.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;
|
||||||
@@ -10,26 +11,19 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 2px 0 15px 0;
|
margin: 2px 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;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__sidebar {
|
a {
|
||||||
.layer-ui__library {
|
margin-inline-start: auto;
|
||||||
padding: 0;
|
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||||
height: 100%;
|
padding-inline-end: 18px;
|
||||||
}
|
white-space: nowrap;
|
||||||
.library-menu-items-container {
|
}
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,38 +61,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-menu-browse-button {
|
|
||||||
width: 80%;
|
|
||||||
min-height: 22px;
|
|
||||||
margin: 0 auto;
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: $oc-white;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-decoration: none !important;
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-primary-darker);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: var(--color-primary-darkest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-menu-browse-button--mobile {
|
|
||||||
min-height: 22px;
|
|
||||||
margin-left: auto;
|
|
||||||
a {
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
onInsertLibraryItems,
|
onInsertShape,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
theme,
|
theme,
|
||||||
@@ -91,7 +91,7 @@ export const LibraryMenu = ({
|
|||||||
}: {
|
}: {
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||||
onAddToLibrary: () => void;
|
onAddToLibrary: () => void;
|
||||||
theme: AppState["theme"];
|
theme: AppState["theme"];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
@@ -104,30 +104,17 @@ export const LibraryMenu = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const device = useDevice();
|
useOnClickOutside(ref, (event) => {
|
||||||
|
// If click on the library icon, do nothing.
|
||||||
useOnClickOutside(
|
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||||
ref,
|
return;
|
||||||
useCallback(
|
}
|
||||||
(event) => {
|
onClose();
|
||||||
// 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 (
|
if (event.key === KEYS.ESCAPE) {
|
||||||
event.key === KEYS.ESCAPE &&
|
|
||||||
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
|
|
||||||
) {
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,7 +122,7 @@ export const LibraryMenu = ({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
|
}, [onClose]);
|
||||||
|
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||||
@@ -152,7 +139,7 @@ export const LibraryMenu = ({
|
|||||||
const nextItems = libraryItems.filter(
|
const nextItems = libraryItems.filter(
|
||||||
(item) => !selectedItems.includes(item.id),
|
(item) => !selectedItems.includes(item.id),
|
||||||
);
|
);
|
||||||
library.setLibrary(nextItems).catch(() => {
|
library.saveLibrary(nextItems).catch(() => {
|
||||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||||
});
|
});
|
||||||
setSelectedItems([]);
|
setSelectedItems([]);
|
||||||
@@ -183,7 +170,7 @@ export const LibraryMenu = ({
|
|||||||
...libraryItems,
|
...libraryItems,
|
||||||
];
|
];
|
||||||
onAddToLibrary();
|
onAddToLibrary();
|
||||||
library.setLibrary(nextItems).catch(() => {
|
library.saveLibrary(nextItems).catch(() => {
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -233,15 +220,16 @@ export const LibraryMenu = ({
|
|||||||
libItem.status = "published";
|
libItem.status = "published";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
library.setLibrary(nextLibItems);
|
library.saveLibrary(nextLibItems);
|
||||||
},
|
},
|
||||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
libraryItemsData.status === "loading" &&
|
LibraryItem["id"] | null
|
||||||
!libraryItemsData.isInitialized
|
>(null);
|
||||||
) {
|
|
||||||
|
if (libraryItemsData.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<LibraryMenuWrapper ref={ref}>
|
<LibraryMenuWrapper ref={ref}>
|
||||||
<div className="layer-ui__library-message">
|
<div className="layer-ui__library-message">
|
||||||
@@ -267,7 +255,7 @@ export const LibraryMenu = ({
|
|||||||
}
|
}
|
||||||
onError={(error) => window.alert(error)}
|
onError={(error) => window.alert(error)}
|
||||||
updateItemsInStorage={() =>
|
updateItemsInStorage={() =>
|
||||||
library.setLibrary(libraryItemsData.libraryItems)
|
library.saveLibrary(libraryItemsData.libraryItems)
|
||||||
}
|
}
|
||||||
onRemove={(id: string) =>
|
onRemove={(id: string) =>
|
||||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||||
@@ -276,7 +264,6 @@ 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)
|
||||||
@@ -284,17 +271,56 @@ export const LibraryMenu = ({
|
|||||||
onAddToLibrary={(elements) =>
|
onAddToLibrary={(elements) =>
|
||||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||||
}
|
}
|
||||||
onInsertLibraryItems={onInsertLibraryItems}
|
onInsertShape={onInsertShape}
|
||||||
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}
|
||||||
onSelectItems={(ids) => setSelectedItems(ids)}
|
onToggle={(id, event) => {
|
||||||
|
const shouldSelect = !selectedItems.includes(id);
|
||||||
|
|
||||||
|
if (shouldSelect) {
|
||||||
|
if (event.shiftKey && lastSelectedItem) {
|
||||||
|
const rangeStart = libraryItemsData.libraryItems.findIndex(
|
||||||
|
(item) => item.id === lastSelectedItem,
|
||||||
|
);
|
||||||
|
const rangeEnd = libraryItemsData.libraryItems.findIndex(
|
||||||
|
(item) => item.id === id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rangeStart === -1 || rangeEnd === -1) {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItemsMap = arrayToMap(selectedItems);
|
||||||
|
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
|
||||||
|
(acc: LibraryItem["id"][], item, idx) => {
|
||||||
|
if (
|
||||||
|
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||||
|
selectedItemsMap.has(item.id)
|
||||||
|
) {
|
||||||
|
acc.push(item.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedItems(nextSelectedIds);
|
||||||
|
} else {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
}
|
||||||
|
setLastSelectedItem(id);
|
||||||
|
} else {
|
||||||
|
setLastSelectedItem(null);
|
||||||
|
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||||
resetLibrary={resetLibrary}
|
resetLibrary={resetLibrary}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,17 +2,8 @@
|
|||||||
|
|
||||||
.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;
|
||||||
@@ -96,16 +87,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&__items {
|
&__items {
|
||||||
flex: 1;
|
max-height: 50vh;
|
||||||
overflow-y: auto;
|
overflow: auto;
|
||||||
overflow-x: hidden;
|
margin-top: 0.5rem;
|
||||||
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 React, { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
import { importLibraryFromJSON, saveLibraryAsJSON } 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,57 +11,48 @@ import {
|
|||||||
LibraryItem,
|
LibraryItem,
|
||||||
LibraryItems,
|
LibraryItems,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { arrayToMap, muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { useDevice } from "./App";
|
import { useDeviceType } from "./App";
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
|
import { 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 { MIME_TYPES, VERSIONS } from "../constants";
|
import { 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,
|
||||||
onInsertLibraryItems,
|
onInsertShape,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
theme,
|
theme,
|
||||||
setAppState,
|
setAppState,
|
||||||
appState,
|
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
library,
|
library,
|
||||||
files,
|
files,
|
||||||
id,
|
id,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
onSelectItems,
|
onToggle,
|
||||||
onPublish,
|
onPublish,
|
||||||
resetLibrary,
|
resetLibrary,
|
||||||
}: {
|
}: {
|
||||||
isLoading: boolean;
|
|
||||||
libraryItems: LibraryItems;
|
libraryItems: LibraryItems;
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onRemoveFromLibrary: () => void;
|
onRemoveFromLibrary: () => void;
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onInsertShape: (elements: LibraryItem["elements"]) => 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"][];
|
||||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
resetLibrary: () => void;
|
resetLibrary: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -93,7 +84,9 @@ 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
|
||||||
@@ -104,34 +97,19 @@ const LibraryMenuItems = ({
|
|||||||
: t("buttons.resetLibrary");
|
: t("buttons.resetLibrary");
|
||||||
return (
|
return (
|
||||||
<div className="library-actions">
|
<div className="library-actions">
|
||||||
{!itemsSelected && (
|
{(!itemsSelected || !isMobile) && (
|
||||||
<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={async () => {
|
onClick={() => {
|
||||||
try {
|
importLibraryFromJSON(library)
|
||||||
await library.updateLibrary({
|
.catch(muteFSAbortError)
|
||||||
libraryItems: fileOpen({
|
.catch((error) => {
|
||||||
description: "Excalidraw library files",
|
setAppState({ errorMessage: error.message });
|
||||||
// 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"
|
||||||
/>
|
/>
|
||||||
@@ -147,7 +125,7 @@ const LibraryMenuItems = ({
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const libraryItems = itemsSelected
|
const libraryItems = itemsSelected
|
||||||
? items
|
? items
|
||||||
: await library.getLatestLibrary();
|
: await library.loadLibrary();
|
||||||
saveLibraryAsJSON(libraryItems)
|
saveLibraryAsJSON(libraryItems)
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -179,7 +157,7 @@ const LibraryMenuItems = ({
|
|||||||
</ToolButton>
|
</ToolButton>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{itemsSelected && (
|
{itemsSelected && !isPublished && (
|
||||||
<Tooltip label={t("hints.publishLibrary")}>
|
<Tooltip label={t("hints.publishLibrary")}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -189,7 +167,7 @@ const LibraryMenuItems = ({
|
|||||||
className="library-actions--publish"
|
className="library-actions--publish"
|
||||||
onClick={onPublish}
|
onClick={onPublish}
|
||||||
>
|
>
|
||||||
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
{!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}
|
||||||
@@ -198,89 +176,17 @@ 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 = device.isMobile && !device.isSmScreen ? 6 : 4;
|
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||||
|
|
||||||
const referrer =
|
const referrer =
|
||||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||||
|
const isPublished = selectedItems.some(
|
||||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
||||||
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:
|
||||||
@@ -302,12 +208,8 @@ 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={onItemSelectToggle}
|
onToggle={(id, event) => {
|
||||||
onDrag={(id, event) => {
|
onToggle(id, event);
|
||||||
event.dataTransfer.setData(
|
|
||||||
MIME_TYPES.excalidrawlib,
|
|
||||||
serializeLibraryAsJSON(getInsertedElements(id)),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@@ -327,7 +229,7 @@ const LibraryMenuItems = ({
|
|||||||
if (item.id) {
|
if (item.id) {
|
||||||
return createLibraryItemCompo({
|
return createLibraryItemCompo({
|
||||||
item,
|
item,
|
||||||
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
|
onClick: () => onInsertShape(item.elements),
|
||||||
key: item.id,
|
key: item.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -366,192 +268,49 @@ 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"),
|
||||||
|
];
|
||||||
|
|
||||||
const renderLibraryHeader = () => {
|
return (
|
||||||
return (
|
<div className="library-menu-items-container">
|
||||||
<>
|
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||||
<div className="layer-ui__library-header" key="library-header">
|
<div className="layer-ui__library-header" key="library-header">
|
||||||
{renderLibraryActions()}
|
{renderLibraryActions()}
|
||||||
{device.canDeviceFitSidebar && (
|
<a
|
||||||
<>
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
<div className="layer-ui__sidebar-lock-button">
|
window.name || "_blank"
|
||||||
<SidebarLockButton
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||||
checked={appState.isLibraryMenuDocked}
|
VERSIONS.excalidrawLibrary
|
||||||
onChange={() => {
|
}`}
|
||||||
document
|
target="_excalidraw_libraries"
|
||||||
.querySelector(".layer-ui__wrapper")
|
>
|
||||||
?.classList.add("animate");
|
{t("labels.libraries")}
|
||||||
const nextState = !appState.isLibraryMenuDocked;
|
</a>
|
||||||
setAppState({
|
</div>
|
||||||
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">
|
<div className="separator">{t("labels.personalLib")}</div>
|
||||||
{(pendingElements.length > 0 ||
|
{renderLibrarySection(unpublishedItems)}
|
||||||
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,
|
|
||||||
])
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
|
|
||||||
<>
|
<>
|
||||||
{(publishedItems.length > 0 ||
|
<div className="separator">{t("labels.excalidrawLib")} </div>
|
||||||
(!device.isMobile &&
|
|
||||||
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
{renderLibrarySection(publishedItems)}
|
||||||
<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 transparent;
|
border: 1px solid var(--button-gray-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.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,7 +1,8 @@
|
|||||||
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 { useDevice } from "../components/App";
|
import { MIME_TYPES } from "../constants";
|
||||||
|
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";
|
||||||
@@ -28,7 +29,6 @@ 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,7 +37,6 @@ 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(() => {
|
||||||
@@ -67,7 +66,7 @@ export const LibraryUnit = ({
|
|||||||
}, [elements, files]);
|
}, [elements, files]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useDevice().isMobile;
|
const isMobile = useDeviceType().isMobile;
|
||||||
const adder = isPending && (
|
const adder = isPending && (
|
||||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||||
);
|
);
|
||||||
@@ -100,12 +99,11 @@ export const LibraryUnit = ({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
if (!id) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
onDrag(id, event);
|
event.dataTransfer.setData(
|
||||||
|
MIME_TYPES.excalidrawlib,
|
||||||
|
JSON.stringify(elements),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{adder}
|
{adder}
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ type MobileMenuProps = {
|
|||||||
onPenModeToggle: () => void;
|
onPenModeToggle: () => void;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderCustomFooter?: (
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
isMobile: boolean,
|
|
||||||
appState: AppState,
|
|
||||||
) => JSX.Element | null;
|
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
@@ -43,7 +40,6 @@ type MobileMenuProps = {
|
|||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => JSX.Element | null;
|
) => JSX.Element | null;
|
||||||
renderStats: () => JSX.Element | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@@ -64,7 +60,6 @@ export const MobileMenu = ({
|
|||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderStats,
|
|
||||||
}: MobileMenuProps) => {
|
}: MobileMenuProps) => {
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
return (
|
return (
|
||||||
@@ -186,7 +181,6 @@ export const MobileMenu = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!viewModeEnabled && renderToolbar()}
|
{!viewModeEnabled && renderToolbar()}
|
||||||
{renderStats()}
|
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -205,11 +199,20 @@ export const MobileMenu = ({
|
|||||||
{appState.collaborators.size > 0 && (
|
{appState.collaborators.size > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.collaborators")}</legend>
|
<legend>{t("labels.collaborators")}</legend>
|
||||||
<UserList
|
<UserList mobile>
|
||||||
mobile
|
{Array.from(appState.collaborators)
|
||||||
collaborators={appState.collaborators}
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
actionManager={actionManager}
|
.filter(
|
||||||
/>
|
([_, 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>
|
||||||
|
|||||||
@@ -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, useDevice } from "./App";
|
import { useExcalidrawContainer, useDeviceType } 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 device = useDevice();
|
const deviceType = useDeviceType();
|
||||||
const isMobileRef = useRef(device.isMobile);
|
const isMobileRef = useRef(deviceType.isMobile);
|
||||||
isMobileRef.current = device.isMobile;
|
isMobileRef.current = deviceType.isMobile;
|
||||||
|
|
||||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (div) {
|
if (div) {
|
||||||
div.classList.toggle("excalidraw--mobile", device.isMobile);
|
div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
|
||||||
}
|
}
|
||||||
}, [div, device.isMobile]);
|
}, [div, deviceType.isMobile]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const isDarkTheme =
|
const isDarkTheme =
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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;
|
||||||
@@ -29,41 +27,6 @@ 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) {
|
||||||
|
|||||||
@@ -82,10 +82,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-warning {
|
|
||||||
color: $oc-red-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-note {
|
&-note {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
@@ -295,11 +295,6 @@ 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}
|
||||||
@@ -334,11 +329,6 @@ 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>
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.layer-ui__sidebar-lock-button {
|
|
||||||
@include toolbarButtonColorStates;
|
|
||||||
margin-right: 0.2rem;
|
|
||||||
}
|
|
||||||
.ToolIcon_type_floating .side_lock_icon {
|
|
||||||
width: calc(var(--space-factor) * 7);
|
|
||||||
height: calc(var(--space-factor) * 7);
|
|
||||||
svg {
|
|
||||||
// mirror
|
|
||||||
transform: scale(-1, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon_type_checkbox {
|
|
||||||
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import "./ToolIcon.scss";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ToolButtonSize } from "./ToolButton";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
import "./SidebarLockButton.scss";
|
|
||||||
|
|
||||||
type SidebarLockIconProps = {
|
|
||||||
checked: boolean;
|
|
||||||
onChange?(): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
|
||||||
|
|
||||||
const SIDE_LIBRARY_TOGGLE_ICON = (
|
|
||||||
<svg viewBox="0 0 24 24" fill="#ffffff">
|
|
||||||
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SidebarLockButton = (props: SidebarLockIconProps) => {
|
|
||||||
return (
|
|
||||||
<Tooltip label={t("labels.sidebarLock")}>
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="ToolIcon_type_checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
onChange={props.onChange}
|
|
||||||
checked={props.checked}
|
|
||||||
aria-label={t("labels.sidebarLock")}
|
|
||||||
/>{" "}
|
|
||||||
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
|
|
||||||
{SIDE_LIBRARY_TOGGLE_ICON}
|
|
||||||
</div>{" "}
|
|
||||||
</label>{" "}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,24 +3,11 @@
|
|||||||
.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%;
|
||||||
@@ -53,7 +40,7 @@
|
|||||||
&--remove {
|
&--remove {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.2rem;
|
top: 0.2rem;
|
||||||
right: 1rem;
|
right: 1.3rem;
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -45,11 +45,6 @@ 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,7 +41,6 @@ const ColStack = ({
|
|||||||
align,
|
align,
|
||||||
justifyContent,
|
justifyContent,
|
||||||
className,
|
className,
|
||||||
style,
|
|
||||||
}: StackProps) => {
|
}: StackProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -50,7 +49,6 @@ const ColStack = ({
|
|||||||
"--gap": gap,
|
"--gap": gap,
|
||||||
justifyItems: align,
|
justifyItems: align,
|
||||||
justifyContent,
|
justifyContent,
|
||||||
...style,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
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 { useDevice } from "../components/App";
|
import { useDeviceType } 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,13 +16,16 @@ export const Stats = (props: {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const deviceType = useDeviceType();
|
||||||
|
|
||||||
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,9 +2,6 @@
|
|||||||
|
|
||||||
.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;
|
||||||
@@ -18,24 +15,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
z-index: 999999;
|
z-index: 999999;
|
||||||
|
}
|
||||||
|
|
||||||
.Toast__message {
|
.Toast__message {
|
||||||
padding: 0 $closeButtonSize + ($closeButtonPadding);
|
color: var(--popup-text-color);
|
||||||
color: var(--popup-text-color);
|
white-space: pre-wrap;
|
||||||
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 {
|
||||||
|
|||||||
+10
-35
@@ -1,59 +1,34 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { close } from "./icons";
|
import { TOAST_TIMEOUT } from "../constants";
|
||||||
import "./Toast.scss";
|
import "./Toast.scss";
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
const DEFAULT_TOAST_TIMEOUT = 5000;
|
|
||||||
|
|
||||||
export const Toast = ({
|
export const Toast = ({
|
||||||
message,
|
message,
|
||||||
clearToast,
|
clearToast,
|
||||||
closable = false,
|
|
||||||
// To prevent autoclose, pass duration as Infinity
|
|
||||||
duration = DEFAULT_TOAST_TIMEOUT,
|
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message: string;
|
||||||
clearToast: () => void;
|
clearToast: () => void;
|
||||||
closable?: boolean;
|
|
||||||
duration?: number;
|
|
||||||
}) => {
|
}) => {
|
||||||
const timerRef = useRef<number>(0);
|
const timerRef = useRef<number>(0);
|
||||||
const shouldAutoClose = duration !== Infinity;
|
|
||||||
const scheduleTimeout = useCallback(() => {
|
const scheduleTimeout = useCallback(
|
||||||
if (!shouldAutoClose) {
|
() =>
|
||||||
return;
|
(timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
|
||||||
}
|
[clearToast],
|
||||||
timerRef.current = window.setTimeout(() => clearToast(), duration);
|
);
|
||||||
}, [clearToast, duration, shouldAutoClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldAutoClose) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scheduleTimeout();
|
scheduleTimeout();
|
||||||
return () => clearTimeout(timerRef.current);
|
return () => clearTimeout(timerRef.current);
|
||||||
}, [scheduleTimeout, message, duration, shouldAutoClose]);
|
}, [scheduleTimeout, message]);
|
||||||
|
|
||||||
const onMouseEnter = shouldAutoClose
|
|
||||||
? () => clearTimeout(timerRef?.current)
|
|
||||||
: undefined;
|
|
||||||
const onMouseLeave = shouldAutoClose ? scheduleTimeout : undefined;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="Toast"
|
className="Toast"
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={() => clearTimeout(timerRef?.current)}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={scheduleTimeout}
|
||||||
>
|
>
|
||||||
<p className="Toast__message">{message}</p>
|
<p className="Toast__message">{message}</p>
|
||||||
{closable && (
|
|
||||||
<ToolButton
|
|
||||||
icon={close}
|
|
||||||
aria-label="close"
|
|
||||||
type="icon"
|
|
||||||
onClick={clearToast}
|
|
||||||
className="close"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
@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 {
|
||||||
|
|||||||
@@ -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: fixed;
|
position: absolute;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@@ -2,51 +2,17 @@ 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";
|
|
||||||
|
|
||||||
export const UserList: React.FC<{
|
type UserListProps = {
|
||||||
|
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 })}>
|
||||||
{avatars}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-16
@@ -108,14 +108,14 @@ export const EXPORT_DATA_TYPES = {
|
|||||||
excalidrawLibrary: "excalidrawlib",
|
excalidrawLibrary: "excalidrawlib",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const EXPORT_SOURCE =
|
export const EXPORT_SOURCE = window.location.origin;
|
||||||
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,19 +154,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// breakpoints
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// sm screen
|
|
||||||
export const MQ_SM_MAX_WIDTH = 640;
|
|
||||||
// md screen
|
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
export const MQ_MAX_WIDTH_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;
|
||||||
|
|
||||||
@@ -202,7 +192,3 @@ 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;
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const createInverseContext = <T extends unknown = null>(
|
||||||
|
initialValue: T,
|
||||||
|
) => {
|
||||||
|
const Context = React.createContext(initialValue) as React.Context<T> & {
|
||||||
|
_updateProviderValue?: (value: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class InverseConsumer extends React.Component {
|
||||||
|
state = { value: initialValue };
|
||||||
|
constructor(props: any) {
|
||||||
|
super(props);
|
||||||
|
Context._updateProviderValue = (value: T) => this.setState({ value });
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Context.Provider value={this.state.value}>
|
||||||
|
{this.props.children}
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InverseProvider extends React.Component<{ value: T }> {
|
||||||
|
componentDidMount() {
|
||||||
|
Context._updateProviderValue?.(this.props.value);
|
||||||
|
}
|
||||||
|
componentDidUpdate() {
|
||||||
|
Context._updateProviderValue?.(this.props.value);
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Context,
|
||||||
|
Consumer: InverseConsumer,
|
||||||
|
Provider: InverseProvider,
|
||||||
|
};
|
||||||
|
};
|
||||||
+1
-18
@@ -350,6 +350,7 @@
|
|||||||
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;
|
||||||
@@ -390,7 +391,6 @@
|
|||||||
|
|
||||||
.App-menu__left {
|
.App-menu__left {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: var(--shadow-island);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-select {
|
.dropdown-select {
|
||||||
@@ -449,7 +449,6 @@
|
|||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
pointer-events: all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-icon {
|
.help-icon {
|
||||||
@@ -568,22 +567,6 @@
|
|||||||
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,32 +6,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin toolbarButtonColorStates {
|
|
||||||
.ToolIcon_type_radio,
|
|
||||||
.ToolIcon_type_checkbox {
|
|
||||||
& + .ToolIcon__icon:active {
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
}
|
|
||||||
&:checked + .ToolIcon__icon {
|
|
||||||
background: var(--color-primary);
|
|
||||||
--icon-fill-color: #{$oc-white};
|
|
||||||
--keybinding-color: #{$oc-white};
|
|
||||||
}
|
|
||||||
&:checked + .ToolIcon__icon:active {
|
|
||||||
background: var(--color-primary-darker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__keybinding {
|
|
||||||
bottom: 4px;
|
|
||||||
right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
$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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-187
@@ -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, nativeFileSystemSupported } from "./filesystem";
|
import { FileSystemHandle } 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,79 +123,48 @@ export const isSupportedImageFile = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadSceneOrLibraryFromBlob = async (
|
export const loadFromBlob = async (
|
||||||
blob: Blob | File,
|
blob: Blob,
|
||||||
/** @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)) {
|
||||||
return {
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
const result = restore(
|
||||||
|
{
|
||||||
|
elements: clearElementsForExport(data.elements || []),
|
||||||
|
appState: {
|
||||||
|
theme: localAppState?.theme,
|
||||||
|
fileHandle: blob.handle || null,
|
||||||
|
...cleanAppStateForExport(data.appState || {}),
|
||||||
|
...(localAppState
|
||||||
|
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
files: data.files,
|
||||||
|
},
|
||||||
|
localAppState,
|
||||||
|
localElements,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error: any) {
|
} 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 loadFromBlob = async (
|
export const loadLibraryFromBlob = 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 data: ImportedLibraryData | undefined = JSON.parse(json);
|
const contents = await parseFileContents(blob);
|
||||||
|
const data: ImportedLibraryData | undefined = JSON.parse(contents);
|
||||||
if (!isValidLibrary(data)) {
|
if (!isValidLibrary(data)) {
|
||||||
throw new Error("Invalid library");
|
throw new Error("Invalid library");
|
||||||
}
|
}
|
||||||
@@ -203,13 +172,6 @@ export const parseLibraryJSON = (
|
|||||||
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> => {
|
||||||
@@ -238,7 +200,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 blobToArrayBuffer(file),
|
await file.arrayBuffer(),
|
||||||
);
|
);
|
||||||
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
|
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -327,125 +289,3 @@ 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,5 +1,4 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ export const encryptData = async (
|
|||||||
: data instanceof Uint8Array
|
: data instanceof Uint8Array
|
||||||
? data
|
? data
|
||||||
: data instanceof Blob
|
: data instanceof Blob
|
||||||
? await blobToArrayBuffer(data)
|
? await data.arrayBuffer()
|
||||||
: 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,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
FileWithHandle,
|
||||||
fileOpen as _fileOpen,
|
fileOpen as _fileOpen,
|
||||||
fileSave as _fileSave,
|
fileSave as _fileSave,
|
||||||
FileSystemHandle,
|
FileSystemHandle,
|
||||||
@@ -25,9 +26,13 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
extensions?: FILE_EXTENSION[];
|
extensions?: FILE_EXTENSION[];
|
||||||
description: string;
|
description: string;
|
||||||
multiple?: M;
|
multiple?: M;
|
||||||
}): Promise<M extends false | undefined ? File : File[]> => {
|
}): Promise<
|
||||||
|
M extends false | undefined ? FileWithHandle : FileWithHandle[]
|
||||||
|
> => {
|
||||||
// an unsafe TS hack, alas not much we can do AFAIK
|
// an unsafe TS hack, alas not much we can do AFAIK
|
||||||
type RetType = M extends false | undefined ? File : File[];
|
type RetType = M extends false | undefined
|
||||||
|
? FileWithHandle
|
||||||
|
: FileWithHandle[];
|
||||||
|
|
||||||
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||||
mimeTypes.push(MIME_TYPES[type]);
|
mimeTypes.push(MIME_TYPES[type]);
|
||||||
|
|||||||
+17
-1
@@ -3,12 +3,28 @@ 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> => {
|
||||||
|
|||||||
+16
-8
@@ -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, normalizeFile } from "./blob";
|
import { isImageFileHandle, loadFromBlob } from "./blob";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ExportedDataState,
|
ExportedDataState,
|
||||||
@@ -17,6 +17,7 @@ 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
|
||||||
@@ -92,18 +93,13 @@ export const loadFromJSON = async (
|
|||||||
localAppState: AppState,
|
localAppState: AppState,
|
||||||
localElements: readonly ExcalidrawElement[] | null,
|
localElements: readonly ExcalidrawElement[] | null,
|
||||||
) => {
|
) => {
|
||||||
const file = await fileOpen({
|
const blob = 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(
|
return loadFromBlob(blob, localAppState, localElements);
|
||||||
await normalizeFile(file),
|
|
||||||
localAppState,
|
|
||||||
localElements,
|
|
||||||
file.handle,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidExcalidrawData = (data?: {
|
export const isValidExcalidrawData = (data?: {
|
||||||
@@ -151,3 +147,15 @@ export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const importLibraryFromJSON = async (library: Library) => {
|
||||||
|
const blob = await fileOpen({
|
||||||
|
description: "Excalidraw library files",
|
||||||
|
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||||
|
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||||
|
/*
|
||||||
|
extensions: [".json", ".excalidrawlib"],
|
||||||
|
*/
|
||||||
|
});
|
||||||
|
await library.importLibrary(blob);
|
||||||
|
};
|
||||||
|
|||||||
+86
-374
@@ -1,26 +1,17 @@
|
|||||||
import { loadLibraryFromBlob } from "./blob";
|
import { loadLibraryFromBlob } from "./blob";
|
||||||
import {
|
import { LibraryItems, LibraryItem } from "../types";
|
||||||
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 { ExcalidrawElement } from "../element/types";
|
import { isPromiseLike } from "../utils";
|
||||||
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" | "loaded";
|
| { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
|
||||||
isInitialized: boolean;
|
| { status: "loaded"; libraryItems: LibraryItems }
|
||||||
libraryItems: LibraryItems;
|
>({ status: "loaded", 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));
|
||||||
@@ -49,28 +40,12 @@ const isUniqueItem = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Merges otherItems into localItems. Unique items in otherItems array are
|
|
||||||
sorted first. */
|
|
||||||
export const mergeLibraryItems = (
|
|
||||||
localItems: LibraryItems,
|
|
||||||
otherItems: LibraryItems,
|
|
||||||
): LibraryItems => {
|
|
||||||
const newItems = [];
|
|
||||||
for (const item of otherItems) {
|
|
||||||
if (isUniqueItem(localItems, item)) {
|
|
||||||
newItems.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...newItems, ...localItems];
|
|
||||||
};
|
|
||||||
|
|
||||||
class Library {
|
class Library {
|
||||||
/** latest libraryItems */
|
/** cache for currently active promise when initializing/updating libaries
|
||||||
|
asynchronously */
|
||||||
|
private libraryItemsPromise: Promise<LibraryItems> | null = null;
|
||||||
|
/** last resolved libraryItems */
|
||||||
private lastLibraryItems: LibraryItems = [];
|
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;
|
||||||
|
|
||||||
@@ -78,359 +53,96 @@ class Library {
|
|||||||
this.app = app;
|
this.app = app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateQueue: Promise<LibraryItems>[] = [];
|
resetLibrary = async () => {
|
||||||
|
this.saveLibrary([]);
|
||||||
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
|
|
||||||
return this.updateQueue[this.updateQueue.length - 1];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private notifyListeners = () => {
|
/** imports library (currently merges, removing duplicates) */
|
||||||
if (this.updateQueue.length > 0) {
|
async importLibrary(
|
||||||
jotaiStore.set(libraryItemsAtom, {
|
library:
|
||||||
status: "loading",
|
| Blob
|
||||||
libraryItems: this.lastLibraryItems,
|
| Required<ImportedDataState>["libraryItems"]
|
||||||
isInitialized: this.isInitialized,
|
| Promise<Required<ImportedDataState>["libraryItems"]>,
|
||||||
});
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||||
} else {
|
) {
|
||||||
this.isInitialized = true;
|
return this.saveLibrary(
|
||||||
jotaiStore.set(libraryItemsAtom, {
|
new Promise<LibraryItems>(async (resolve, reject) => {
|
||||||
status: "loaded",
|
try {
|
||||||
libraryItems: this.lastLibraryItems,
|
let libraryItems: LibraryItems;
|
||||||
isInitialized: this.isInitialized,
|
if (library instanceof Blob) {
|
||||||
});
|
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
|
||||||
try {
|
} else {
|
||||||
this.app.props.onLibraryChange?.(
|
libraryItems = restoreLibraryItems(await library, defaultStatus);
|
||||||
cloneLibraryItems(this.lastLibraryItems),
|
}
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
resetLibrary = () => {
|
const existingLibraryItems = this.lastLibraryItems;
|
||||||
return this.setLibrary([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const filteredItems = [];
|
||||||
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
|
for (const item of libraryItems) {
|
||||||
*/
|
if (isUniqueItem(existingLibraryItems, item)) {
|
||||||
getLatestLibrary = (): Promise<LibraryItems> => {
|
filteredItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
const libraryItems = await (this.getLastUpdateTask() ||
|
resolve(
|
||||||
this.lastLibraryItems);
|
cloneLibraryItems(
|
||||||
if (this.updateQueue.length > 0) {
|
await (this.libraryItemsPromise || this.lastLibraryItems),
|
||||||
resolve(this.getLatestLibrary());
|
),
|
||||||
} else {
|
);
|
||||||
resolve(cloneLibraryItems(libraryItems));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return resolve(this.lastLibraryItems);
|
return resolve(this.lastLibraryItems);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE this is a high-level public API (exposed on ExcalidrawAPI) with
|
saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
|
||||||
// a slight overhead (always restoring library items). For internal use
|
const prevLibraryItems = this.lastLibraryItems;
|
||||||
// where merging isn't needed, use `library.setLibrary()` directly.
|
try {
|
||||||
updateLibrary = async ({
|
let nextLibraryItems;
|
||||||
libraryItems,
|
if (isPromiseLike(items)) {
|
||||||
prompt = false,
|
const promise = items.then((items) => cloneLibraryItems(items));
|
||||||
merge = false,
|
this.libraryItemsPromise = promise;
|
||||||
openLibraryMenu = false,
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
defaultStatus = "unpublished",
|
status: "loading",
|
||||||
}: {
|
promise,
|
||||||
libraryItems: LibraryItemsSource;
|
libraryItems: null,
|
||||||
merge?: boolean;
|
});
|
||||||
prompt?: boolean;
|
nextLibraryItems = await promise;
|
||||||
openLibraryMenu?: boolean;
|
} else {
|
||||||
defaultStatus?: "unpublished" | "published";
|
nextLibraryItems = cloneLibraryItems(items);
|
||||||
}): Promise<LibraryItems> => {
|
|
||||||
if (openLibraryMenu) {
|
|
||||||
this.app.setState({ isLibraryOpen: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.setLibrary(() => {
|
|
||||||
return new Promise<LibraryItems>(async (resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const source = await (typeof libraryItems === "function"
|
|
||||||
? libraryItems(this.lastLibraryItems)
|
|
||||||
: libraryItems);
|
|
||||||
|
|
||||||
let nextItems;
|
|
||||||
|
|
||||||
if (source instanceof Blob) {
|
|
||||||
nextItems = await loadLibraryFromBlob(source, defaultStatus);
|
|
||||||
} else {
|
|
||||||
nextItems = restoreLibraryItems(source, defaultStatus);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!prompt ||
|
|
||||||
window.confirm(
|
|
||||||
t("alerts.confirmAddLibrary", {
|
|
||||||
numShapes: nextItems.length,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (merge) {
|
|
||||||
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
|
|
||||||
} else {
|
|
||||||
resolve(nextItems);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reject(new AbortError());
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).finally(() => {
|
|
||||||
this.app.focusContainer();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
setLibrary = (
|
|
||||||
/**
|
|
||||||
* LibraryItems that will replace current items. Can be a function which
|
|
||||||
* will be invoked after all previous tasks are resolved
|
|
||||||
* (this is the prefered way to update the library to avoid race conditions,
|
|
||||||
* but you'll want to manually merge the library items in the callback
|
|
||||||
* - which is what we're doing in Library.importLibrary()).
|
|
||||||
*
|
|
||||||
* If supplied promise is rejected with AbortError, we swallow it and
|
|
||||||
* do not update the library.
|
|
||||||
*/
|
|
||||||
libraryItems:
|
|
||||||
| LibraryItems
|
|
||||||
| Promise<LibraryItems>
|
|
||||||
| ((
|
|
||||||
latestLibraryItems: LibraryItems,
|
|
||||||
) => LibraryItems | Promise<LibraryItems>),
|
|
||||||
): Promise<LibraryItems> => {
|
|
||||||
const task = new Promise<LibraryItems>(async (resolve, reject) => {
|
|
||||||
try {
|
|
||||||
await this.getLastUpdateTask();
|
|
||||||
|
|
||||||
if (typeof libraryItems === "function") {
|
|
||||||
libraryItems = libraryItems(this.lastLibraryItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
|
|
||||||
|
|
||||||
resolve(this.lastLibraryItems);
|
|
||||||
} catch (error: any) {
|
|
||||||
reject(error);
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch((error) => {
|
this.lastLibraryItems = nextLibraryItems;
|
||||||
if (error.name === "AbortError") {
|
this.libraryItemsPromise = null;
|
||||||
console.warn("Library update aborted by user");
|
|
||||||
return this.lastLibraryItems;
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
}
|
status: "loaded",
|
||||||
throw error;
|
libraryItems: nextLibraryItems,
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
|
|
||||||
this.notifyListeners();
|
|
||||||
});
|
});
|
||||||
|
await this.app.props.onLibraryChange?.(
|
||||||
this.updateQueue.push(task);
|
cloneLibraryItems(nextLibraryItems),
|
||||||
this.notifyListeners();
|
);
|
||||||
|
} catch (error: any) {
|
||||||
return task;
|
this.lastLibraryItems = prevLibraryItems;
|
||||||
|
this.libraryItemsPromise = null;
|
||||||
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
|
status: "loaded",
|
||||||
|
libraryItems: prevLibraryItems,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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]);
|
|
||||||
};
|
|
||||||
|
|||||||
+4
-16
@@ -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, updateActiveTool } from "../utils";
|
import { getUpdatedTimestamp } from "../utils";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
@@ -48,7 +48,6 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||||||
arrow: true,
|
arrow: true,
|
||||||
freedraw: true,
|
freedraw: true,
|
||||||
eraser: false,
|
eraser: false,
|
||||||
custom: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RestoredDataState = {
|
export type RestoredDataState = {
|
||||||
@@ -199,7 +198,6 @@ const restoreElement = (
|
|||||||
y,
|
y,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// generic elements
|
// generic elements
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return restoreElementWithProperties(element, {});
|
return restoreElementWithProperties(element, {});
|
||||||
@@ -257,7 +255,6 @@ export const restoreAppState = (
|
|||||||
? localValue
|
? localValue
|
||||||
: defaultValue;
|
: defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
cursorButton: localAppState?.cursorButton || "up",
|
cursorButton: localAppState?.cursorButton || "up",
|
||||||
@@ -266,15 +263,11 @@ 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:
|
||||||
@@ -283,11 +276,6 @@ 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,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+2
-7
@@ -1,10 +1,5 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import {
|
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
|
||||||
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";
|
||||||
|
|
||||||
@@ -24,7 +19,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_anyVersion;
|
libraryItems?: LibraryItems | LibraryItems_v1;
|
||||||
files?: BinaryFiles;
|
files?: BinaryFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-62
@@ -47,20 +47,6 @@ 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",
|
||||||
@@ -88,17 +74,16 @@ 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(Scene.getScene(linearElement)!, onlyUnbound).forEach(
|
.getNonDeletedElements(onlyUnbound)
|
||||||
(element) => {
|
.forEach((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 = (
|
||||||
@@ -268,7 +253,7 @@ export const getHoveredElementForBinding = (
|
|||||||
scene: Scene,
|
scene: Scene,
|
||||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
const hoveredElement = getElementAtPosition(
|
const hoveredElement = getElementAtPosition(
|
||||||
scene.getNonDeletedElements(),
|
scene.getElements(),
|
||||||
(element) =>
|
(element) =>
|
||||||
isBindableElement(element, false) &&
|
isBindableElement(element, false) &&
|
||||||
bindingBorderTest(element, pointerCoords),
|
bindingBorderTest(element, pointerCoords),
|
||||||
@@ -320,48 +305,46 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
getNonDeletedElements(
|
const bindableElement = changedElement as ExcalidrawBindableElement;
|
||||||
Scene.getScene(changedElement)!,
|
// In case the boundElements are stale
|
||||||
boundLinearElements.map((el) => el.id),
|
if (!doesNeedUpdate(element, bindableElement)) {
|
||||||
).forEach((element) => {
|
return;
|
||||||
if (!isLinearElement(element)) {
|
}
|
||||||
return;
|
const startBinding = maybeCalculateNewGapWhenScaling(
|
||||||
}
|
bindableElement,
|
||||||
|
element.startBinding,
|
||||||
const bindableElement = changedElement as ExcalidrawBindableElement;
|
newSize,
|
||||||
// In case the boundElements are stale
|
);
|
||||||
if (!doesNeedUpdate(element, bindableElement)) {
|
const endBinding = maybeCalculateNewGapWhenScaling(
|
||||||
return;
|
bindableElement,
|
||||||
}
|
element.endBinding,
|
||||||
const startBinding = maybeCalculateNewGapWhenScaling(
|
newSize,
|
||||||
bindableElement,
|
);
|
||||||
element.startBinding,
|
// `linearElement` is being moved/scaled already, just update the binding
|
||||||
newSize,
|
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||||
);
|
mutateElement(element, { startBinding, endBinding });
|
||||||
const endBinding = maybeCalculateNewGapWhenScaling(
|
return;
|
||||||
bindableElement,
|
}
|
||||||
element.endBinding,
|
updateBoundPoint(
|
||||||
newSize,
|
element,
|
||||||
);
|
"start",
|
||||||
// `linearElement` is being moved/scaled already, just update the binding
|
startBinding,
|
||||||
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
changedElement as ExcalidrawBindableElement,
|
||||||
mutateElement(element, { startBinding, endBinding });
|
);
|
||||||
return;
|
updateBoundPoint(
|
||||||
}
|
element,
|
||||||
updateBoundPoint(
|
"end",
|
||||||
element,
|
endBinding,
|
||||||
"start",
|
changedElement as ExcalidrawBindableElement,
|
||||||
startBinding,
|
);
|
||||||
changedElement as ExcalidrawBindableElement,
|
});
|
||||||
);
|
|
||||||
updateBoundPoint(
|
|
||||||
element,
|
|
||||||
"end",
|
|
||||||
endBinding,
|
|
||||||
changedElement as ExcalidrawBindableElement,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const doesNeedUpdate = (
|
const doesNeedUpdate = (
|
||||||
@@ -524,7 +507,7 @@ const getElligibleElementsForBindableElementAndWhere = (
|
|||||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||||
): SuggestedPointBinding[] => {
|
): SuggestedPointBinding[] => {
|
||||||
return Scene.getScene(bindableElement)!
|
return Scene.getScene(bindableElement)!
|
||||||
.getNonDeletedElements()
|
.getElements()
|
||||||
.map((element) => {
|
.map((element) => {
|
||||||
if (!isBindingElement(element, false)) {
|
if (!isBindingElement(element, false)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ 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,9 +115,6 @@ 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];
|
||||||
|
|
||||||
@@ -547,29 +544,6 @@ 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,14 +283,7 @@ 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
|
||||||
// Rounding here so that the lines calculated is more accurate in all browsers.
|
const lines = editable.scrollHeight / getApproxLineHeight(font);
|
||||||
// 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"
|
||||||
@@ -305,6 +298,7 @@ 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
|
||||||
@@ -322,6 +316,8 @@ 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);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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)
|
||||||
|
|||||||
+124
-127
@@ -8,12 +8,10 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import {
|
import { getSceneVersion } from "../../packages/excalidraw/index";
|
||||||
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,
|
||||||
@@ -32,9 +30,7 @@ import {
|
|||||||
generateCollaborationLinkData,
|
generateCollaborationLinkData,
|
||||||
getCollaborationLink,
|
getCollaborationLink,
|
||||||
getCollabServer,
|
getCollabServer,
|
||||||
getSyncableElements,
|
|
||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
SyncableExcalidrawElement,
|
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import {
|
import {
|
||||||
isSavedToFirebase,
|
isSavedToFirebase,
|
||||||
@@ -49,9 +45,12 @@ 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,
|
||||||
@@ -70,45 +69,52 @@ 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 Collab>;
|
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
||||||
|
|
||||||
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"];
|
||||||
startCollaboration: CollabInstance["startCollaboration"];
|
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||||
syncElements: CollabInstance["syncElements"];
|
syncElements: CollabInstance["syncElements"];
|
||||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||||
setUsername: (username: string) => void;
|
setUsername: (username: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicProps {
|
interface Props {
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
|
onRoomClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = PublicProps & { modalIsShown: boolean };
|
const {
|
||||||
|
Context: CollabContext,
|
||||||
|
Consumer: CollabContextConsumer,
|
||||||
|
Provider: CollabContextProvider,
|
||||||
|
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
|
||||||
|
|
||||||
class Collab extends PureComponent<Props, CollabState> {
|
export { CollabContext, CollabContextConsumer };
|
||||||
|
|
||||||
|
class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||||
portal: Portal;
|
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>();
|
||||||
@@ -116,8 +122,10 @@ class Collab 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);
|
||||||
@@ -155,18 +163,6 @@ class Collab 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
|
||||||
@@ -199,23 +195,19 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
|
isCollaborating = () => this._isCollaborating;
|
||||||
|
|
||||||
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 = getSyncableElements(
|
const syncableElements = this.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))
|
||||||
) {
|
) {
|
||||||
@@ -240,7 +232,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
saveCollabRoomToFirebase = async (
|
saveCollabRoomToFirebase = async (
|
||||||
syncableElements: readonly SyncableExcalidrawElement[],
|
syncableElements: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const savedData = await saveToFirebase(
|
const savedData = await saveToFirebase(
|
||||||
@@ -259,36 +251,31 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stopCollaboration = (keepRemoteState = true) => {
|
openPortal = async () => {
|
||||||
|
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||||
|
return this.initializeSocketClient(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
closePortal = () => {
|
||||||
this.queueBroadcastAllElements.cancel();
|
this.queueBroadcastAllElements.cancel();
|
||||||
this.queueSaveToFirebase.cancel();
|
this.queueSaveToFirebase.cancel();
|
||||||
this.loadImageFiles.cancel();
|
this.loadImageFiles.cancel();
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(
|
this.saveCollabRoomToFirebase(
|
||||||
getSyncableElements(
|
this.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");
|
||||||
|
|
||||||
LocalData.fileStorage.reset();
|
this.props.onRoomClose?.();
|
||||||
|
|
||||||
const elements = this.excalidrawAPI
|
const elements = this.excalidrawAPI
|
||||||
.getSceneElementsIncludingDeleted()
|
.getSceneElementsIncludingDeleted()
|
||||||
@@ -307,20 +294,20 @@ class Collab 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: {
|
||||||
@@ -361,9 +348,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private fallbackInitializationHandler: null | (() => any) = null;
|
private initializeSocketClient = async (
|
||||||
|
|
||||||
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) {
|
||||||
@@ -386,23 +371,13 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||||
|
|
||||||
this.setIsCollaborating(true);
|
this._isCollaborating = 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();
|
||||||
|
|
||||||
@@ -415,8 +390,6 @@ class Collab 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 });
|
||||||
@@ -440,15 +413,18 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback in case you're not alone in the room but still don't receive
|
// 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(() => {
|
||||||
fallbackInitializationHandler,
|
this.initializeRoom({
|
||||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
roomLinkData: existingRoomLinkData,
|
||||||
);
|
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(
|
||||||
@@ -553,12 +529,6 @@ class Collab 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();
|
||||||
|
|
||||||
@@ -596,8 +566,6 @@ class Collab 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,
|
||||||
@@ -703,17 +671,19 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setCollaborators(sockets: string[]) {
|
setCollaborators(sockets: string[]) {
|
||||||
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
this.setState((state) => {
|
||||||
new Map();
|
const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
|
||||||
for (const socketId of sockets) {
|
new Map();
|
||||||
if (this.collaborators.has(socketId)) {
|
for (const socketId of sockets) {
|
||||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
if (this.collaborators.has(socketId)) {
|
||||||
} else {
|
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||||
collaborators.set(socketId, {});
|
} else {
|
||||||
|
collaborators.set(socketId, {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
this.collaborators = collaborators;
|
||||||
this.collaborators = collaborators;
|
this.excalidrawAPI.updateScene({ collaborators });
|
||||||
this.excalidrawAPI.updateScene({ collaborators });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||||
@@ -742,6 +712,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
onIdleStateChange = (userState: UserIdleState) => {
|
onIdleStateChange = (userState: UserIdleState) => {
|
||||||
|
this.setState({ userState });
|
||||||
this.portal.broadcastIdleChange(userState);
|
this.portal.broadcastIdleChange(userState);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -775,22 +746,18 @@ class Collab 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) {
|
||||||
if (this.portal.socketInitialized) {
|
this.saveCollabRoomToFirebase(
|
||||||
this.saveCollabRoomToFirebase(
|
this.getSyncableElements(
|
||||||
getSyncableElements(
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||||
},
|
|
||||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
|
||||||
{ leading: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = () => {
|
||||||
jotaiStore.set(collabDialogShownAtom, false);
|
this.setState({ modalIsShown: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
setUsername = (username: string) => {
|
setUsername = (username: string) => {
|
||||||
@@ -802,10 +769,42 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
saveUsernameToLocalStorage(username);
|
saveUsernameToLocalStorage(username);
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
onCollabButtonClick = () => {
|
||||||
const { username, errorMessage, activeRoomLink } = this.state;
|
this.setState({
|
||||||
|
modalIsShown: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const { modalIsShown } = this.props;
|
isSyncableElement = (element: ExcalidrawElement) => {
|
||||||
|
return element.isDeleted || !isInvisiblySmallElement(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
||||||
|
elements.filter((element) => this.isSyncableElement(element));
|
||||||
|
|
||||||
|
/** PRIVATE. Use `this.getContextValue()` instead. */
|
||||||
|
private contextValue: CollabAPI | null = null;
|
||||||
|
|
||||||
|
/** Getter of context value. Returned object is stable. */
|
||||||
|
getContextValue = (): CollabAPI => {
|
||||||
|
if (!this.contextValue) {
|
||||||
|
this.contextValue = {} as CollabAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.contextValue.isCollaborating = this.isCollaborating;
|
||||||
|
this.contextValue.username = this.state.username;
|
||||||
|
this.contextValue.onPointerUpdate = this.onPointerUpdate;
|
||||||
|
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
||||||
|
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
||||||
|
this.contextValue.syncElements = this.syncElements;
|
||||||
|
this.contextValue.fetchImageFilesFromFirebase =
|
||||||
|
this.fetchImageFilesFromFirebase;
|
||||||
|
this.contextValue.setUsername = this.setUsername;
|
||||||
|
return this.contextValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -815,8 +814,8 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
activeRoomLink={activeRoomLink}
|
activeRoomLink={activeRoomLink}
|
||||||
username={username}
|
username={username}
|
||||||
onUsernameChange={this.onUsernameChange}
|
onUsernameChange={this.onUsernameChange}
|
||||||
onRoomCreate={() => this.startCollaboration(null)}
|
onRoomCreate={this.openPortal}
|
||||||
onRoomDestroy={this.stopCollaboration}
|
onRoomDestroy={this.closePortal}
|
||||||
setErrorMessage={(errorMessage) => {
|
setErrorMessage={(errorMessage) => {
|
||||||
this.setState({ errorMessage });
|
this.setState({ errorMessage });
|
||||||
}}
|
}}
|
||||||
@@ -829,6 +828,11 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
onClose={() => this.setState({ errorMessage: "" })}
|
onClose={() => this.setState({ errorMessage: "" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<CollabContextProvider
|
||||||
|
value={{
|
||||||
|
api: this.getContextValue(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -836,7 +840,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
collab: InstanceType<typeof Collab>;
|
collab: InstanceType<typeof CollabWrapper>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,11 +851,4 @@ if (
|
|||||||
window.collab = window.collab || ({} as Window["collab"]);
|
window.collab = window.collab || ({} as Window["collab"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const _Collab: React.FC<PublicProps> = (props) => {
|
export default CollabWrapper;
|
||||||
const [collabDialogShown] = useAtom(collabDialogShownAtom);
|
|
||||||
return <Collab {...props} modalIsShown={collabDialogShown} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default _Collab;
|
|
||||||
|
|
||||||
export type TCollabClass = Collab;
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import {
|
import { SocketUpdateData, SocketUpdateDataSource } from "../data";
|
||||||
isSyncableElement,
|
|
||||||
SocketUpdateData,
|
|
||||||
SocketUpdateDataSource,
|
|
||||||
} from "../data";
|
|
||||||
|
|
||||||
import { TCollabClass } from "./Collab";
|
import CollabWrapper from "./CollabWrapper";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import {
|
import {
|
||||||
@@ -20,14 +16,14 @@ import { BroadcastedExcalidrawElement } from "./reconciliation";
|
|||||||
import { encryptData } from "../../data/encryption";
|
import { encryptData } from "../../data/encryption";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
collab: TCollabClass;
|
collab: CollabWrapper;
|
||||||
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: TCollabClass) {
|
constructor(collab: CollabWrapper) {
|
||||||
this.collab = collab;
|
this.collab = collab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +143,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)!) &&
|
||||||
isSyncableElement(element)
|
this.collab.isSyncableElement(element)
|
||||||
) {
|
) {
|
||||||
acc.push({
|
acc.push({
|
||||||
...element,
|
...element,
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ 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;
|
||||||
@@ -97,10 +95,7 @@ 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={() => {
|
onClick={onRoomCreate}
|
||||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
|
||||||
onRoomCreate();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -165,10 +160,7 @@ 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={() => {
|
onClick={onRoomDestroy}
|
||||||
trackEvent("share", "room closed");
|
|
||||||
onRoomDestroy();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -128,18 +127,7 @@ const decryptElements = async (
|
|||||||
return JSON.parse(decodedData);
|
return JSON.parse(decodedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
class FirebaseSceneVersionCache {
|
const firebaseSceneVersionCache = new WeakMap<SocketIOClient.Socket, number>();
|
||||||
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,
|
||||||
@@ -148,7 +136,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)
|
||||||
@@ -193,7 +181,7 @@ export const saveFilesToFirebase = async ({
|
|||||||
|
|
||||||
const createFirebaseSceneDocument = async (
|
const createFirebaseSceneDocument = async (
|
||||||
firebase: ResolutionType<typeof loadFirestore>,
|
firebase: ResolutionType<typeof loadFirestore>,
|
||||||
elements: readonly SyncableExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
) => {
|
) => {
|
||||||
const sceneVersion = getSceneVersion(elements);
|
const sceneVersion = getSceneVersion(elements);
|
||||||
@@ -209,7 +197,7 @@ const createFirebaseSceneDocument = async (
|
|||||||
|
|
||||||
export const saveToFirebase = async (
|
export const saveToFirebase = async (
|
||||||
portal: Portal,
|
portal: Portal,
|
||||||
elements: readonly SyncableExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
const { roomId, roomKey, socket } = portal;
|
const { roomId, roomKey, socket } = portal;
|
||||||
@@ -241,18 +229,18 @@ export const saveToFirebase = async (
|
|||||||
transaction.set(docRef, sceneDocument);
|
transaction.set(docRef, sceneDocument);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
sceneVersion: sceneDocument.sceneVersion,
|
||||||
reconciledElements: null,
|
reconciledElements: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
||||||
const prevElements = getSyncableElements(
|
const prevElements = await decryptElements(prevDocData, roomKey);
|
||||||
await decryptElements(prevDocData, roomKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
const reconciledElements = getSyncableElements(
|
const reconciledElements = reconcileElements(
|
||||||
reconcileElements(elements, prevElements, appState),
|
elements,
|
||||||
|
prevElements,
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const sceneDocument = await createFirebaseSceneDocument(
|
const sceneDocument = await createFirebaseSceneDocument(
|
||||||
@@ -263,14 +251,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.elements);
|
firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
|
||||||
|
|
||||||
return { reconciledElements: savedData.reconciledElements };
|
return savedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromFirebase = async (
|
export const loadFromFirebase = async (
|
||||||
@@ -287,12 +275,10 @@ export const loadFromFirebase = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const storedScene = doc.data() as FirebaseStoredScene;
|
const storedScene = doc.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = await decryptElements(storedScene, roomKey);
|
||||||
await decryptElements(storedScene, roomKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
FirebaseSceneVersionCache.set(socket, elements);
|
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreElements(elements, null);
|
return restoreElements(elements, null);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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";
|
||||||
@@ -18,35 +17,10 @@ import {
|
|||||||
UserIdleState,
|
UserIdleState,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
import { bytesToHexString } from "../../utils";
|
import { bytesToHexString } from "../../utils";
|
||||||
import {
|
import { FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES } from "../app_constants";
|
||||||
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;
|
||||||
|
|
||||||
@@ -134,16 +108,9 @@ 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(RE_COLLAB_LINK);
|
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
||||||
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,25 +32,3 @@
|
|||||||
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, useEffect, useRef, useState } from "react";
|
import { useCallback, useContext, 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 { t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
defaultLang,
|
defaultLang,
|
||||||
@@ -45,26 +45,20 @@ import {
|
|||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
SYNC_BROWSER_TABS_TIMEOUT,
|
SYNC_BROWSER_TABS_TIMEOUT,
|
||||||
} from "./app_constants";
|
} from "./app_constants";
|
||||||
import Collab, {
|
import CollabWrapper, {
|
||||||
CollabAPI,
|
CollabAPI,
|
||||||
collabAPIAtom,
|
CollabContext,
|
||||||
collabDialogShownAtom,
|
CollabContextConsumer,
|
||||||
isCollaboratingAtom,
|
} from "./collab/CollabWrapper";
|
||||||
} from "./collab/Collab";
|
|
||||||
import { LanguageList } from "./components/LanguageList";
|
import { LanguageList } from "./components/LanguageList";
|
||||||
import {
|
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
|
||||||
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 { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
import { 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";
|
||||||
|
|
||||||
@@ -78,18 +72,14 @@ 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";
|
|
||||||
|
|
||||||
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: {
|
||||||
@@ -179,7 +169,7 @@ const initializeScene = async (opts: {
|
|||||||
|
|
||||||
if (roomLinkData) {
|
if (roomLinkData) {
|
||||||
return {
|
return {
|
||||||
scene: await opts.collabAPI.startCollaboration(roomLinkData),
|
scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
|
||||||
isExternalScene: true,
|
isExternalScene: true,
|
||||||
id: roomLinkData.roomId,
|
id: roomLinkData.roomId,
|
||||||
key: roomLinkData.roomKey,
|
key: roomLinkData.roomKey,
|
||||||
@@ -197,7 +187,7 @@ const initializeScene = async (opts: {
|
|||||||
return { scene: null, isExternalScene: false };
|
return { scene: null, isExternalScene: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
const PlusLPLinkJSX = (
|
const PlusLinkJSX = (
|
||||||
<p style={{ direction: "ltr", unicodeBidi: "embed" }}>
|
<p style={{ direction: "ltr", unicodeBidi: "embed" }}>
|
||||||
Introducing Excalidraw+
|
Introducing Excalidraw+
|
||||||
<br />
|
<br />
|
||||||
@@ -211,17 +201,6 @@ const PlusLPLinkJSX = (
|
|||||||
</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;
|
||||||
@@ -251,16 +230,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [excalidrawAPI, excalidrawRefCallback] =
|
const [excalidrawAPI, excalidrawRefCallback] =
|
||||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||||
|
|
||||||
const [collabAPI] = useAtom(collabAPIAtom);
|
const collabAPI = useContext(CollabContext)?.api;
|
||||||
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) {
|
||||||
@@ -331,46 +301,33 @@ const ExcalidrawWrapper = () => {
|
|||||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.scene.libraryItems = getLibraryItemsFromStorage();
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeScene({ collabAPI }).then(async (data) => {
|
initializeScene({ collabAPI }).then((data) => {
|
||||||
loadImages(data, /* isInitialLoad */ true);
|
loadImages(data, /* isInitialLoad */ true);
|
||||||
|
initialStatePromiseRef.current.promise.resolve(data.scene);
|
||||||
initialStatePromiseRef.current.promise.resolve({
|
|
||||||
...data.scene,
|
|
||||||
// at this point the state may have already been updated (e.g. when
|
|
||||||
// collaborating, we may have received updates from other clients)
|
|
||||||
appState: restoreAppState(
|
|
||||||
data.scene?.appState,
|
|
||||||
excalidrawAPI.getAppState(),
|
|
||||||
),
|
|
||||||
elements: reconcileElements(
|
|
||||||
data.scene?.elements || [],
|
|
||||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
|
||||||
excalidrawAPI.getAppState(),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onHashChange = async (event: HashChangeEvent) => {
|
const onHashChange = (event: HashChangeEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||||
if (!libraryUrlTokens) {
|
const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
|
||||||
if (
|
if (libraryUrl) {
|
||||||
collabAPI.isCollaborating() &&
|
// If hash changed and it contains library url, import it and replace
|
||||||
!isCollaborationLink(window.location.href)
|
// the url to its previous state (important in case of collaboration
|
||||||
) {
|
// and similar).
|
||||||
collabAPI.stopCollaboration(false);
|
// Using history API won't trigger another hashchange.
|
||||||
}
|
window.history.replaceState({}, "", event.oldURL);
|
||||||
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
|
||||||
|
} 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,
|
||||||
...restore(data.scene, null, null),
|
appState: restoreAppState(data.scene.appState, null),
|
||||||
commitToHistory: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -398,8 +355,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
setLangCode(langCode);
|
setLangCode(langCode);
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
});
|
|
||||||
excalidrawAPI.updateLibrary({
|
|
||||||
libraryItems: getLibraryItemsFromStorage(),
|
libraryItems: getLibraryItemsFromStorage(),
|
||||||
});
|
});
|
||||||
collabAPI.setUsername(username || "");
|
collabAPI.setUsername(username || "");
|
||||||
@@ -511,17 +466,19 @@ 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)
|
||||||
) {
|
) {
|
||||||
const newElement = newElementWith(element, { status: "saved" });
|
didChange = true;
|
||||||
if (newElement !== element) {
|
const newEl = newElementWith(element, { status: "saved" });
|
||||||
didChange = true;
|
if (pendingImageElement === element) {
|
||||||
|
pendingImageElement = newEl;
|
||||||
}
|
}
|
||||||
return newElement;
|
return newEl;
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
});
|
});
|
||||||
@@ -529,6 +486,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (didChange) {
|
if (didChange) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
|
appState: {
|
||||||
|
pendingImageElement,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -572,16 +532,17 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: isExcalidrawPlusSignedUser ? "21ch" : "23ch",
|
width: "24ch",
|
||||||
fontSize: "0.7em",
|
fontSize: "0.7em",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
|
{/* <GitHubCorner theme={appState.theme} dir={document.dir} /> */}
|
||||||
|
{/* FIXME remove after 2021-05-20 */}
|
||||||
|
{PlusLinkJSX}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -633,14 +594,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
marginTop: isTinyDevice ? 16 : undefined,
|
marginTop: isTinyDevice ? 16 : undefined,
|
||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
marginRight: isTinyDevice ? "auto" : undefined,
|
marginRight: isTinyDevice ? "auto" : undefined,
|
||||||
padding: isExcalidrawPlusSignedUser ? undefined : "4px 2px",
|
padding: "4px 2px",
|
||||||
border: isExcalidrawPlusSignedUser
|
border: "1px dashed #aaa",
|
||||||
? undefined
|
|
||||||
: "1px dashed #aaa",
|
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
|
{PlusLinkJSX}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -672,19 +631,23 @@ 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": isCollaborating,
|
"is-collaborating": collabAPI?.isCollaborating(),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
ref={excalidrawRefCallback}
|
ref={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
onCollabButtonClick={() => setCollabDialogShown(true)}
|
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={collabAPI?.isCollaborating()}
|
||||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||||
UIOptions={{
|
UIOptions={{
|
||||||
canvasActions: {
|
canvasActions: {
|
||||||
@@ -718,7 +681,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
onLibraryChange={onLibraryChange}
|
onLibraryChange={onLibraryChange}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
/>
|
/>
|
||||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
{excalidrawAPI && (
|
||||||
|
<CollabWrapper
|
||||||
|
excalidrawAPI={excalidrawAPI}
|
||||||
|
onRoomClose={onRoomClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
@@ -732,9 +700,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
const ExcalidrawApp = () => {
|
const ExcalidrawApp = () => {
|
||||||
return (
|
return (
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<Provider unstable_createStore={() => jotaiStore}>
|
<CollabContextConsumer>
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawWrapper />
|
||||||
</Provider>
|
</CollabContextConsumer>
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
-3
@@ -13,7 +13,6 @@ interface Window {
|
|||||||
ClipboardItem: any;
|
ClipboardItem: any;
|
||||||
__EXCALIDRAW_SHA__: string | undefined;
|
__EXCALIDRAW_SHA__: string | undefined;
|
||||||
EXCALIDRAW_ASSET_PATH: string | undefined;
|
EXCALIDRAW_ASSET_PATH: string | undefined;
|
||||||
EXCALIDRAW_EXPORT_SOURCE: string;
|
|
||||||
gtag: Function;
|
gtag: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +34,6 @@ type Mutable<T> = {
|
|||||||
-readonly [P in keyof T]: T[P];
|
-readonly [P in keyof T]: T[P];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ValueOf<T> = T[keyof T];
|
|
||||||
|
|
||||||
type Merge<M, N> = Omit<M, keyof N> & N;
|
type Merge<M, N> = Omit<M, keyof N> & N;
|
||||||
|
|
||||||
/** utility type to assert that the second type is a subtype of the first type.
|
/** utility type to assert that the second type is a subtype of the first type.
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ export const setLanguage = async (lang: Language) => {
|
|||||||
currentLangData = {};
|
currentLangData = {};
|
||||||
} else {
|
} else {
|
||||||
currentLangData = await import(
|
currentLangData = await import(
|
||||||
/* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json`
|
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-24
@@ -1,27 +1,4 @@
|
|||||||
import { unstable_createStore, useAtom, WritableAtom } from "jotai";
|
import { unstable_createStore } from "jotai";
|
||||||
import { useLayoutEffect } from "react";
|
|
||||||
|
|
||||||
export const jotaiScope = Symbol();
|
export const jotaiScope = Symbol();
|
||||||
export const jotaiStore = unstable_createStore();
|
export const jotaiStore = unstable_createStore();
|
||||||
|
|
||||||
export const useAtomWithInitialValue = <
|
|
||||||
T extends unknown,
|
|
||||||
A extends WritableAtom<T, T>,
|
|
||||||
>(
|
|
||||||
atom: A,
|
|
||||||
initialValue: T | (() => T),
|
|
||||||
) => {
|
|
||||||
const [value, setValue] = useAtom(atom);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (typeof initialValue === "function") {
|
|
||||||
// @ts-ignore
|
|
||||||
setValue(initialValue());
|
|
||||||
} else {
|
|
||||||
setValue(initialValue);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [value, setValue] as const;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -119,8 +119,7 @@
|
|||||||
"unlock": "",
|
"unlock": "",
|
||||||
"lockAll": "",
|
"lockAll": "",
|
||||||
"unlockAll": ""
|
"unlockAll": ""
|
||||||
},
|
}
|
||||||
"statusPublished": ""
|
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "إعادة تعيين اللوحة",
|
"clearReset": "إعادة تعيين اللوحة",
|
||||||
@@ -173,6 +172,7 @@
|
|||||||
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
|
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
|
||||||
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
||||||
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)",
|
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)",
|
||||||
|
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
|
||||||
"errorAddingToLibrary": "تعذر إضافة العنصر للمكتبة",
|
"errorAddingToLibrary": "تعذر إضافة العنصر للمكتبة",
|
||||||
"errorRemovingFromLibrary": "تعذر إزالة العنصر من المكتبة",
|
"errorRemovingFromLibrary": "تعذر إزالة العنصر من المكتبة",
|
||||||
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
||||||
@@ -189,8 +189,7 @@
|
|||||||
"fileTooBig": "الملف كبير جداً. الحد الأقصى المسموح به للحجم هو {{maxSize}}.",
|
"fileTooBig": "الملف كبير جداً. الحد الأقصى المسموح به للحجم هو {{maxSize}}.",
|
||||||
"svgImageInsertError": "تعذر إدراج صورة SVG. يبدو أن ترميز SVG غير صحيح.",
|
"svgImageInsertError": "تعذر إدراج صورة SVG. يبدو أن ترميز SVG غير صحيح.",
|
||||||
"invalidSVGString": "SVG غير صالح.",
|
"invalidSVGString": "SVG غير صالح.",
|
||||||
"cannotResolveCollabServer": "",
|
"cannotResolveCollabServer": ""
|
||||||
"importLibraryError": ""
|
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "تحديد",
|
"selection": "تحديد",
|
||||||
@@ -342,8 +341,7 @@
|
|||||||
"post": "وهو ما يعني باختصار أنه يمكن لأي شخص استخدامها دون قيود."
|
"post": "وهو ما يعني باختصار أنه يمكن لأي شخص استخدامها دون قيود."
|
||||||
},
|
},
|
||||||
"noteItems": "يجب أن يكون لكل عنصر مكتبة اسمه الخاص حتى يكون قابلاً للتصفية. سيتم تضمين عناصر المكتبة التالية:",
|
"noteItems": "يجب أن يكون لكل عنصر مكتبة اسمه الخاص حتى يكون قابلاً للتصفية. سيتم تضمين عناصر المكتبة التالية:",
|
||||||
"atleastOneLibItem": "يرجى تحديد عنصر مكتبة واحد على الأقل للبدء",
|
"atleastOneLibItem": "يرجى تحديد عنصر مكتبة واحد على الأقل للبدء"
|
||||||
"republishWarning": ""
|
|
||||||
},
|
},
|
||||||
"publishSuccessDialog": {
|
"publishSuccessDialog": {
|
||||||
"title": "تم إرسال المكتبة",
|
"title": "تم إرسال المكتبة",
|
||||||
|
|||||||
@@ -119,8 +119,7 @@
|
|||||||
"unlock": "",
|
"unlock": "",
|
||||||
"lockAll": "",
|
"lockAll": "",
|
||||||
"unlockAll": ""
|
"unlockAll": ""
|
||||||
},
|
}
|
||||||
"statusPublished": ""
|
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Нулиране на платно",
|
"clearReset": "Нулиране на платно",
|
||||||
@@ -173,6 +172,7 @@
|
|||||||
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
|
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
|
||||||
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
|
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
|
||||||
"collabStopOverridePrompt": "Прекратяването на сесията ще презапише предишната, локално запазена, рисунка. Сигурни ли сте?\n\n(Ако искате да продължите с локалната рисунка, просто затворете таба на браузъра.)",
|
"collabStopOverridePrompt": "Прекратяването на сесията ще презапише предишната, локално запазена, рисунка. Сигурни ли сте?\n\n(Ако искате да продължите с локалната рисунка, просто затворете таба на браузъра.)",
|
||||||
|
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
|
||||||
"errorAddingToLibrary": "",
|
"errorAddingToLibrary": "",
|
||||||
"errorRemovingFromLibrary": "",
|
"errorRemovingFromLibrary": "",
|
||||||
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
|
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
|
||||||
@@ -189,8 +189,7 @@
|
|||||||
"fileTooBig": "",
|
"fileTooBig": "",
|
||||||
"svgImageInsertError": "",
|
"svgImageInsertError": "",
|
||||||
"invalidSVGString": "",
|
"invalidSVGString": "",
|
||||||
"cannotResolveCollabServer": "",
|
"cannotResolveCollabServer": ""
|
||||||
"importLibraryError": ""
|
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Селекция",
|
"selection": "Селекция",
|
||||||
@@ -342,8 +341,7 @@
|
|||||||
"post": ""
|
"post": ""
|
||||||
},
|
},
|
||||||
"noteItems": "",
|
"noteItems": "",
|
||||||
"atleastOneLibItem": "",
|
"atleastOneLibItem": ""
|
||||||
"republishWarning": ""
|
|
||||||
},
|
},
|
||||||
"publishSuccessDialog": {
|
"publishSuccessDialog": {
|
||||||
"title": "",
|
"title": "",
|
||||||
|
|||||||
@@ -119,8 +119,7 @@
|
|||||||
"unlock": "",
|
"unlock": "",
|
||||||
"lockAll": "",
|
"lockAll": "",
|
||||||
"unlockAll": ""
|
"unlockAll": ""
|
||||||
},
|
}
|
||||||
"statusPublished": ""
|
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "",
|
"clearReset": "",
|
||||||
@@ -173,6 +172,7 @@
|
|||||||
"uploadedSecurly": "",
|
"uploadedSecurly": "",
|
||||||
"loadSceneOverridePrompt": "",
|
"loadSceneOverridePrompt": "",
|
||||||
"collabStopOverridePrompt": "",
|
"collabStopOverridePrompt": "",
|
||||||
|
"errorLoadingLibrary": "",
|
||||||
"errorAddingToLibrary": "",
|
"errorAddingToLibrary": "",
|
||||||
"errorRemovingFromLibrary": "",
|
"errorRemovingFromLibrary": "",
|
||||||
"confirmAddLibrary": "",
|
"confirmAddLibrary": "",
|
||||||
@@ -189,8 +189,7 @@
|
|||||||
"fileTooBig": "",
|
"fileTooBig": "",
|
||||||
"svgImageInsertError": "",
|
"svgImageInsertError": "",
|
||||||
"invalidSVGString": "",
|
"invalidSVGString": "",
|
||||||
"cannotResolveCollabServer": "",
|
"cannotResolveCollabServer": ""
|
||||||
"importLibraryError": ""
|
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "",
|
"selection": "",
|
||||||
@@ -342,8 +341,7 @@
|
|||||||
"post": ""
|
"post": ""
|
||||||
},
|
},
|
||||||
"noteItems": "",
|
"noteItems": "",
|
||||||
"atleastOneLibItem": "",
|
"atleastOneLibItem": ""
|
||||||
"republishWarning": ""
|
|
||||||
},
|
},
|
||||||
"publishSuccessDialog": {
|
"publishSuccessDialog": {
|
||||||
"title": "",
|
"title": "",
|
||||||
|
|||||||
@@ -119,8 +119,7 @@
|
|||||||
"unlock": "",
|
"unlock": "",
|
||||||
"lockAll": "",
|
"lockAll": "",
|
||||||
"unlockAll": ""
|
"unlockAll": ""
|
||||||
},
|
}
|
||||||
"statusPublished": ""
|
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Neteja el llenç",
|
"clearReset": "Neteja el llenç",
|
||||||
@@ -173,6 +172,7 @@
|
|||||||
"uploadedSecurly": "La càrrega s'ha assegurat amb xifratge punta a punta, cosa que significa que el servidor Excalidraw i tercers no poden llegir el contingut.",
|
"uploadedSecurly": "La càrrega s'ha assegurat amb xifratge punta a punta, cosa que significa que el servidor Excalidraw i tercers no poden llegir el contingut.",
|
||||||
"loadSceneOverridePrompt": "Si carregas aquest dibuix extern, substituirá el que tens. Vols continuar?",
|
"loadSceneOverridePrompt": "Si carregas aquest dibuix extern, substituirá el que tens. Vols continuar?",
|
||||||
"collabStopOverridePrompt": "Aturar la sessió provocarà la sobreescriptura del dibuix previ, que hi ha desat en l'emmagatzematge local. N'esteu segur?\n\n(Si voleu conservar el dibuix local, tanqueu la pentanya del navegador en comptes d'aturar la sessió).",
|
"collabStopOverridePrompt": "Aturar la sessió provocarà la sobreescriptura del dibuix previ, que hi ha desat en l'emmagatzematge local. N'esteu segur?\n\n(Si voleu conservar el dibuix local, tanqueu la pentanya del navegador en comptes d'aturar la sessió).",
|
||||||
|
"errorLoadingLibrary": "S'ha produït un error en carregar la biblioteca de tercers.",
|
||||||
"errorAddingToLibrary": "No s'ha pogut afegir l'element a la biblioteca",
|
"errorAddingToLibrary": "No s'ha pogut afegir l'element a la biblioteca",
|
||||||
"errorRemovingFromLibrary": "No s'ha pogut eliminar l'element de la biblioteca",
|
"errorRemovingFromLibrary": "No s'ha pogut eliminar l'element de la biblioteca",
|
||||||
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
|
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
|
||||||
@@ -189,8 +189,7 @@
|
|||||||
"fileTooBig": "El fitxer és massa gros. La mida màxima permesa és {{maxSize}}.",
|
"fileTooBig": "El fitxer és massa gros. La mida màxima permesa és {{maxSize}}.",
|
||||||
"svgImageInsertError": "No ha estat possible inserir la imatge SVG. Les marques SVG semblen invàlides.",
|
"svgImageInsertError": "No ha estat possible inserir la imatge SVG. Les marques SVG semblen invàlides.",
|
||||||
"invalidSVGString": "SVG no vàlid.",
|
"invalidSVGString": "SVG no vàlid.",
|
||||||
"cannotResolveCollabServer": "",
|
"cannotResolveCollabServer": ""
|
||||||
"importLibraryError": ""
|
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Selecció",
|
"selection": "Selecció",
|
||||||
@@ -342,8 +341,7 @@
|
|||||||
"post": "que, en resum, vol dir que qualsevol persona pot fer-ne ús sense restriccions."
|
"post": "que, en resum, vol dir que qualsevol persona pot fer-ne ús sense restriccions."
|
||||||
},
|
},
|
||||||
"noteItems": "Cada element de la biblioteca ha de tenir el seu propi nom per tal que sigui filtrable. S'hi inclouran els elements següents:",
|
"noteItems": "Cada element de la biblioteca ha de tenir el seu propi nom per tal que sigui filtrable. S'hi inclouran els elements següents:",
|
||||||
"atleastOneLibItem": "Si us plau, seleccioneu si més no un element de la biblioteca per a començar",
|
"atleastOneLibItem": "Si us plau, seleccioneu si més no un element de la biblioteca per a començar"
|
||||||
"republishWarning": ""
|
|
||||||
},
|
},
|
||||||
"publishSuccessDialog": {
|
"publishSuccessDialog": {
|
||||||
"title": "Biblioteca enviada",
|
"title": "Biblioteca enviada",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user