Compare commits

..

28 Commits

Author SHA1 Message Date
ad1992 c4b951a0c5 convert customElementsConfig into an object 2022-05-04 14:26:54 +05:30
ad1992 c93d8f4bd0 don't use addCallback for triggering onCreate 2022-05-04 13:30:42 +05:30
ad1992 645f9a5dc0 Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-05-04 13:20:08 +05:30
ad1992 128b7741c1 update config to use displayData 2022-04-27 14:03:42 +05:30
ad1992 1edde7291c fix specs 2022-04-26 12:02:50 +05:30
ad1992 1ca56204b1 don't draw if image not present 2022-04-25 20:25:14 +05:30
ad1992 f3ae7a8506 don't show move cursor if transform handles disabled 2022-04-25 14:45:17 +05:30
ad1992 5f57daa132 fix: selection not working sometimes when transformHandles disabled 2022-04-25 14:23:28 +05:30
ad1992 db9c9eb3d2 suppport disabling context menu in custom elements 2022-04-22 00:56:17 +05:30
ad1992 2e8c4d25f2 fix package example 2022-04-21 23:55:38 +05:30
ad1992 4953828d86 Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-04-21 19:50:46 +05:30
ad1992 6eb0cf6a10 unbind onCreate once executed 2022-04-20 11:48:22 +05:30
ad1992 ba48aa24a0 Add onCreate in customElementConfig 2022-04-19 21:58:38 +05:30
ad1992 4e75f10b2c cache svg with element id 2022-04-18 21:30:38 +05:30
ad1992 d2d3599661 Support svg as a async function returing promise/string 2022-04-18 16:08:40 +05:30
ad1992 ed3eda3401 restore custom elements with correct type 2022-03-30 14:20:29 +05:30
ad1992 d27b32dd2c Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-03-29 17:26:46 +05:30
ad1992 2337842f57 fix typescript 2022-03-29 16:07:45 +05:30
ad1992 5b78f50fe3 Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-03-29 15:34:48 +05:30
ad1992 a4a95a591a Add stackedOnTop to make sure the custom element is always rendered on top of all when stackedOnTop is true 2022-03-28 15:03:29 +05:30
ad1992 3d459076fb Merge remote-tracking branch 'origin/master' into aakansha-custom-elements
Update customType
2022-03-25 22:32:28 +05:30
ad1992 14a23c6c50 make onElementClick optional 2022-03-24 17:28:40 +05:30
ad1992 5f4a5b1789 Add onElementClick and export sceneCoordsToViewportCoords 2022-03-24 17:24:54 +05:30
ad1992 47498796e0 fix hit testing for custom elements 2022-03-24 15:06:22 +05:30
ad1992 8706277d14 rename name to customType 2022-03-24 14:04:31 +05:30
ad1992 3d0a1106ff support making transform handles optional 2022-03-23 23:24:25 +05:30
ad1992 61699ff3c2 cache the custom image element and improve jittering experience 2022-03-23 19:42:39 +05:30
ad1992 39d0084a5e feat: Support custom elements in @excalidraw/excalidraw 2022-03-23 19:04:00 +05:30
187 changed files with 5859 additions and 6833 deletions
-9
View File
@@ -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=
-2
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- lipis
assignees:
- lipis
open-pull-requests-limit: 20
- package-ecosystem: npm
directory: /src/packages/excalidraw/
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- ad1992
assignees:
- ad1992
open-pull-requests-limit: 20
- package-ecosystem: npm
directory: /src/packages/utils/
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- ad1992
assignees:
- ad1992
open-pull-requests-limit: 20
+1 -1
View File
@@ -1,4 +1,4 @@
name: Auto release excalidraw next name: Auto release @excalidraw/excalidraw-next
on: on:
push: push:
branches: branches:
+2 -2
View File
@@ -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
View File
@@ -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"
} }
} }
-35
View File
@@ -52,25 +52,6 @@
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." 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
View File
@@ -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();
}); });
-20
View File
@@ -1,20 +0,0 @@
const fs = require("fs");
// for development purposes we want to have the service-worker.js file
// accessible from the public folder. On build though, we need to compile it
// and CRA expects that file to be in src/ folder.
const moveServiceWorkerScript = () => {
const oldPath = "./public/service-worker.js";
const newPath = "./src/service-worker.js";
fs.rename(oldPath, newPath, (error) => {
if (error) {
throw error;
}
console.info("public/service-worker.js moved to src/");
});
};
// -----------------------------------------------------------------------------
moveServiceWorkerScript();
-37
View File
@@ -1,37 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage);
pkg.version = nextVersion;
const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
};
const prerelease = async (nextVersion) => {
try {
await updateChangelog(nextVersion);
updatePackageVersion(nextVersion);
await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
console.info("Done!");
} catch (error) {
console.error(error);
process.exit(1);
}
};
const nextVersion = process.argv.slice(2)[0];
if (!nextVersion) {
console.error("Pass the next version to release!");
process.exit(1);
}
prerelease(nextVersion);
+25 -30
View File
@@ -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();
+27
View File
@@ -0,0 +1,27 @@
const fs = require("fs");
const updateReadme = () => {
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
// remove note for unstable release
data = data.replace(
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
"",
);
// replace "excalidraw-next" with "excalidraw"
data = data.replace(/excalidraw-next/g, "excalidraw");
data = data.trim();
const demoIndex = data.indexOf("### Demo");
const excalidrawNextNote =
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
// Add excalidraw next note to try out for unreleased changes
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
};
module.exports = updateReadme;
+29 -15
View File
@@ -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,
+1 -2
View File
@@ -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(
+1 -1
View File
@@ -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";
+3 -3
View File
@@ -7,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave"; import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
import { 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"
/> />
+18 -23
View File
@@ -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",
}; };
+11 -2
View File
@@ -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}
/> />
); );
}, },
+4 -8
View File
@@ -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,
+1 -1
View File
@@ -48,7 +48,7 @@ describe("actionStyles", () => {
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => { Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.C); Keyboard.codeDown(CODES.C);
}); });
const secondRect = JSON.parse(copiedStyles)[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
View File
@@ -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;
+1 -1
View File
@@ -30,7 +30,7 @@ const trackAction = (
trackEvent( trackEvent(
action.trackEvent.category, action.trackEvent.category,
action.trackEvent.action || action.name, action.trackEvent.action || action.name,
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`, `${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
); );
} }
} }
+2 -1
View File
@@ -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
View File
@@ -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 },
}); });
-121
View File
@@ -1,121 +0,0 @@
import {
Spreadsheet,
tryParseCells,
tryParseNumber,
VALID_SPREADSHEET,
} from "./charts";
describe("charts", () => {
describe("tryParseNumber", () => {
it.each<[string, number]>([
["1", 1],
["0", 0],
["-1", -1],
["0.1", 0.1],
[".1", 0.1],
["1.", 1],
["424.", 424],
["$1", 1],
["-.1", -0.1],
["-$1", -1],
["$-1", -1],
])("should correctly identify %s as numbers", (given, expected) => {
expect(tryParseNumber(given)).toEqual(expected);
});
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
"should correctly identify %s as not a number",
(given) => {
expect(tryParseNumber(given)).toBeNull();
},
);
});
describe("tryParseCells", () => {
it("Successfully parses a spreadsheet", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("Uses the second column as the label if it is not a number", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("treats the first column as labels if both columns are numbers", () => {
const spreadsheet = [
["time", "value"],
["01", "61"],
["02", "-60"],
["03", "85"],
["04", "-67"],
["05", "54"],
["06", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
});
});
+7 -16
View File
@@ -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;
+6 -13
View File
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types"; import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { 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
View File
File diff suppressed because it is too large Load Diff
+4 -12
View File
@@ -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
)} )}
+2 -2
View File
@@ -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"
/> />
+2 -4
View File
@@ -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);
} }
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { 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>
+33 -30
View File
@@ -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)}
+10 -3
View File
@@ -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>
+1 -1
View File
@@ -45,7 +45,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text"); return t("hints.text");
} }
if (appState.activeTool.type === "image" && appState.pendingImageElementId) { if (appState.activeTool.type === "image" && appState.pendingImageElement) {
return t("hints.placeImage"); return t("hints.placeImage");
} }
+2 -2
View File
@@ -5,7 +5,7 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { 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 && (
+1 -1
View File
@@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
useEffect(() => { useEffect(() => {
const updateLang = async () => { const updateLang = async () => {
await setLanguage(currentLang); await setLanguage(currentLang);
setLoading(false);
}; };
const currentLang = const currentLang =
languages.find((lang) => lang.code === props.langCode) || defaultLang; languages.find((lang) => lang.code === props.langCode) || defaultLang;
updateLang(); updateLang();
setLoading(false);
}, [props.langCode]); }, [props.langCode]);
return loading ? <LoadingMessage /> : props.children; return loading ? <LoadingMessage /> : props.children;
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { 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 -55
View File
@@ -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
View File
@@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { CLASSES, 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>
); );
}; };
+1 -16
View File
@@ -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"))}
+9 -45
View File
@@ -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;
}
}
} }
+58 -28
View File
@@ -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}
/> />
+3 -16
View File
@@ -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;
+61 -294
View File
@@ -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>
); );
}; };
+5 -1
View File
@@ -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;
+7 -9
View File
@@ -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}
+15 -12
View File
@@ -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>
+6 -6
View File
@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useExcalidrawContainer, 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 =
-37
View File
@@ -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) {
-4
View File
@@ -82,10 +82,6 @@
} }
} }
&-warning {
color: $oc-red-6;
}
&-note { &-note {
padding: 1em; padding: 1em;
font-style: italic; font-style: italic;
-10
View File
@@ -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>
-22
View File
@@ -1,22 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.layer-ui__sidebar-lock-button {
@include toolbarButtonColorStates;
margin-right: 0.2rem;
}
.ToolIcon_type_floating .side_lock_icon {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
svg {
// mirror
transform: scale(-1, 1);
}
}
.ToolIcon_type_checkbox {
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
background-color: var(--color-primary);
}
}
}
-46
View File
@@ -1,46 +0,0 @@
import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
import { t } from "../i18n";
import { Tooltip } from "./Tooltip";
import "./SidebarLockButton.scss";
type SidebarLockIconProps = {
checked: boolean;
onChange?(): void;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
const SIDE_LIBRARY_TOGGLE_ICON = (
<svg viewBox="0 0 24 24" fill="#ffffff">
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
</svg>
);
export const SidebarLockButton = (props: SidebarLockIconProps) => {
return (
<Tooltip label={t("labels.sidebarLock")}>
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
)}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={props.onChange}
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
{SIDE_LIBRARY_TOGGLE_ICON}
</div>{" "}
</label>{" "}
</Tooltip>
);
};
+2 -15
View File
@@ -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;
-5
View File
@@ -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")}
-2
View File
@@ -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}
-1
View File
@@ -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;
+6 -3
View File
@@ -2,7 +2,7 @@ import React from "react";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { 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}>
+4 -20
View File
@@ -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
View File
@@ -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>
); );
}; };
+22 -1
View File
@@ -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 {
+5 -39
View File
@@ -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
View File
@@ -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;
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
export const createInverseContext = <T extends unknown = null>(
initialValue: T,
) => {
const Context = React.createContext(initialValue) as React.Context<T> & {
_updateProviderValue?: (value: T) => void;
};
class InverseConsumer extends React.Component {
state = { value: initialValue };
constructor(props: any) {
super(props);
Context._updateProviderValue = (value: T) => this.setState({ value });
}
render() {
return (
<Context.Provider value={this.state.value}>
{this.props.children}
</Context.Provider>
);
}
}
class InverseProvider extends React.Component<{ value: T }> {
componentDidMount() {
Context._updateProviderValue?.(this.props.value);
}
componentDidUpdate() {
Context._updateProviderValue?.(this.props.value);
}
render() {
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
}
}
return {
Context,
Consumer: InverseConsumer,
Provider: InverseProvider,
};
};
+1 -18
View File
@@ -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 {
-24
View File
@@ -6,32 +6,8 @@
} }
} }
@mixin toolbarButtonColorStates {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
.ToolIcon__keybinding {
bottom: 4px;
right: 4px;
}
}
$theme-filter: "invert(93%) hue-rotate(180deg)"; $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
View File
@@ -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 -2
View File
@@ -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
+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+21 -4
View File
@@ -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
View File
@@ -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.)
// //
+3 -4
View File
@@ -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,
); );
-3
View File
@@ -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];
+5
View File
@@ -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";
+7 -2
View File
@@ -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
-1
View File
@@ -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)
@@ -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;
+5 -9
View File
@@ -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,
+2 -10
View File
@@ -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>
</> </>
+15 -29
View File
@@ -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);
+2 -35
View File
@@ -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;
-22
View File
@@ -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);
}
}
+64 -96
View File
@@ -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>
); );
}; };
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
};
+4 -6
View File
@@ -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": "تم إرسال المكتبة",
+4 -6
View File
@@ -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": "",
+4 -6
View File
@@ -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": "",
+4 -6
View File
@@ -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",
+4 -6
View File
@@ -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
View File
@@ -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