Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4b951a0c5 | |||
| c93d8f4bd0 | |||
| 645f9a5dc0 | |||
| 128b7741c1 | |||
| 1edde7291c | |||
| 1ca56204b1 | |||
| f3ae7a8506 | |||
| 5f57daa132 | |||
| db9c9eb3d2 | |||
| 2e8c4d25f2 | |||
| 4953828d86 | |||
| 6eb0cf6a10 | |||
| ba48aa24a0 | |||
| 4e75f10b2c | |||
| d2d3599661 | |||
| ed3eda3401 | |||
| d27b32dd2c | |||
| 2337842f57 | |||
| 5b78f50fe3 | |||
| a4a95a591a | |||
| 3d459076fb | |||
| 14a23c6c50 | |||
| 5f4a5b1789 | |||
| 47498796e0 | |||
| 8706277d14 | |||
| 3d0a1106ff | |||
| 61699ff3c2 | |||
| 39d0084a5e |
@@ -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
|
||||||
|
|||||||
+7
-10
@@ -22,7 +22,7 @@
|
|||||||
"@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",
|
||||||
@@ -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;
|
||||||
@@ -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,22 +304,36 @@ export const actionErase = register({
|
|||||||
name: "eraser",
|
name: "eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
let activeTool: AppState["activeTool"];
|
const activeTool: any = { ...appState.activeTool };
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
if (appState.activeTool.type !== "eraser") {
|
||||||
activeTool = updateActiveTool(appState, {
|
if (appState.activeTool.type === "custom") {
|
||||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
activeTool.lastActiveToolBeforeEraser = {
|
||||||
type: "selection",
|
type: "custom",
|
||||||
}),
|
customType: appState.activeTool.customType,
|
||||||
lastActiveToolBeforeEraser: null,
|
};
|
||||||
});
|
} else {
|
||||||
} else {
|
activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
|
||||||
activeTool = updateActiveTool(appState, {
|
}
|
||||||
type: "eraser",
|
}
|
||||||
lastActiveToolBeforeEraser: appState.activeTool,
|
if (isEraserActive(appState)) {
|
||||||
});
|
if (appState.activeTool.lastActiveToolBeforeEraser) {
|
||||||
|
if (
|
||||||
|
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
|
||||||
|
appState.activeTool.lastActiveToolBeforeEraser?.type === "custom"
|
||||||
|
) {
|
||||||
|
activeTool.type = "custom";
|
||||||
|
activeTool.customType =
|
||||||
|
appState.activeTool.lastActiveToolBeforeEraser.customType;
|
||||||
|
} else {
|
||||||
|
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeTool.type = "selection";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeTool.type = "eraser";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -141,21 +136,21 @@ export const actionFinalize = register({
|
|||||||
) {
|
) {
|
||||||
resetCursor(canvas);
|
resetCursor(canvas);
|
||||||
}
|
}
|
||||||
|
const activeTool: any = { ...appState.activeTool };
|
||||||
let activeTool: AppState["activeTool"];
|
if (appState.activeTool.lastActiveToolBeforeEraser) {
|
||||||
if (appState.activeTool.type === "eraser") {
|
if (
|
||||||
activeTool = updateActiveTool(appState, {
|
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
|
||||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
|
||||||
type: "selection",
|
) {
|
||||||
}),
|
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
|
||||||
lastActiveToolBeforeEraser: null,
|
activeTool.customType =
|
||||||
});
|
appState.activeTool.lastActiveToolBeforeEraser.customType;
|
||||||
|
} else {
|
||||||
|
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool.type = "selection";
|
||||||
type: "selection",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -181,7 +176,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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,16 @@ 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);
|
||||||
|
|
||||||
@@ -41,7 +50,7 @@ export const actionGoToCollaborator = register({
|
|||||||
border={stroke}
|
border={stroke}
|
||||||
onClick={() => updateData(collaborator.pointer)}
|
onClick={() => updateData(collaborator.pointer)}
|
||||||
name={collaborator.username || ""}
|
name={collaborator.username || ""}
|
||||||
src={collaborator.avatarUrl}
|
src={collaborator.src}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+503
-412
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import "./Avatar.scss";
|
import "./Avatar.scss";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { getClientInitials } from "../clients";
|
import { getClientInitials } from "../clients";
|
||||||
|
|
||||||
type AvatarProps = {
|
type AvatarProps = {
|
||||||
@@ -13,21 +13,13 @@ type AvatarProps = {
|
|||||||
|
|
||||||
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
|
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
|
||||||
const shortName = getClientInitials(name);
|
const shortName = getClientInitials(name);
|
||||||
const [error, setError] = useState(false);
|
const style = src
|
||||||
const loadImg = !error && src;
|
|
||||||
const style = loadImg
|
|
||||||
? undefined
|
? undefined
|
||||||
: { background: color, border: `1px solid ${border}` };
|
: { background: color, border: `1px solid ${border}` };
|
||||||
return (
|
return (
|
||||||
<div className="Avatar" style={style} onClick={onClick}>
|
<div className="Avatar" style={style} onClick={onClick}>
|
||||||
{loadImg ? (
|
{src ? (
|
||||||
<img
|
<img className="Avatar-img" src={src} alt={shortName} />
|
||||||
className="Avatar-img"
|
|
||||||
src={src}
|
|
||||||
alt={shortName}
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
onError={() => setError(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
shortName
|
shortName
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-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"];
|
||||||
@@ -67,7 +68,9 @@ interface LayerUIProps {
|
|||||||
library: Library;
|
library: Library;
|
||||||
id: string;
|
id: string;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
|
renderCustomElementWidget?: (appState: AppState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
@@ -86,7 +89,6 @@ const LayerUI = ({
|
|||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
renderCustomStats,
|
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
UIOptions,
|
UIOptions,
|
||||||
@@ -94,8 +96,9 @@ const LayerUI = ({
|
|||||||
library,
|
library,
|
||||||
id,
|
id,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
|
renderCustomElementWidget,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const device = useDevice();
|
const deviceType = useDeviceType();
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
@@ -276,9 +279,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 +342,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 +364,7 @@ const LayerUI = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
@@ -375,11 +377,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>
|
||||||
@@ -427,12 +441,14 @@ const LayerUI = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("eraser", { size: "small" })}
|
{actionManager.renderAction("eraser", { size: "small" })}
|
||||||
|
{renderCustomElementWidget &&
|
||||||
|
renderCustomElementWidget(appState)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!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 +525,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 +546,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,7 +11,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 2px 0 15px 0;
|
margin: 2px 0;
|
||||||
|
|
||||||
.Spinner {
|
.Spinner {
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
}
|
}
|
||||||
@@ -19,17 +21,13 @@
|
|||||||
// 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 +65,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] =
|
||||||
@@ -238,6 +225,10 @@ export const LibraryMenu = ({
|
|||||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
|
LibraryItem["id"] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
libraryItemsData.status === "loading" &&
|
libraryItemsData.status === "loading" &&
|
||||||
!libraryItemsData.isInitialized
|
!libraryItemsData.isInitialized
|
||||||
@@ -284,17 +275,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,39 +11,34 @@ 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 Spinner from "./Spinner";
|
||||||
import { fileOpen } from "../data/filesystem";
|
|
||||||
|
|
||||||
import { SidebarLockButton } from "./SidebarLockButton";
|
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
const LibraryMenuItems = ({
|
||||||
isLoading,
|
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,
|
||||||
}: {
|
}: {
|
||||||
@@ -51,17 +46,16 @@ const LibraryMenuItems = ({
|
|||||||
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 +87,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 +100,20 @@ 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",
|
console.error(error);
|
||||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||||
// 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"
|
||||||
/>
|
/>
|
||||||
@@ -179,7 +161,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 +171,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 +180,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 +212,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 +233,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 +272,53 @@ 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 && (
|
{isLoading ? (
|
||||||
<>
|
<Spinner />
|
||||||
<div className="layer-ui__sidebar-lock-button">
|
) : (
|
||||||
<SidebarLockButton
|
<a
|
||||||
checked={appState.isLibraryMenuDocked}
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
onChange={() => {
|
window.name || "_blank"
|
||||||
document
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||||
.querySelector(".layer-ui__wrapper")
|
VERSIONS.excalidrawLibrary
|
||||||
?.classList.add("animate");
|
}`}
|
||||||
const nextState = !appState.isLibraryMenuDocked;
|
target="_excalidraw_libraries"
|
||||||
setAppState({
|
>
|
||||||
isLibraryMenuDocked: nextState,
|
{t("labels.libraries")}
|
||||||
});
|
</a>
|
||||||
trackEvent(
|
)}
|
||||||
"library",
|
</div>
|
||||||
`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,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-15
@@ -1,5 +1,5 @@
|
|||||||
import cssVariables from "./css/variables.module.scss";
|
import cssVariables from "./css/variables.module.scss";
|
||||||
import { AppProps } from "./types";
|
import { AppProps, CustomElementConfig } from "./types";
|
||||||
import { FontFamilyValues } from "./element/types";
|
import { FontFamilyValues } from "./element/types";
|
||||||
|
|
||||||
export const APP_NAME = "Excalidraw";
|
export const APP_NAME = "Excalidraw";
|
||||||
@@ -116,6 +116,7 @@ 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 +155,20 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// breakpoints
|
export const DEFAULT_CUSTOM_ELEMENT_CONFIG: Required<CustomElementConfig> = {
|
||||||
// -----------------------------------------------------------------------------
|
type: "custom",
|
||||||
// sm screen
|
customType: "custom",
|
||||||
export const MQ_SM_MAX_WIDTH = 640;
|
transformHandles: true,
|
||||||
// md screen
|
displayData: { content: "", type: "svg" },
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
stackedOnTop: false,
|
||||||
|
onCreate: () => {},
|
||||||
|
disableContextMenu: false,
|
||||||
|
};
|
||||||
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 +204,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);
|
||||||
|
};
|
||||||
|
|||||||
+32
-262
@@ -1,20 +1,10 @@
|
|||||||
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 { getCommonBoundingBox } from "../element/bounds";
|
|
||||||
import { AbortError } from "../errors";
|
|
||||||
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" | "loaded";
|
||||||
@@ -112,6 +102,36 @@ class Library {
|
|||||||
return this.setLibrary([]);
|
return this.setLibrary([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* imports library (from blob or libraryItems), merging with current library
|
||||||
|
* (attempting to remove duplicates)
|
||||||
|
*/
|
||||||
|
importLibrary(
|
||||||
|
library:
|
||||||
|
| Blob
|
||||||
|
| Required<ImportedDataState>["libraryItems"]
|
||||||
|
| Promise<Required<ImportedDataState>["libraryItems"]>,
|
||||||
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||||
|
): Promise<LibraryItems> {
|
||||||
|
return this.setLibrary(
|
||||||
|
() =>
|
||||||
|
new Promise<LibraryItems>(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
let libraryItems: LibraryItems;
|
||||||
|
if (library instanceof Blob) {
|
||||||
|
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
|
||||||
|
} else {
|
||||||
|
libraryItems = restoreLibraryItems(await library, defaultStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
|
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
|
||||||
*/
|
*/
|
||||||
@@ -131,65 +151,6 @@ class Library {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE this is a high-level public API (exposed on ExcalidrawAPI) with
|
|
||||||
// a slight overhead (always restoring library items). For internal use
|
|
||||||
// where merging isn't needed, use `library.setLibrary()` directly.
|
|
||||||
updateLibrary = async ({
|
|
||||||
libraryItems,
|
|
||||||
prompt = false,
|
|
||||||
merge = false,
|
|
||||||
openLibraryMenu = false,
|
|
||||||
defaultStatus = "unpublished",
|
|
||||||
}: {
|
|
||||||
libraryItems: LibraryItemsSource;
|
|
||||||
merge?: boolean;
|
|
||||||
prompt?: boolean;
|
|
||||||
openLibraryMenu?: boolean;
|
|
||||||
defaultStatus?: "unpublished" | "published";
|
|
||||||
}): Promise<LibraryItems> => {
|
|
||||||
if (openLibraryMenu) {
|
|
||||||
this.app.setState({ isLibraryOpen: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.setLibrary(() => {
|
|
||||||
return new Promise<LibraryItems>(async (resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const source = await (typeof libraryItems === "function"
|
|
||||||
? libraryItems(this.lastLibraryItems)
|
|
||||||
: libraryItems);
|
|
||||||
|
|
||||||
let nextItems;
|
|
||||||
|
|
||||||
if (source instanceof Blob) {
|
|
||||||
nextItems = await loadLibraryFromBlob(source, defaultStatus);
|
|
||||||
} else {
|
|
||||||
nextItems = restoreLibraryItems(source, defaultStatus);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!prompt ||
|
|
||||||
window.confirm(
|
|
||||||
t("alerts.confirmAddLibrary", {
|
|
||||||
numShapes: nextItems.length,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (merge) {
|
|
||||||
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
|
|
||||||
} else {
|
|
||||||
resolve(nextItems);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reject(new AbortError());
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).finally(() => {
|
|
||||||
this.app.focusContainer();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
setLibrary = (
|
setLibrary = (
|
||||||
/**
|
/**
|
||||||
* LibraryItems that will replace current items. Can be a function which
|
* LibraryItems that will replace current items. Can be a function which
|
||||||
@@ -243,194 +204,3 @@ class Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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]);
|
|
||||||
};
|
|
||||||
|
|||||||
+19
-19
@@ -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<
|
||||||
@@ -199,7 +199,10 @@ const restoreElement = (
|
|||||||
y,
|
y,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
case "custom":
|
||||||
|
return restoreElementWithProperties(element, {
|
||||||
|
customType: element.customType || "custom",
|
||||||
|
});
|
||||||
// generic elements
|
// generic elements
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return restoreElementWithProperties(element, {});
|
return restoreElementWithProperties(element, {});
|
||||||
@@ -257,7 +260,19 @@ export const restoreAppState = (
|
|||||||
? localValue
|
? localValue
|
||||||
: defaultValue;
|
: defaultValue;
|
||||||
}
|
}
|
||||||
|
const activeTool: any = {
|
||||||
|
lastActiveToolBeforeEraser: null,
|
||||||
|
locked: nextAppState.activeTool.locked ?? false,
|
||||||
|
type: "selection",
|
||||||
|
};
|
||||||
|
if (AllowedExcalidrawActiveTools[nextAppState.activeTool.type]) {
|
||||||
|
if (nextAppState.activeTool.type === "custom") {
|
||||||
|
activeTool.type = "custom";
|
||||||
|
activeTool.customType = nextAppState.activeTool.customType ?? "custom";
|
||||||
|
} else {
|
||||||
|
activeTool.type = nextAppState.activeTool.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
cursorButton: localAppState?.cursorButton || "up",
|
cursorButton: localAppState?.cursorButton || "up",
|
||||||
@@ -265,17 +280,7 @@ export const restoreAppState = (
|
|||||||
penDetected:
|
penDetected:
|
||||||
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,
|
|
||||||
locked: nextAppState.activeTool.locked ?? false,
|
|
||||||
},
|
|
||||||
// Migrates from previous version where appState.zoom was a number
|
// Migrates from previous version where appState.zoom was a number
|
||||||
zoom:
|
zoom:
|
||||||
typeof appState.zoom === "number"
|
typeof appState.zoom === "number"
|
||||||
@@ -283,11 +288,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;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawCustomElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||||
@@ -32,13 +33,20 @@ import { Point } from "../types";
|
|||||||
import { Drawable } from "roughjs/bin/core";
|
import { Drawable } from "roughjs/bin/core";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShapeForElement } from "../renderer/renderElement";
|
import { getShapeForElement } from "../renderer/renderElement";
|
||||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
import {
|
||||||
|
hasBoundTextElement,
|
||||||
|
isCustomElement,
|
||||||
|
isImageElement,
|
||||||
|
} from "./typeChecks";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
import { isTransparent } from "../utils";
|
import { isTransparent } from "../utils";
|
||||||
|
|
||||||
const isElementDraggableFromInside = (
|
const isElementDraggableFromInside = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
if (isCustomElement(element)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (element.type === "arrow") {
|
if (element.type === "arrow") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -166,6 +174,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
|||||||
case "text":
|
case "text":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
|
case "custom":
|
||||||
const distance = distanceToBindableElement(args.element, args.point);
|
const distance = distanceToBindableElement(args.element, args.point);
|
||||||
return args.check(distance, args.threshold);
|
return args.check(distance, args.threshold);
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
@@ -199,6 +208,7 @@ export const distanceToBindableElement = (
|
|||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
|
case "custom":
|
||||||
return distanceToRectangle(element, point);
|
return distanceToRectangle(element, point);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return distanceToDiamond(element, point);
|
return distanceToDiamond(element, point);
|
||||||
@@ -228,7 +238,8 @@ const distanceToRectangle = (
|
|||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawFreeDrawElement
|
| ExcalidrawFreeDrawElement
|
||||||
| ExcalidrawImageElement,
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawCustomElement,
|
||||||
point: Point,
|
point: Point,
|
||||||
): number => {
|
): number => {
|
||||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||||
@@ -504,6 +515,7 @@ export const determineFocusDistance = (
|
|||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
|
case "custom":
|
||||||
return c / (hwidth * (nabs + q * mabs));
|
return c / (hwidth * (nabs + q * mabs));
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||||
@@ -536,6 +548,7 @@ export const determineFocusPoint = (
|
|||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
|
case "custom":
|
||||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||||
break;
|
break;
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
@@ -586,6 +599,7 @@ const getSortedElementLineIntersections = (
|
|||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
|
case "custom":
|
||||||
const corners = getCorners(element);
|
const corners = getCorners(element);
|
||||||
intersections = corners
|
intersections = corners
|
||||||
.flatMap((point, i) => {
|
.flatMap((point, i) => {
|
||||||
@@ -619,7 +633,8 @@ const getCorners = (
|
|||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawImageElement
|
| ExcalidrawImageElement
|
||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawTextElement,
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawCustomElement,
|
||||||
scale: number = 1,
|
scale: number = 1,
|
||||||
): GA.Point[] => {
|
): GA.Point[] => {
|
||||||
const hx = (scale * element.width) / 2;
|
const hx = (scale * element.width) / 2;
|
||||||
@@ -628,6 +643,7 @@ const getCorners = (
|
|||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
|
case "custom":
|
||||||
return [
|
return [
|
||||||
GA.point(hx, hy),
|
GA.point(hx, hy),
|
||||||
GA.point(hx, -hy),
|
GA.point(hx, -hy),
|
||||||
@@ -770,7 +786,8 @@ export const findFocusPointForRectangulars = (
|
|||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawImageElement
|
| ExcalidrawImageElement
|
||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawTextElement,
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawCustomElement,
|
||||||
// Between -1 and 1 for how far away should the focus point be relative
|
// Between -1 and 1 for how far away should the focus point be relative
|
||||||
// to the size of the element. Sign determines orientation.
|
// to the size of the element. Sign determines orientation.
|
||||||
relativeDistance: number,
|
relativeDistance: number,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
ExcalidrawRectangleElement,
|
ExcalidrawRectangleElement,
|
||||||
|
ExcalidrawCustomElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
|
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||||
import { randomInteger, randomId } from "../random";
|
import { randomInteger, randomId } from "../random";
|
||||||
@@ -320,6 +321,17 @@ export const newImageElement = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const newCustomElement = (
|
||||||
|
customType: string,
|
||||||
|
opts: {
|
||||||
|
type: ExcalidrawCustomElement["type"];
|
||||||
|
} & ElementConstructorOpts,
|
||||||
|
): NonDeleted<ExcalidrawCustomElement> => {
|
||||||
|
return {
|
||||||
|
..._newElementBase<ExcalidrawCustomElement>("custom", opts),
|
||||||
|
customType,
|
||||||
|
};
|
||||||
|
};
|
||||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
|
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
|
||||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ExcalidrawTextContainer,
|
ExcalidrawTextContainer,
|
||||||
|
ExcalidrawCustomElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const isGenericElement = (
|
export const isGenericElement = (
|
||||||
@@ -142,3 +143,7 @@ export const isBoundToContainer = (
|
|||||||
element !== null && isTextElement(element) && element.containerId !== null
|
element !== null && isTextElement(element) && element.containerId !== null
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isCustomElement = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
): element is ExcalidrawCustomElement => element && element.type === "custom";
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
|||||||
scale: [number, number];
|
scale: [number, number];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type ExcalidrawCustomElement = _ExcalidrawElementBase &
|
||||||
|
Readonly<{ type: "custom"; customType: string }>;
|
||||||
|
|
||||||
export type InitializedExcalidrawImageElement = MarkNonNullable<
|
export type InitializedExcalidrawImageElement = MarkNonNullable<
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
"fileId"
|
"fileId"
|
||||||
@@ -108,7 +111,8 @@ export type ExcalidrawElement =
|
|||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawLinearElement
|
| ExcalidrawLinearElement
|
||||||
| ExcalidrawFreeDrawElement
|
| ExcalidrawFreeDrawElement
|
||||||
| ExcalidrawImageElement;
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawCustomElement;
|
||||||
|
|
||||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
@@ -134,7 +138,8 @@ export type ExcalidrawBindableElement =
|
|||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawEllipseElement
|
| ExcalidrawEllipseElement
|
||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawImageElement;
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawCustomElement;
|
||||||
|
|
||||||
export type ExcalidrawTextContainer =
|
export type ExcalidrawTextContainer =
|
||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
|
|||||||
@@ -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
-2
@@ -35,8 +35,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",
|
||||||
|
|||||||
@@ -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": "Výběr",
|
"selection": "Výběr",
|
||||||
@@ -342,8 +341,7 @@
|
|||||||
"post": ""
|
"post": ""
|
||||||
},
|
},
|
||||||
"noteItems": "",
|
"noteItems": "",
|
||||||
"atleastOneLibItem": "",
|
"atleastOneLibItem": ""
|
||||||
"republishWarning": ""
|
|
||||||
},
|
},
|
||||||
"publishSuccessDialog": {
|
"publishSuccessDialog": {
|
||||||
"title": "",
|
"title": "",
|
||||||
|
|||||||
+72
-74
@@ -1,74 +1,74 @@
|
|||||||
{
|
{
|
||||||
"labels": {
|
"labels": {
|
||||||
"paste": "Indsæt",
|
"paste": "Indsæt",
|
||||||
"pasteCharts": "Indsæt diagrammer",
|
"pasteCharts": "",
|
||||||
"selectAll": "Marker alle",
|
"selectAll": "Marker alle",
|
||||||
"multiSelect": "Tilføj element til markering",
|
"multiSelect": "",
|
||||||
"moveCanvas": "Flyt lærred",
|
"moveCanvas": "",
|
||||||
"cut": "Klip",
|
"cut": "Klip",
|
||||||
"copy": "Kopier",
|
"copy": "Kopier",
|
||||||
"copyAsPng": "Kopier til klippebord som PNG",
|
"copyAsPng": "Kopier til klippebord som PNG",
|
||||||
"copyAsSvg": "Kopier til klippebord som SVG",
|
"copyAsSvg": "Kopier til klippebord som SVG",
|
||||||
"copyText": "Kopiér til udklipsholder som tekst",
|
"copyText": "",
|
||||||
"bringForward": "Flyt fremad",
|
"bringForward": "",
|
||||||
"sendToBack": "Placer bagest",
|
"sendToBack": "",
|
||||||
"bringToFront": "Placer forrest",
|
"bringToFront": "",
|
||||||
"sendBackward": "Send bagud",
|
"sendBackward": "",
|
||||||
"delete": "Fjern",
|
"delete": "Fjern",
|
||||||
"copyStyles": "Kopier stil",
|
"copyStyles": "",
|
||||||
"pasteStyles": "Indsæt stil",
|
"pasteStyles": "",
|
||||||
"stroke": "Linje",
|
"stroke": "Linje",
|
||||||
"background": "Baggrund",
|
"background": "Baggrund",
|
||||||
"fill": "Udfyld",
|
"fill": "",
|
||||||
"strokeWidth": "Linjebredde",
|
"strokeWidth": "Linjebredde",
|
||||||
"strokeStyle": "Linjeform",
|
"strokeStyle": "",
|
||||||
"strokeStyle_solid": "Solid",
|
"strokeStyle_solid": "",
|
||||||
"strokeStyle_dashed": "Stiplet",
|
"strokeStyle_dashed": "",
|
||||||
"strokeStyle_dotted": "Prikket",
|
"strokeStyle_dotted": "",
|
||||||
"sloppiness": "Sjuskethed",
|
"sloppiness": "",
|
||||||
"opacity": "Gennemsigtighed",
|
"opacity": "",
|
||||||
"textAlign": "Tekstjustering",
|
"textAlign": "",
|
||||||
"edges": "Kanter",
|
"edges": "",
|
||||||
"sharp": "Skarp",
|
"sharp": "",
|
||||||
"round": "Rund",
|
"round": "",
|
||||||
"arrowheads": "Pilehoveder",
|
"arrowheads": "",
|
||||||
"arrowhead_none": "Ingen",
|
"arrowhead_none": "",
|
||||||
"arrowhead_arrow": "Pil",
|
"arrowhead_arrow": "Pil",
|
||||||
"arrowhead_bar": "Bjælke",
|
"arrowhead_bar": "",
|
||||||
"arrowhead_dot": "Prik",
|
"arrowhead_dot": "",
|
||||||
"arrowhead_triangle": "Trekant",
|
"arrowhead_triangle": "",
|
||||||
"fontSize": "Skriftstørrelse",
|
"fontSize": "",
|
||||||
"fontFamily": "Skrifttypefamilie",
|
"fontFamily": "",
|
||||||
"onlySelected": "Kun valgte",
|
"onlySelected": "",
|
||||||
"withBackground": "Baggrund",
|
"withBackground": "",
|
||||||
"exportEmbedScene": "Indlejr scene",
|
"exportEmbedScene": "",
|
||||||
"exportEmbedScene_details": "Scene data vil blive gemt i den eksporterede PNG/SVG-fil, så scenen kan gendannes fra den.\nDette vil øge den eksporterede filstørrelse.",
|
"exportEmbedScene_details": "",
|
||||||
"addWatermark": "Tilføj \"Lavet med Excalidraw\"",
|
"addWatermark": "",
|
||||||
"handDrawn": "Hånd-tegnet",
|
"handDrawn": "",
|
||||||
"normal": "Normal",
|
"normal": "",
|
||||||
"code": "Kode",
|
"code": "",
|
||||||
"small": "Lille",
|
"small": "",
|
||||||
"medium": "Mellem",
|
"medium": "",
|
||||||
"large": "Stor",
|
"large": "",
|
||||||
"veryLarge": "Meget stor",
|
"veryLarge": "",
|
||||||
"solid": "Solid",
|
"solid": "",
|
||||||
"hachure": "Skravering",
|
"hachure": "",
|
||||||
"crossHatch": "Krydsskravering",
|
"crossHatch": "",
|
||||||
"thin": "Tynd",
|
"thin": "",
|
||||||
"bold": "Fed",
|
"bold": "Fed",
|
||||||
"left": "Venstre",
|
"left": "Venstre",
|
||||||
"center": "Centrere",
|
"center": "Centrere",
|
||||||
"right": "Højre",
|
"right": "Højre",
|
||||||
"extraBold": "Extra fed",
|
"extraBold": "Extra fed",
|
||||||
"architect": "Arkitekt",
|
"architect": "",
|
||||||
"artist": "Kunstner",
|
"artist": "",
|
||||||
"cartoonist": "Tegneserietegner",
|
"cartoonist": "",
|
||||||
"fileTitle": "Filnavn",
|
"fileTitle": "Filnavn",
|
||||||
"colorPicker": "Farvevælger",
|
"colorPicker": "Farvevælger",
|
||||||
"canvasColors": "Brugt på lærred",
|
"canvasColors": "Brugt på lærred",
|
||||||
"canvasBackground": "Lærredsbaggrund",
|
"canvasBackground": "",
|
||||||
"drawingCanvas": "Tegnelærred",
|
"drawingCanvas": "",
|
||||||
"layers": "Lag",
|
"layers": "",
|
||||||
"actions": "",
|
"actions": "",
|
||||||
"language": "Sprog",
|
"language": "Sprog",
|
||||||
"liveCollaboration": "Direkte samarbejde",
|
"liveCollaboration": "Direkte samarbejde",
|
||||||
@@ -119,8 +119,7 @@
|
|||||||
"unlock": "",
|
"unlock": "",
|
||||||
"lockAll": "",
|
"lockAll": "",
|
||||||
"unlockAll": ""
|
"unlockAll": ""
|
||||||
},
|
}
|
||||||
"statusPublished": ""
|
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "",
|
"clearReset": "",
|
||||||
@@ -133,10 +132,10 @@
|
|||||||
"copyPngToClipboard": "Kopier PNG til klippebord",
|
"copyPngToClipboard": "Kopier PNG til klippebord",
|
||||||
"scale": "",
|
"scale": "",
|
||||||
"save": "",
|
"save": "",
|
||||||
"saveAs": "Gem som",
|
"saveAs": "",
|
||||||
"load": "Indlæs",
|
"load": "",
|
||||||
"getShareableLink": "Lav et delbart link",
|
"getShareableLink": "",
|
||||||
"close": "Luk",
|
"close": "",
|
||||||
"selectLanguage": "Vælg sprog",
|
"selectLanguage": "Vælg sprog",
|
||||||
"scrollBackToContent": "Scroll tilbage til indhold",
|
"scrollBackToContent": "Scroll tilbage til indhold",
|
||||||
"zoomIn": "Zoom ind",
|
"zoomIn": "Zoom ind",
|
||||||
@@ -147,32 +146,33 @@
|
|||||||
"edit": "Rediger",
|
"edit": "Rediger",
|
||||||
"undo": "Fortryd",
|
"undo": "Fortryd",
|
||||||
"redo": "Gendan",
|
"redo": "Gendan",
|
||||||
"resetLibrary": "Nulstil bibliotek",
|
"resetLibrary": "",
|
||||||
"createNewRoom": "Opret nyt rum",
|
"createNewRoom": "Opret nyt rum",
|
||||||
"fullScreen": "Fuld skærm",
|
"fullScreen": "Fuld skærm",
|
||||||
"darkMode": "Mørk tilstand",
|
"darkMode": "Mørk tilstand",
|
||||||
"lightMode": "Lys baggrund",
|
"lightMode": "Lys baggrund",
|
||||||
"zenMode": "Zentilstand",
|
"zenMode": "",
|
||||||
"exitZenMode": "Stop zentilstand",
|
"exitZenMode": "",
|
||||||
"cancel": "Annuller",
|
"cancel": "",
|
||||||
"clear": "Ryd",
|
"clear": "",
|
||||||
"remove": "Fjern",
|
"remove": "",
|
||||||
"publishLibrary": "Publicér",
|
"publishLibrary": "",
|
||||||
"submit": "Gem",
|
"submit": "",
|
||||||
"confirm": "Bekræft"
|
"confirm": ""
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"clearReset": "Dette vil rydde hele lærredet. Er du sikker?",
|
"clearReset": "",
|
||||||
"couldNotCreateShareableLink": "Kunne ikke oprette delbart link.",
|
"couldNotCreateShareableLink": "",
|
||||||
"couldNotCreateShareableLinkTooBig": "Kunne ikke oprette delbart link: scenen er for stor",
|
"couldNotCreateShareableLinkTooBig": "",
|
||||||
"couldNotLoadInvalidFile": "Kunne ikke indlæse ugyldig fil",
|
"couldNotLoadInvalidFile": "",
|
||||||
"importBackendFailed": "Import fra backend mislykkedes.",
|
"importBackendFailed": "",
|
||||||
"cannotExportEmptyCanvas": "",
|
"cannotExportEmptyCanvas": "",
|
||||||
"couldNotCopyToClipboard": "",
|
"couldNotCopyToClipboard": "",
|
||||||
"decryptFailed": "",
|
"decryptFailed": "",
|
||||||
"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": "",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user