mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-10 07:45:20 +01:00
Compare commits
28 Commits
v0.12.0
...
aakansha-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4b951a0c5 | ||
|
|
c93d8f4bd0 | ||
|
|
645f9a5dc0 | ||
|
|
128b7741c1 | ||
|
|
1edde7291c | ||
|
|
1ca56204b1 | ||
|
|
f3ae7a8506 | ||
|
|
5f57daa132 | ||
|
|
db9c9eb3d2 | ||
|
|
2e8c4d25f2 | ||
|
|
4953828d86 | ||
|
|
6eb0cf6a10 | ||
|
|
ba48aa24a0 | ||
|
|
4e75f10b2c | ||
|
|
d2d3599661 | ||
|
|
ed3eda3401 | ||
|
|
d27b32dd2c | ||
|
|
2337842f57 | ||
|
|
5b78f50fe3 | ||
|
|
a4a95a591a | ||
|
|
3d459076fb | ||
|
|
14a23c6c50 | ||
|
|
5f4a5b1789 | ||
|
|
47498796e0 | ||
|
|
8706277d14 | ||
|
|
3d0a1106ff | ||
|
|
61699ff3c2 | ||
|
|
39d0084a5e |
@@ -11,12 +11,3 @@ REACT_APP_WS_SERVER_URL=http://localhost:3002
|
||||
REACT_APP_PORTAL_URL=
|
||||
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||
|
||||
# put these in your .env.local, or make sure you don't commit!
|
||||
# must be lowercase `true` when turned on
|
||||
#
|
||||
# whether to enable Service Workers in development
|
||||
REACT_APP_DEV_ENABLE_SW=
|
||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
|
||||
@@ -13,5 +13,3 @@ REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","
|
||||
|
||||
# production-only vars
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||
|
||||
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
||||
|
||||
37
.github/dependabot.yml
vendored
Normal file
37
.github/dependabot.yml
vendored
Normal 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
|
||||
2
.github/workflows/autorelease-excalidraw.yml
vendored
2
.github/workflows/autorelease-excalidraw.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Auto release excalidraw next
|
||||
name: Auto release @excalidraw/excalidraw-next
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
||||
4
.github/workflows/autorelease-preview.yml
vendored
4
.github/workflows/autorelease-preview.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Auto release excalidraw preview
|
||||
name: Auto release preview @excalidraw/excalidraw-preview
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
Auto-release-excalidraw-preview:
|
||||
name: Auto release preview
|
||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
||||
if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: React to release comment
|
||||
|
||||
17
package.json
17
package.json
@@ -22,7 +22,7 @@
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react": "12.1.2",
|
||||
"@tldraw/vec": "1.4.3",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/pica": "5.1.3",
|
||||
@@ -51,7 +51,7 @@
|
||||
"react-dom": "17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"sass": "1.49.7",
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.5.5"
|
||||
},
|
||||
@@ -64,13 +64,13 @@
|
||||
"@types/resize-observer-browser": "0.1.6",
|
||||
"chai": "4.3.6",
|
||||
"dotenv": "10.0.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "2.4.0",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"lint-staged": "12.3.7",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"prettier": "2.5.1",
|
||||
"rewire": "5.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
@@ -94,8 +94,7 @@
|
||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build:prebuild": "node ./scripts/prebuild.js",
|
||||
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"eject": "react-scripts eject",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
@@ -113,8 +112,6 @@
|
||||
"test:typecheck": "tsc",
|
||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||
"test": "yarn test:app",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease": "node scripts/prerelease.js",
|
||||
"release": "node scripts/release.js"
|
||||
"autorelease": "node scripts/autorelease.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,25 +52,6 @@
|
||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
// Redirect only the bare root path, so link/room/library urls are not
|
||||
// redirected.
|
||||
//
|
||||
// Putting into index.html for best performance (can't redirect on server
|
||||
// due to location.hash checks).
|
||||
if (
|
||||
window.location.pathname === "/" &&
|
||||
!window.location.hash &&
|
||||
!window.location.search &&
|
||||
// if its present redirect
|
||||
document.cookie.includes("excplus-autoredirect=true")
|
||||
) {
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
@@ -98,22 +79,6 @@
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
|
||||
<script>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
window.WebSocket = function (url) {
|
||||
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
|
||||
console.info(
|
||||
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||
);
|
||||
} else {
|
||||
return new _WebSocket(url);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
|
||||
@@ -5,25 +5,22 @@ const core = require("@actions/core");
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
const isPreview = process.argv.slice(2)[0] === "preview";
|
||||
|
||||
const getShortCommitHash = () => {
|
||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
const tag = isPreview ? "preview" : "next";
|
||||
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
|
||||
console.info(`Published ${pkg.name}@${tag}🎉`);
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
console.info("Published 🎉");
|
||||
core.setOutput(
|
||||
"result",
|
||||
`**Preview version has been shipped** :rocket:
|
||||
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
|
||||
You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
|
||||
);
|
||||
} catch (error) {
|
||||
core.setOutput("result", "package couldn't be published :warning:!");
|
||||
@@ -54,19 +51,27 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
}
|
||||
|
||||
// update package.json
|
||||
pkg.name = "@excalidraw/excalidraw-next";
|
||||
let version = `${pkg.version}-${getShortCommitHash()}`;
|
||||
|
||||
// update readme
|
||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
|
||||
const isPreview = process.argv.slice(2)[0] === "preview";
|
||||
if (isPreview) {
|
||||
// use pullNumber-commithash as the version for preview
|
||||
const pullRequestNumber = process.argv.slice(3)[0];
|
||||
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
||||
// replace "excalidraw-next" with "excalidraw-preview"
|
||||
pkg.name = "@excalidraw/excalidraw-preview";
|
||||
data = data.replace(/excalidraw-next/g, "excalidraw-preview");
|
||||
data = data.trim();
|
||||
}
|
||||
pkg.version = version;
|
||||
|
||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
console.info("Publish in progress...");
|
||||
publish();
|
||||
});
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// for development purposes we want to have the service-worker.js file
|
||||
// accessible from the public folder. On build though, we need to compile it
|
||||
// and CRA expects that file to be in src/ folder.
|
||||
const moveServiceWorkerScript = () => {
|
||||
const oldPath = "./public/service-worker.js";
|
||||
const newPath = "./src/service-worker.js";
|
||||
|
||||
fs.rename(oldPath, newPath, (error) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
console.info("public/service-worker.js moved to src/");
|
||||
});
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
moveServiceWorkerScript();
|
||||
@@ -1,37 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
const updateChangelog = require("./updateChangelog");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
|
||||
const updatePackageVersion = (nextVersion) => {
|
||||
const pkg = require(excalidrawPackage);
|
||||
pkg.version = nextVersion;
|
||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||
};
|
||||
|
||||
const prerelease = async (nextVersion) => {
|
||||
try {
|
||||
await updateChangelog(nextVersion);
|
||||
updatePackageVersion(nextVersion);
|
||||
await exec(`git add -u`);
|
||||
await exec(
|
||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||
);
|
||||
|
||||
console.info("Done!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextVersion = process.argv.slice(2)[0];
|
||||
if (!nextVersion) {
|
||||
console.error("Pass the next version to release!");
|
||||
process.exit(1);
|
||||
}
|
||||
prerelease(nextVersion);
|
||||
@@ -1,44 +1,39 @@
|
||||
const fs = require("fs");
|
||||
const { execSync } = require("child_process");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
const updateReadme = require("./updateReadme");
|
||||
const updateChangelog = require("./updateChangelog");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
|
||||
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
|
||||
|
||||
const updateReadme = () => {
|
||||
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
|
||||
|
||||
// remove note for stable readme
|
||||
const data = originalReadMe.slice(excalidrawIndex);
|
||||
|
||||
// update readme
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
const updatePackageVersion = (nextVersion) => {
|
||||
const pkg = require(excalidrawPackage);
|
||||
pkg.version = nextVersion;
|
||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
const release = async (nextVersion) => {
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
updateReadme();
|
||||
await updateChangelog(nextVersion);
|
||||
updatePackageVersion(nextVersion);
|
||||
await exec(`git add -u`);
|
||||
await exec(
|
||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||
);
|
||||
/* eslint-disable no-console */
|
||||
console.log("Done!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
updateReadme();
|
||||
console.info("Note for stable readme removed");
|
||||
|
||||
publish();
|
||||
console.info(`Published ${pkg.version}!`);
|
||||
|
||||
// revert readme after release
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
|
||||
console.info("Readme reverted");
|
||||
};
|
||||
|
||||
release();
|
||||
const nextVersion = process.argv.slice(2)[0];
|
||||
if (!nextVersion) {
|
||||
console.error("Pass the next version to release!");
|
||||
process.exit(1);
|
||||
}
|
||||
release(nextVersion);
|
||||
|
||||
27
scripts/updateReadme.js
Normal file
27
scripts/updateReadme.js
Normal 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;
|
||||
@@ -11,7 +11,7 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
@@ -304,22 +304,36 @@ export const actionErase = register({
|
||||
name: "eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
const activeTool: any = { ...appState.activeTool };
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "eraser",
|
||||
lastActiveToolBeforeEraser: appState.activeTool,
|
||||
});
|
||||
if (appState.activeTool.type !== "eraser") {
|
||||
if (appState.activeTool.type === "custom") {
|
||||
activeTool.lastActiveToolBeforeEraser = {
|
||||
type: "custom",
|
||||
customType: appState.activeTool.customType,
|
||||
};
|
||||
} else {
|
||||
activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
appState: {
|
||||
...appState,
|
||||
|
||||
@@ -12,7 +12,6 @@ import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -135,7 +134,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
activeTool: { ...appState.activeTool, type: "selection" },
|
||||
multiElement: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import { distributeElements, Distribution } from "../disitrubte";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
@@ -204,7 +204,7 @@ export const actionSaveFileToDisk = register({
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
@@ -248,7 +248,7 @@ export const actionLoadScene = register({
|
||||
icon={load}
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
onClick={updateData}
|
||||
data-testid="load-button"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { updateActiveTool, resetCursor } from "../utils";
|
||||
import { resetCursor } from "../utils";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
@@ -14,12 +14,11 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
|
||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
@@ -50,12 +49,8 @@ export const actionFinalize = register({
|
||||
|
||||
let newElements = elements;
|
||||
|
||||
const pendingImageElement =
|
||||
appState.pendingImageElementId &&
|
||||
scene.getElement(appState.pendingImageElementId);
|
||||
|
||||
if (pendingImageElement) {
|
||||
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
||||
if (appState.pendingImageElement) {
|
||||
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
@@ -141,21 +136,21 @@ export const actionFinalize = register({
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
}
|
||||
|
||||
let activeTool: AppState["activeTool"];
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
const activeTool: any = { ...appState.activeTool };
|
||||
if (appState.activeTool.lastActiveToolBeforeEraser) {
|
||||
if (
|
||||
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
|
||||
appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
|
||||
) {
|
||||
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
|
||||
activeTool.customType =
|
||||
appState.activeTool.lastActiveToolBeforeEraser.customType;
|
||||
} else {
|
||||
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
|
||||
}
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
});
|
||||
activeTool.type = "selection";
|
||||
}
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
@@ -181,7 +176,7 @@ export const actionFinalize = register({
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
pendingImageElementId: null,
|
||||
pendingImageElement: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
};
|
||||
|
||||
@@ -31,7 +31,16 @@ export const actionGoToCollaborator = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
const [clientId, collaborator] = data as [string, Collaborator];
|
||||
const clientId: string | undefined = data?.id;
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const collaborator = appState.collaborators.get(clientId);
|
||||
|
||||
if (!collaborator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { background, stroke } = getClientColors(clientId, appState);
|
||||
|
||||
@@ -41,7 +50,7 @@ export const actionGoToCollaborator = register({
|
||||
border={stroke}
|
||||
onClick={() => updateData(collaborator.pointer)}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
src={collaborator.src}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -485,14 +485,10 @@ export const actionChangeOpacity = register({
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(el) =>
|
||||
newElementWith(el, {
|
||||
opacity: value,
|
||||
}),
|
||||
true,
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
opacity: value,
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemOpacity: value },
|
||||
commitToHistory: true,
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("actionStyles", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||
Keyboard.codeDown(CODES.C);
|
||||
});
|
||||
const secondRect = JSON.parse(copiedStyles)[0];
|
||||
const secondRect = JSON.parse(copiedStyles);
|
||||
expect(secondRect.id).toBe(h.elements[1].id);
|
||||
|
||||
mouse.reset();
|
||||
|
||||
@@ -6,15 +6,13 @@ import {
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import { hasBoundTextElement } from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getContainerElement } from "../element/textElement";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
@@ -23,15 +21,9 @@ export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const elementsCopied = [];
|
||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||
elementsCopied.push(element);
|
||||
if (element && hasBoundTextElement(element)) {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
elementsCopied.push(boundTextElement);
|
||||
}
|
||||
if (element) {
|
||||
copiedStyles = JSON.stringify(elementsCopied);
|
||||
copiedStyles = JSON.stringify(element);
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
@@ -50,62 +42,31 @@ export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const elementsCopied = JSON.parse(copiedStyles);
|
||||
const pastedElement = elementsCopied[0];
|
||||
const boundTextElement = elementsCopied[1];
|
||||
const pastedElement = JSON.parse(copiedStyles);
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
return { elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (selectedElementIds.includes(element.id)) {
|
||||
let elementStylesToCopyFrom = pastedElement;
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
elementStylesToCopyFrom = boundTextElement;
|
||||
}
|
||||
if (!elementStylesToCopyFrom) {
|
||||
return element;
|
||||
}
|
||||
let newElement = newElementWith(element, {
|
||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||
opacity: elementStylesToCopyFrom?.opacity,
|
||||
roughness: elementStylesToCopyFrom?.roughness,
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
const newElement = newElementWith(element, {
|
||||
backgroundColor: pastedElement?.backgroundColor,
|
||||
strokeWidth: pastedElement?.strokeWidth,
|
||||
strokeColor: pastedElement?.strokeColor,
|
||||
strokeStyle: pastedElement?.strokeStyle,
|
||||
fillStyle: pastedElement?.fillStyle,
|
||||
opacity: pastedElement?.opacity,
|
||||
roughness: pastedElement?.roughness,
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily:
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
textAlign:
|
||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
if (isTextElement(newElement) && isTextElement(element)) {
|
||||
mutateElement(newElement, {
|
||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
container =
|
||||
selectedElements.find(
|
||||
(element) =>
|
||||
isTextElement(newElement) &&
|
||||
element.id === newElement.containerId,
|
||||
) || null;
|
||||
}
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
}
|
||||
|
||||
if (newElement.type === "arrow") {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
});
|
||||
redrawTextBoundingBox(newElement, getContainerElement(newElement));
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
|
||||
@@ -30,7 +30,7 @@ const trackAction = (
|
||||
trackEvent(
|
||||
action.trackEvent.category,
|
||||
action.trackEvent.action || action.name,
|
||||
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
||||
`${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
} from "../types";
|
||||
import { ToolButtonSize } from "../components/ToolButton";
|
||||
|
||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||
|
||||
@@ -118,7 +119,7 @@ export type PanelComponentProps = {
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Record<string, any>;
|
||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
|
||||
@@ -43,7 +43,6 @@ export const getDefaultAppState = (): Omit<
|
||||
editingLinearElement: null,
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
customType: null,
|
||||
locked: false,
|
||||
lastActiveToolBeforeEraser: null,
|
||||
},
|
||||
@@ -58,7 +57,6 @@ export const getDefaultAppState = (): Omit<
|
||||
gridSize: null,
|
||||
isBindingEnabled: true,
|
||||
isLibraryOpen: false,
|
||||
isLibraryMenuDocked: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
@@ -88,7 +86,7 @@ export const getDefaultAppState = (): Omit<
|
||||
value: 1 as NormalizedZoomValue,
|
||||
},
|
||||
viewModeEnabled: false,
|
||||
pendingImageElementId: null,
|
||||
pendingImageElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
};
|
||||
};
|
||||
@@ -147,8 +145,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
isLibraryOpen: { browser: true, export: false, server: false },
|
||||
isLibraryMenuDocked: { browser: true, export: false, server: false },
|
||||
isLibraryOpen: { browser: false, export: false, server: false },
|
||||
isLoading: { browser: false, export: false, server: false },
|
||||
isResizing: { browser: false, export: false, server: false },
|
||||
isRotating: { browser: false, export: false, server: false },
|
||||
@@ -179,7 +176,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
zenModeEnabled: { browser: true, export: false, server: false },
|
||||
zoom: { browser: true, export: false, server: false },
|
||||
viewModeEnabled: { browser: false, export: false, server: false },
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
pendingImageElement: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,24 +29,18 @@ type ParseSpreadsheetResult =
|
||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseNumber = (s: string): number | null => {
|
||||
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
||||
const tryParseNumber = (s: string): number | null => {
|
||||
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
||||
return parseFloat(match[1].replace(/,/g, ""));
|
||||
};
|
||||
|
||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const numCols = cells[0].length;
|
||||
|
||||
if (numCols > 2) {
|
||||
@@ -77,16 +71,13 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
};
|
||||
}
|
||||
|
||||
const labelColumnNumeric = isNumericColumn(cells, 0);
|
||||
const valueColumnNumeric = isNumericColumn(cells, 1);
|
||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
||||
|
||||
if (!labelColumnNumeric && !valueColumnNumeric) {
|
||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
|
||||
? [0, 1]
|
||||
: [1, 0];
|
||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
||||
const rows = hasHeader ? cells.slice(1) : cells;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
@@ -15,12 +15,7 @@ import {
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import {
|
||||
capitalizeString,
|
||||
isTransparent,
|
||||
updateActiveTool,
|
||||
setCursorForShape,
|
||||
} from "../utils";
|
||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
@@ -52,7 +47,7 @@ export const SelectedShapeActions = ({
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
const isEditing = Boolean(appState.editingElement);
|
||||
const device = useDevice();
|
||||
const deviceType = useDeviceType();
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
@@ -177,8 +172,8 @@ export const SelectedShapeActions = ({
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{!device.isMobile && renderAction("duplicateSelection")}
|
||||
{!device.isMobile && renderAction("deleteSelectedElements")}
|
||||
{!deviceType.isMobile && renderAction("duplicateSelection")}
|
||||
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
@@ -234,9 +229,7 @@ export const ShapesSwitcher = ({
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
});
|
||||
const nextActiveTool = { ...activeTool, type: value };
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import "./Avatar.scss";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { getClientInitials } from "../clients";
|
||||
|
||||
type AvatarProps = {
|
||||
@@ -13,21 +13,13 @@ type AvatarProps = {
|
||||
|
||||
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
|
||||
const shortName = getClientInitials(name);
|
||||
const [error, setError] = useState(false);
|
||||
const loadImg = !error && src;
|
||||
const style = loadImg
|
||||
const style = src
|
||||
? undefined
|
||||
: { background: color, border: `1px solid ${border}` };
|
||||
return (
|
||||
<div className="Avatar" style={style} onClick={onClick}>
|
||||
{loadImg ? (
|
||||
<img
|
||||
className="Avatar-img"
|
||||
src={src}
|
||||
alt={shortName}
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
{src ? (
|
||||
<img className="Avatar-img" src={src} alt={shortName} />
|
||||
) : (
|
||||
shortName
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import { trash } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
icon={trash}
|
||||
title={t("buttons.clearReset")}
|
||||
aria-label={t("buttons.clearReset")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
onClick={toggleDialog}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
|
||||
@@ -18,15 +18,13 @@
|
||||
left: -5px;
|
||||
}
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
background-color: $oc-green-6;
|
||||
color: $oc-white;
|
||||
font-size: 0.6em;
|
||||
font-family: "Cascadia";
|
||||
font-size: 0.7em;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
@@ -26,9 +26,9 @@ const CollabButton = ({
|
||||
type="button"
|
||||
title={t("labels.liveCollaboration")}
|
||||
aria-label={t("labels.liveCollaboration")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
>
|
||||
{isCollaborating && (
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||
)}
|
||||
</ToolButton>
|
||||
|
||||
@@ -128,33 +128,45 @@ const Picker = ({
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
let handled = false;
|
||||
if (isArrowKey(event.key)) {
|
||||
handled = true;
|
||||
if (event.key === KEYS.TAB) {
|
||||
const { activeElement } = document;
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === firstItem.current) {
|
||||
colorInput.current?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
} else if (activeElement === colorInput.current) {
|
||||
firstItem.current?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
} else if (isArrowKey(event.key)) {
|
||||
const { activeElement } = document;
|
||||
const isRTL = getLanguage().rtl;
|
||||
let isCustom = false;
|
||||
let index = Array.prototype.indexOf.call(
|
||||
gallery.current!.querySelector(".color-picker-content--default")
|
||||
?.children,
|
||||
gallery!.current!.querySelector(".color-picker-content--default")!
|
||||
.children,
|
||||
activeElement,
|
||||
);
|
||||
if (index === -1) {
|
||||
index = Array.prototype.indexOf.call(
|
||||
gallery.current!.querySelector(".color-picker-content--canvas-colors")
|
||||
?.children,
|
||||
gallery!.current!.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)!.children,
|
||||
activeElement,
|
||||
);
|
||||
if (index !== -1) {
|
||||
isCustom = true;
|
||||
}
|
||||
}
|
||||
const parentElement = isCustom
|
||||
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
|
||||
: gallery.current?.querySelector(".color-picker-content--default");
|
||||
const parentSelector = isCustom
|
||||
? gallery!.current!.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)!
|
||||
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
||||
|
||||
if (parentElement && index !== -1) {
|
||||
const length = parentElement.children.length - (showInput ? 1 : 0);
|
||||
if (index !== -1) {
|
||||
const length = parentSelector!.children.length - (showInput ? 1 : 0);
|
||||
const nextIndex =
|
||||
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
|
||||
? (index + 1) % length
|
||||
@@ -165,38 +177,30 @@ const Picker = ({
|
||||
: !isCustom && event.key === KEYS.ARROW_UP
|
||||
? (length + index - 5) % length
|
||||
: index;
|
||||
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
|
||||
(parentSelector!.children![nextIndex] as HTMLElement)?.focus();
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (
|
||||
keyBindings.includes(event.key.toLowerCase()) &&
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.altKey &&
|
||||
!isWritableElement(event.target)
|
||||
) {
|
||||
handled = true;
|
||||
const index = keyBindings.indexOf(event.key.toLowerCase());
|
||||
const isCustom = index >= MAX_DEFAULT_COLORS;
|
||||
const parentElement = isCustom
|
||||
? gallery?.current?.querySelector(
|
||||
const parentSelector = isCustom
|
||||
? gallery!.current!.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)
|
||||
: gallery?.current?.querySelector(".color-picker-content--default");
|
||||
)!
|
||||
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
||||
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
|
||||
(
|
||||
parentElement?.children[actualIndex] as HTMLElement | undefined
|
||||
)?.focus();
|
||||
(parentSelector!.children![actualIndex] as HTMLElement)?.focus();
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||
handled = true;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (handled) {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const renderColors = (colors: Array<string>, custom: boolean = false) => {
|
||||
@@ -260,8 +264,7 @@ const Picker = ({
|
||||
gallery.current = el;
|
||||
}
|
||||
}}
|
||||
// to allow focusing by clicking but not by tabbing
|
||||
tabIndex={-1}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="color-picker-content--default">
|
||||
{renderColors(colors)}
|
||||
|
||||
@@ -2,14 +2,13 @@ import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer, useDevice } from "../components/App";
|
||||
import { useExcalidrawContainer, useDeviceType } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { AppState } from "../types";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
|
||||
export interface DialogProps {
|
||||
children: React.ReactNode;
|
||||
@@ -65,6 +64,14 @@ export const Dialog = (props: DialogProps) => {
|
||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||
}, [islandNode, props.autofocus]);
|
||||
|
||||
const queryFocusableElements = (node: HTMLElement) => {
|
||||
const focusableElements = node.querySelectorAll<HTMLElement>(
|
||||
"button, a, input, select, textarea, div[tabindex]",
|
||||
);
|
||||
|
||||
return focusableElements ? Array.from(focusableElements) : [];
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
(lastActiveElement as HTMLElement).focus();
|
||||
props.onCloseRequest();
|
||||
@@ -87,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
onClick={onClose}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useDevice().isMobile ? back : close}
|
||||
{useDeviceType().isMobile ? back : close}
|
||||
</button>
|
||||
</h2>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
|
||||
if (appState.activeTool.type === "image" && appState.pendingImageElement) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
|
||||
icon={exportImage}
|
||||
type="button"
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
title={t("buttons.exportImage")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
||||
@@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
|
||||
useEffect(() => {
|
||||
const updateLang = async () => {
|
||||
await setLanguage(currentLang);
|
||||
setLoading(false);
|
||||
};
|
||||
const currentLang =
|
||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||
updateLang();
|
||||
setLoading(false);
|
||||
}, [props.langCode]);
|
||||
|
||||
return loading ? <LoadingMessage /> : props.children;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
title={t("buttons.export")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
||||
@@ -1,63 +1,9 @@
|
||||
@import "open-color/open-color";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.layer-ui__sidebar {
|
||||
position: absolute;
|
||||
top: var(--sat);
|
||||
bottom: var(--sab);
|
||||
right: var(--sar);
|
||||
z-index: 5;
|
||||
|
||||
box-shadow: var(--shadow-island);
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin: var(--space-factor);
|
||||
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
||||
|
||||
.Island {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.ToolIcon__icon__close {
|
||||
.Modal__close {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__wrapper.animate {
|
||||
transition: width 0.1s ease-in-out;
|
||||
}
|
||||
.layer-ui__wrapper {
|
||||
// when the rightside sidebar is docked, we need to resize the UI by its
|
||||
// width, making the nested UI content shift to the left. To do this,
|
||||
// we need the UI container to actually have dimensions set, but
|
||||
// then we also need to disable pointer events else the canvas below
|
||||
// wouldn't be interactive.
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
&__top-right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import { CLASSES } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
@@ -25,8 +25,9 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
||||
import { Section } from "./Section";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import Library from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
@@ -36,9 +37,7 @@ import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import { useDeviceType } from "../components/App";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -57,9 +56,11 @@ interface LayerUIProps {
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
@@ -67,7 +68,9 @@ interface LayerUIProps {
|
||||
library: Library;
|
||||
id: string;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderCustomElementWidget?: (appState: AppState) => void;
|
||||
}
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
@@ -86,7 +89,6 @@ const LayerUI = ({
|
||||
isCollaborating,
|
||||
renderTopRightUI,
|
||||
renderCustomFooter,
|
||||
renderCustomStats,
|
||||
viewModeEnabled,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
@@ -94,8 +96,9 @@ const LayerUI = ({
|
||||
library,
|
||||
id,
|
||||
onImageAction,
|
||||
renderCustomElementWidget,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
const deviceType = useDeviceType();
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
@@ -276,9 +279,7 @@ const LayerUI = ({
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onClose={closeLibrary}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
}}
|
||||
onInsertShape={onInsertElements}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
@@ -341,7 +342,7 @@ const LayerUI = ({
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={device.isMobile}
|
||||
isMobile={deviceType.isMobile}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
@@ -363,6 +364,7 @@ const LayerUI = ({
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
@@ -375,11 +377,23 @@ const LayerUI = ({
|
||||
},
|
||||
)}
|
||||
>
|
||||
<UserList
|
||||
collaborators={appState.collaborators}
|
||||
actionManager={actionManager}
|
||||
/>
|
||||
{renderTopRightUI?.(device.isMobile, appState)}
|
||||
<UserList>
|
||||
{appState.collaborators.size > 0 &&
|
||||
Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, client]) => (
|
||||
<Tooltip
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
{renderTopRightUI?.(deviceType.isMobile, appState)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
@@ -427,12 +441,14 @@ const LayerUI = ({
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("eraser", { size: "small" })}
|
||||
{renderCustomElementWidget &&
|
||||
renderCustomElementWidget(appState)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!viewModeEnabled &&
|
||||
appState.multiElement &&
|
||||
device.isTouchScreen && (
|
||||
deviceType.isTouchScreen && (
|
||||
<div
|
||||
className={clsx("finalize-button zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
@@ -509,24 +525,7 @@ const LayerUI = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const renderStats = () => {
|
||||
if (!appState.showStats) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return device.isMobile ? (
|
||||
return deviceType.isMobile ? (
|
||||
<>
|
||||
{dialogs}
|
||||
<MobileMenu
|
||||
@@ -547,48 +546,33 @@ const LayerUI = ({
|
||||
showThemeBtn={showThemeBtn}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderStats={renderStats}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper", {
|
||||
"disable-pointerEvents":
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
(appState.editingElement &&
|
||||
!isTextElement(appState.editingElement)),
|
||||
})}
|
||||
style={
|
||||
appState.isLibraryOpen &&
|
||||
appState.isLibraryMenuDocked &&
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{renderStats()}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{appState.isLibraryOpen && (
|
||||
<div className="layer-ui__sidebar">{libraryMenu}</div>
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper", {
|
||||
"disable-pointerEvents":
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
(appState.editingElement && !isTextElement(appState.editingElement)),
|
||||
})}
|
||||
>
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import clsx from "clsx";
|
||||
import { t } from "../i18n";
|
||||
import { AppState } from "../types";
|
||||
import { capitalizeString } from "../utils";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "./App";
|
||||
|
||||
const LIBRARY_ICON = (
|
||||
<svg viewBox="0 0 576 512">
|
||||
@@ -20,7 +18,6 @@ export const LibraryButton: React.FC<{
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
isMobile?: boolean;
|
||||
}> = ({ appState, setAppState, isMobile }) => {
|
||||
const device = useDevice();
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
@@ -37,19 +34,7 @@ export const LibraryButton: React.FC<{
|
||||
type="checkbox"
|
||||
name="editor-library"
|
||||
onChange={(event) => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.remove("animate");
|
||||
const nextState = event.target.checked;
|
||||
setAppState({ isLibraryOpen: nextState });
|
||||
// track only openings
|
||||
if (nextState) {
|
||||
trackEvent(
|
||||
"library",
|
||||
"toggleLibrary (open)",
|
||||
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
setAppState({ isLibraryOpen: event.target.checked });
|
||||
}}
|
||||
checked={appState.isLibraryOpen}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -10,7 +11,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 2px 0 15px 0;
|
||||
margin: 2px 0;
|
||||
|
||||
.Spinner {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
@@ -19,17 +21,13 @@
|
||||
// 2px from the left to account for focus border of left-most button
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar {
|
||||
.layer-ui__library {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.library-menu-items-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
a {
|
||||
margin-inline-start: auto;
|
||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||
padding-inline-end: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,38 +65,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button {
|
||||
width: 80%;
|
||||
min-height: 22px;
|
||||
margin: 0 auto;
|
||||
margin-top: 1rem;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--color-primary);
|
||||
color: $oc-white;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-decoration: none !important;
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button--mobile {
|
||||
min-height: 22px;
|
||||
margin-left: auto;
|
||||
a {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ import "./LibraryMenu.scss";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import Spinner from "./Spinner";
|
||||
import { useDevice } from "./App";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
@@ -77,7 +77,7 @@ const LibraryMenuWrapper = forwardRef<
|
||||
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertLibraryItems,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
@@ -91,7 +91,7 @@ export const LibraryMenu = ({
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onClose: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
@@ -104,30 +104,17 @@ export const LibraryMenu = ({
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const device = useDevice();
|
||||
|
||||
useOnClickOutside(
|
||||
ref,
|
||||
useCallback(
|
||||
(event) => {
|
||||
// If click on the library icon, do nothing.
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
|
||||
),
|
||||
);
|
||||
useOnClickOutside(ref, (event) => {
|
||||
// If click on the library icon, do nothing.
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
|
||||
) {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@@ -135,7 +122,7 @@ export const LibraryMenu = ({
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
|
||||
}, [onClose]);
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
@@ -238,6 +225,10 @@ export const LibraryMenu = ({
|
||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||
);
|
||||
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
|
||||
if (
|
||||
libraryItemsData.status === "loading" &&
|
||||
!libraryItemsData.isInitialized
|
||||
@@ -284,17 +275,56 @@ export const LibraryMenu = ({
|
||||
onAddToLibrary={(elements) =>
|
||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||
}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
onInsertShape={onInsertShape}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
appState={appState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={(ids) => setSelectedItems(ids)}
|
||||
onToggle={(id, event) => {
|
||||
const shouldSelect = !selectedItems.includes(id);
|
||||
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = libraryItemsData.libraryItems.findIndex(
|
||||
(item) => item.id === lastSelectedItem,
|
||||
);
|
||||
const rangeEnd = libraryItemsData.libraryItems.findIndex(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
|
||||
if (rangeStart === -1 || rangeEnd === -1) {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItemsMap = arrayToMap(selectedItems);
|
||||
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
|
||||
(acc: LibraryItem["id"][], item, idx) => {
|
||||
if (
|
||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||
selectedItemsMap.has(item.id)
|
||||
) {
|
||||
acc.push(item.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
setSelectedItems(nextSelectedIds);
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
}
|
||||
setLastSelectedItem(id);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
|
||||
@@ -2,17 +2,8 @@
|
||||
|
||||
.excalidraw {
|
||||
.library-menu-items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
.library-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin-right: auto;
|
||||
align-items: center;
|
||||
|
||||
button .library-actions-counter {
|
||||
position: absolute;
|
||||
@@ -96,16 +87,12 @@
|
||||
}
|
||||
}
|
||||
&__items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-bottom: 1rem;
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.6em 0.2em;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { chunk } from "lodash";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
||||
import { useCallback, useState } from "react";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import Library from "../data/library";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
@@ -11,39 +11,34 @@ import {
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { arrayToMap, muteFSAbortError } from "../utils";
|
||||
import { useDevice } from "./App";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useDeviceType } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { MIME_TYPES, VERSIONS } from "../constants";
|
||||
import { VERSIONS } from "../constants";
|
||||
import Spinner from "./Spinner";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
|
||||
import { SidebarLockButton } from "./SidebarLockButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
isLoading,
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertLibraryItems,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
appState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
onToggle,
|
||||
onPublish,
|
||||
resetLibrary,
|
||||
}: {
|
||||
@@ -51,17 +46,16 @@ const LibraryMenuItems = ({
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onRemoveFromLibrary: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
appState: AppState;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
||||
onPublish: () => void;
|
||||
resetLibrary: () => void;
|
||||
}) => {
|
||||
@@ -93,7 +87,9 @@ const LibraryMenuItems = ({
|
||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
const device = useDevice();
|
||||
|
||||
const isMobile = useDeviceType().isMobile;
|
||||
|
||||
const renderLibraryActions = () => {
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
const items = itemsSelected
|
||||
@@ -104,34 +100,20 @@ const LibraryMenuItems = ({
|
||||
: t("buttons.resetLibrary");
|
||||
return (
|
||||
<div className="library-actions">
|
||||
{!itemsSelected && (
|
||||
{(!itemsSelected || !isMobile) && (
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await library.updateLibrary({
|
||||
libraryItems: fileOpen({
|
||||
description: "Excalidraw library files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
*/
|
||||
}),
|
||||
merge: true,
|
||||
openLibraryMenu: true,
|
||||
onClick={() => {
|
||||
importLibraryFromJSON(library)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn(error);
|
||||
return;
|
||||
}
|
||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||
}
|
||||
}}
|
||||
className="library-actions--load"
|
||||
/>
|
||||
@@ -179,7 +161,7 @@ const LibraryMenuItems = ({
|
||||
</ToolButton>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
{itemsSelected && !isPublished && (
|
||||
<Tooltip label={t("hints.publishLibrary")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
@@ -189,7 +171,7 @@ const LibraryMenuItems = ({
|
||||
className="library-actions--publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
@@ -198,89 +180,17 @@ const LibraryMenuItems = ({
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{device.isMobile && (
|
||||
<div className="library-menu-browse-button--mobile">
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
|
||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
|
||||
const onItemSelectToggle = (
|
||||
id: LibraryItem["id"],
|
||||
event: React.MouseEvent,
|
||||
) => {
|
||||
const shouldSelect = !selectedItems.includes(id);
|
||||
|
||||
const orderedItems = [...unpublishedItems, ...publishedItems];
|
||||
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = orderedItems.findIndex(
|
||||
(item) => item.id === lastSelectedItem,
|
||||
);
|
||||
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
|
||||
|
||||
if (rangeStart === -1 || rangeEnd === -1) {
|
||||
onSelectItems([...selectedItems, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItemsMap = arrayToMap(selectedItems);
|
||||
const nextSelectedIds = orderedItems.reduce(
|
||||
(acc: LibraryItem["id"][], item, idx) => {
|
||||
if (
|
||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||
selectedItemsMap.has(item.id)
|
||||
) {
|
||||
acc.push(item.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
onSelectItems(nextSelectedIds);
|
||||
} else {
|
||||
onSelectItems([...selectedItems, id]);
|
||||
}
|
||||
setLastSelectedItem(id);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
onSelectItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const getInsertedElements = (id: string) => {
|
||||
let targetElements;
|
||||
if (selectedItems.includes(id)) {
|
||||
targetElements = libraryItems.filter((item) =>
|
||||
selectedItems.includes(item.id),
|
||||
);
|
||||
} else {
|
||||
targetElements = libraryItems.filter((item) => item.id === id);
|
||||
}
|
||||
return targetElements;
|
||||
};
|
||||
const isPublished = selectedItems.some(
|
||||
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
||||
);
|
||||
|
||||
const createLibraryItemCompo = (params: {
|
||||
item:
|
||||
@@ -302,12 +212,8 @@ const LibraryMenuItems = ({
|
||||
onClick={params.onClick || (() => {})}
|
||||
id={params.item?.id || null}
|
||||
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||
onToggle={onItemSelectToggle}
|
||||
onDrag={(id, event) => {
|
||||
event.dataTransfer.setData(
|
||||
MIME_TYPES.excalidrawlib,
|
||||
serializeLibraryAsJSON(getInsertedElements(id)),
|
||||
);
|
||||
onToggle={(id, event) => {
|
||||
onToggle(id, event);
|
||||
}}
|
||||
/>
|
||||
</Stack.Col>
|
||||
@@ -327,7 +233,7 @@ const LibraryMenuItems = ({
|
||||
if (item.id) {
|
||||
return createLibraryItemCompo({
|
||||
item,
|
||||
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
|
||||
onClick: () => onInsertShape(item.elements),
|
||||
key: item.id,
|
||||
});
|
||||
}
|
||||
@@ -366,192 +272,53 @@ const LibraryMenuItems = ({
|
||||
});
|
||||
};
|
||||
|
||||
const unpublishedItems = libraryItems.filter(
|
||||
(item) => item.status !== "published",
|
||||
);
|
||||
const publishedItems = libraryItems.filter(
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
const unpublishedItems = [
|
||||
// append pending library item
|
||||
...(pendingElements.length
|
||||
? [{ id: null, elements: pendingElements }]
|
||||
: []),
|
||||
...libraryItems.filter((item) => item.status !== "published"),
|
||||
];
|
||||
|
||||
const renderLibraryHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
{device.canDeviceFitSidebar && (
|
||||
<>
|
||||
<div className="layer-ui__sidebar-lock-button">
|
||||
<SidebarLockButton
|
||||
checked={appState.isLibraryMenuDocked}
|
||||
onChange={() => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.add("animate");
|
||||
const nextState = !appState.isLibraryMenuDocked;
|
||||
setAppState({
|
||||
isLibraryMenuDocked: nextState,
|
||||
});
|
||||
trackEvent(
|
||||
"library",
|
||||
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
|
||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!device.isMobile && (
|
||||
<div className="ToolIcon__icon__close">
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={() =>
|
||||
setAppState({
|
||||
isLibraryOpen: false,
|
||||
})
|
||||
}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{close}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibraryMenuItems = () => {
|
||||
return (
|
||||
return (
|
||||
<div className="library-menu-items-container">
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Stack.Col
|
||||
className="library-menu-items-container__items"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{
|
||||
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<div className="separator">
|
||||
{(pendingElements.length > 0 ||
|
||||
unpublishedItems.length > 0 ||
|
||||
publishedItems.length > 0) && (
|
||||
<div>{t("labels.personalLib")}</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
marginRight: "1rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontWeight: "normal",
|
||||
}}
|
||||
>
|
||||
<div style={{ transform: "translateY(2px)" }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!pendingElements.length && !unpublishedItems.length ? (
|
||||
<div
|
||||
style={{
|
||||
height: 65,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
fontSize: ".9rem",
|
||||
}}
|
||||
>
|
||||
{t("library.noItems")}
|
||||
<div
|
||||
style={{
|
||||
margin: ".6rem 0",
|
||||
fontSize: ".8em",
|
||||
width: "70%",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{publishedItems.length > 0
|
||||
? t("library.hint_emptyPrivateLibrary")
|
||||
: t("library.hint_emptyLibrary")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
renderLibrarySection([
|
||||
// append pending library item
|
||||
...(pendingElements.length
|
||||
? [{ id: null, elements: pendingElements }]
|
||||
: []),
|
||||
...unpublishedItems,
|
||||
])
|
||||
)}
|
||||
<div className="separator">{t("labels.personalLib")}</div>
|
||||
{renderLibrarySection(unpublishedItems)}
|
||||
</>
|
||||
|
||||
<>
|
||||
{(publishedItems.length > 0 ||
|
||||
(!device.isMobile &&
|
||||
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
||||
<div className="separator">{t("labels.excalidrawLib")}</div>
|
||||
)}
|
||||
{publishedItems.length > 0 ? (
|
||||
renderLibrarySection(publishedItems)
|
||||
) : unpublishedItems.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
margin: "1rem 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
fontSize: ".9rem",
|
||||
}}
|
||||
>
|
||||
{t("library.noItems")}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="separator">{t("labels.excalidrawLib")} </div>
|
||||
|
||||
{renderLibrarySection(publishedItems)}
|
||||
</>
|
||||
</Stack.Col>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibraryFooter = () => {
|
||||
return (
|
||||
<a
|
||||
className="library-menu-browse-button"
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="library-menu-items-container"
|
||||
style={
|
||||
device.isMobile
|
||||
? {
|
||||
minHeight: "200px",
|
||||
maxHeight: "70vh",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{renderLibraryHeader()}
|
||||
{renderLibraryMenuItems()}
|
||||
{!device.isMobile && renderLibraryFooter()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.excalidraw {
|
||||
.library-unit {
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--button-gray-2);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
@@ -21,6 +21,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark .library-unit {
|
||||
border-color: rgb(48, 48, 48);
|
||||
}
|
||||
|
||||
.library-unit__dragger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDevice } from "../components/App";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
@@ -28,7 +29,6 @@ export const LibraryUnit = ({
|
||||
onClick,
|
||||
selected,
|
||||
onToggle,
|
||||
onDrag,
|
||||
}: {
|
||||
id: LibraryItem["id"] | /** for pending item */ null;
|
||||
elements?: LibraryItem["elements"];
|
||||
@@ -37,7 +37,6 @@ export const LibraryUnit = ({
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
onToggle: (id: string, event: React.MouseEvent) => void;
|
||||
onDrag: (id: string, event: React.DragEvent) => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -67,7 +66,7 @@ export const LibraryUnit = ({
|
||||
}, [elements, files]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useDevice().isMobile;
|
||||
const isMobile = useDeviceType().isMobile;
|
||||
const adder = isPending && (
|
||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||
);
|
||||
@@ -100,12 +99,11 @@ export const LibraryUnit = ({
|
||||
: undefined
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
if (!id) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
setIsHovered(false);
|
||||
onDrag(id, event);
|
||||
event.dataTransfer.setData(
|
||||
MIME_TYPES.excalidrawlib,
|
||||
JSON.stringify(elements),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{adder}
|
||||
|
||||
@@ -32,10 +32,7 @@ type MobileMenuProps = {
|
||||
onPenModeToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
renderCustomFooter?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
showThemeBtn: boolean;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
@@ -43,7 +40,6 @@ type MobileMenuProps = {
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderStats: () => JSX.Element | null;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
@@ -64,7 +60,6 @@ export const MobileMenu = ({
|
||||
showThemeBtn,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderStats,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
@@ -186,7 +181,6 @@ export const MobileMenu = ({
|
||||
return (
|
||||
<>
|
||||
{!viewModeEnabled && renderToolbar()}
|
||||
{renderStats()}
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
@@ -205,11 +199,20 @@ export const MobileMenu = ({
|
||||
{appState.collaborators.size > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList
|
||||
mobile
|
||||
collaborators={appState.collaborators}
|
||||
actionManager={actionManager}
|
||||
/>
|
||||
<UserList mobile>
|
||||
{Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(
|
||||
([_, client]) => Object.keys(client).length !== 0,
|
||||
)
|
||||
.map(([clientId, client]) => (
|
||||
<React.Fragment key={clientId}>
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</UserList>
|
||||
</fieldset>
|
||||
)}
|
||||
</Stack.Col>
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { useExcalidrawContainer, useDevice } from "./App";
|
||||
import { useExcalidrawContainer, useDeviceType } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
@@ -59,17 +59,17 @@ export const Modal = (props: {
|
||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const device = useDevice();
|
||||
const isMobileRef = useRef(device.isMobile);
|
||||
isMobileRef.current = device.isMobile;
|
||||
const deviceType = useDeviceType();
|
||||
const isMobileRef = useRef(deviceType.isMobile);
|
||||
isMobileRef.current = deviceType.isMobile;
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (div) {
|
||||
div.classList.toggle("excalidraw--mobile", device.isMobile);
|
||||
div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
|
||||
}
|
||||
}, [div, device.isMobile]);
|
||||
}, [div, deviceType.isMobile]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isDarkTheme =
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { useLayoutEffect, useRef, useEffect } from "react";
|
||||
import "./Popover.scss";
|
||||
import { unstable_batchedUpdates } from "react-dom";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
type Props = {
|
||||
top?: number;
|
||||
@@ -29,41 +27,6 @@ export const Popover = ({
|
||||
}: Props) => {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const container = popoverRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
const focusableElements = queryFocusableElements(container);
|
||||
const { activeElement } = document;
|
||||
const currentIndex = focusableElements.findIndex(
|
||||
(element) => element === activeElement,
|
||||
);
|
||||
|
||||
if (currentIndex === 0 && event.shiftKey) {
|
||||
focusableElements[focusableElements.length - 1].focus();
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
} else if (
|
||||
currentIndex === focusableElements.length - 1 &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
focusableElements[0].focus();
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||
}, [container]);
|
||||
|
||||
// ensure the popover doesn't overflow the viewport
|
||||
useLayoutEffect(() => {
|
||||
if (fitInViewport && popoverRef.current) {
|
||||
|
||||
@@ -82,10 +82,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&-warning {
|
||||
color: $oc-red-6;
|
||||
}
|
||||
|
||||
&-note {
|
||||
padding: 1em;
|
||||
font-style: italic;
|
||||
|
||||
@@ -295,11 +295,6 @@ const PublishLibrary = ({
|
||||
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
||||
|
||||
const shouldRenderForm = !!libraryItems.length;
|
||||
|
||||
const containsPublishedItems = libraryItems.some(
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={onDialogClose}
|
||||
@@ -334,11 +329,6 @@ const PublishLibrary = ({
|
||||
<div className="publish-library-note">
|
||||
{t("publishDialog.noteItems")}
|
||||
</div>
|
||||
{containsPublishedItems && (
|
||||
<span className="publish-library-note publish-library-warning">
|
||||
{t("publishDialog.republishWarning")}
|
||||
</span>
|
||||
)}
|
||||
{renderLibraryItems()}
|
||||
<div className="publish-library__fields">
|
||||
<label>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__sidebar-lock-button {
|
||||
@include toolbarButtonColorStates;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
.ToolIcon_type_floating .side_lock_icon {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
svg {
|
||||
// mirror
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon_type_checkbox {
|
||||
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./SidebarLockButton.scss";
|
||||
|
||||
type SidebarLockIconProps = {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||
|
||||
const SIDE_LIBRARY_TOGGLE_ICON = (
|
||||
<svg viewBox="0 0 24 24" fill="#ffffff">
|
||||
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SidebarLockButton = (props: SidebarLockIconProps) => {
|
||||
return (
|
||||
<Tooltip label={t("labels.sidebarLock")}>
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={t("labels.sidebarLock")}
|
||||
/>{" "}
|
||||
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
|
||||
{SIDE_LIBRARY_TOGGLE_ICON}
|
||||
</div>{" "}
|
||||
</label>{" "}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -3,24 +3,11 @@
|
||||
.excalidraw {
|
||||
.single-library-item {
|
||||
position: relative;
|
||||
|
||||
&-status {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
left: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
color: $oc-red-7;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 0.1rem 0.2rem;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
&__svg {
|
||||
background-color: $oc-white;
|
||||
padding: 0.3rem;
|
||||
width: 7.5rem;
|
||||
height: 7.5rem;
|
||||
border: 1px solid var(--button-gray-2);
|
||||
margin: 0.3rem;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -53,7 +40,7 @@
|
||||
&--remove {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 1rem;
|
||||
right: 1.3rem;
|
||||
|
||||
.ToolIcon__icon {
|
||||
margin: 0;
|
||||
|
||||
@@ -45,11 +45,6 @@ const SingleLibraryItem = ({
|
||||
|
||||
return (
|
||||
<div className="single-library-item">
|
||||
{libItem.status === "published" && (
|
||||
<span className="single-library-item-status">
|
||||
{t("labels.statusPublished")}
|
||||
</span>
|
||||
)}
|
||||
<div ref={svgRef} className="single-library-item__svg" />
|
||||
<ToolButton
|
||||
aria-label={t("buttons.remove")}
|
||||
|
||||
@@ -41,7 +41,6 @@ const ColStack = ({
|
||||
align,
|
||||
justifyContent,
|
||||
className,
|
||||
style,
|
||||
}: StackProps) => {
|
||||
return (
|
||||
<div
|
||||
@@ -50,7 +49,6 @@ const ColStack = ({
|
||||
"--gap": gap,
|
||||
justifyItems: align,
|
||||
justifyContent,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
pointer-events: all;
|
||||
|
||||
h3 {
|
||||
margin: 0 24px 8px 0;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { close } from "./icons";
|
||||
@@ -16,13 +16,16 @@ export const Stats = (props: {
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const deviceType = useDeviceType();
|
||||
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||
if (device.isMobile && props.appState.openMenu) {
|
||||
|
||||
if (deviceType.isMobile && props.appState.openMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={2}>
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
.excalidraw {
|
||||
.Toast {
|
||||
$closeButtonSize: 1.2rem;
|
||||
$closeButtonPadding: 0.4rem;
|
||||
|
||||
animation: fade-in 0.5s;
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 4px;
|
||||
@@ -18,24 +15,11 @@
|
||||
text-align: center;
|
||||
width: 300px;
|
||||
z-index: 999999;
|
||||
}
|
||||
|
||||
.Toast__message {
|
||||
padding: 0 $closeButtonSize + ($closeButtonPadding);
|
||||
color: var(--popup-text-color);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: $closeButtonPadding;
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: $closeButtonSize;
|
||||
height: $closeButtonSize;
|
||||
}
|
||||
}
|
||||
.Toast__message {
|
||||
color: var(--popup-text-color);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
|
||||
@@ -1,59 +1,34 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { close } from "./icons";
|
||||
import { TOAST_TIMEOUT } from "../constants";
|
||||
import "./Toast.scss";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
const DEFAULT_TOAST_TIMEOUT = 5000;
|
||||
|
||||
export const Toast = ({
|
||||
message,
|
||||
clearToast,
|
||||
closable = false,
|
||||
// To prevent autoclose, pass duration as Infinity
|
||||
duration = DEFAULT_TOAST_TIMEOUT,
|
||||
}: {
|
||||
message: string;
|
||||
clearToast: () => void;
|
||||
closable?: boolean;
|
||||
duration?: number;
|
||||
}) => {
|
||||
const timerRef = useRef<number>(0);
|
||||
const shouldAutoClose = duration !== Infinity;
|
||||
const scheduleTimeout = useCallback(() => {
|
||||
if (!shouldAutoClose) {
|
||||
return;
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => clearToast(), duration);
|
||||
}, [clearToast, duration, shouldAutoClose]);
|
||||
|
||||
const scheduleTimeout = useCallback(
|
||||
() =>
|
||||
(timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
|
||||
[clearToast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoClose) {
|
||||
return;
|
||||
}
|
||||
scheduleTimeout();
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [scheduleTimeout, message, duration, shouldAutoClose]);
|
||||
}, [scheduleTimeout, message]);
|
||||
|
||||
const onMouseEnter = shouldAutoClose
|
||||
? () => clearTimeout(timerRef?.current)
|
||||
: undefined;
|
||||
const onMouseLeave = shouldAutoClose ? scheduleTimeout : undefined;
|
||||
return (
|
||||
<div
|
||||
className="Toast"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseEnter={() => clearTimeout(timerRef?.current)}
|
||||
onMouseLeave={scheduleTimeout}
|
||||
>
|
||||
<p className="Toast__message">{message}</p>
|
||||
{closable && (
|
||||
<ToolButton
|
||||
icon={close}
|
||||
aria-label="close"
|
||||
type="icon"
|
||||
onClick={clearToast}
|
||||
className="close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
@import "open-color/open-color.scss";
|
||||
@import "../css/variables.module";
|
||||
|
||||
@mixin toolbarButtonColorStates {
|
||||
.ToolIcon_type_radio,
|
||||
.ToolIcon_type_checkbox {
|
||||
& + .ToolIcon__icon:active {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
&:checked + .ToolIcon__icon {
|
||||
background: var(--color-primary);
|
||||
--icon-fill-color: #{$oc-white};
|
||||
--keybinding-color: #{$oc-white};
|
||||
}
|
||||
&:checked + .ToolIcon__icon:active {
|
||||
background: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__keybinding {
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.App-toolbar-container {
|
||||
|
||||
@@ -2,51 +2,17 @@ import "./UserList.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { AppState, Collaborator } from "../types";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
|
||||
export const UserList: React.FC<{
|
||||
type UserListProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
collaborators: AppState["collaborators"];
|
||||
actionManager: ActionManager;
|
||||
}> = ({ className, mobile, collaborators, actionManager }) => {
|
||||
const uniqueCollaborators = new Map<string, Collaborator>();
|
||||
|
||||
collaborators.forEach((collaborator, socketId) => {
|
||||
uniqueCollaborators.set(
|
||||
// filter on user id, else fall back on unique socketId
|
||||
collaborator.id || socketId,
|
||||
collaborator,
|
||||
);
|
||||
});
|
||||
|
||||
const avatars =
|
||||
uniqueCollaborators.size > 0 &&
|
||||
Array.from(uniqueCollaborators)
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, collaborator]) => {
|
||||
const avatarJSX = actionManager.renderAction("goToCollaborator", [
|
||||
clientId,
|
||||
collaborator,
|
||||
]);
|
||||
|
||||
return mobile ? (
|
||||
<Tooltip
|
||||
label={collaborator.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{avatarJSX}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const UserList = ({ children, className, mobile }: UserListProps) => {
|
||||
return (
|
||||
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
||||
{avatars}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import cssVariables from "./css/variables.module.scss";
|
||||
import { AppProps } from "./types";
|
||||
import { AppProps, CustomElementConfig } from "./types";
|
||||
import { FontFamilyValues } from "./element/types";
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
@@ -116,6 +116,7 @@ export const IMAGE_RENDER_TIMEOUT = 500;
|
||||
export const TAP_TWICE_TIMEOUT = 300;
|
||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||
export const TITLE_TIMEOUT = 10000;
|
||||
export const TOAST_TIMEOUT = 5000;
|
||||
export const VERSION_TIMEOUT = 30000;
|
||||
export const SCROLL_TIMEOUT = 100;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
@@ -154,19 +155,20 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
},
|
||||
};
|
||||
|
||||
// breakpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
// sm screen
|
||||
export const MQ_SM_MAX_WIDTH = 640;
|
||||
// md screen
|
||||
export const DEFAULT_CUSTOM_ELEMENT_CONFIG: Required<CustomElementConfig> = {
|
||||
type: "custom",
|
||||
customType: "custom",
|
||||
transformHandles: true,
|
||||
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_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
// sidebar
|
||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth);
|
||||
|
||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
|
||||
@@ -202,7 +204,3 @@ export const VERTICAL_ALIGN = {
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
|
||||
export const COOKIES = {
|
||||
AUTH_STATE_COOKIE: "excplus-auth",
|
||||
} as const;
|
||||
|
||||
42
src/createInverseContext.tsx
Normal file
42
src/createInverseContext.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -350,6 +350,7 @@
|
||||
align-items: flex-start;
|
||||
cursor: default;
|
||||
pointer-events: none !important;
|
||||
z-index: 100;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
left: 0.25rem;
|
||||
@@ -390,7 +391,6 @@
|
||||
|
||||
.App-menu__left {
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
|
||||
.dropdown-select {
|
||||
@@ -449,7 +449,6 @@
|
||||
bottom: 30px;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
@@ -568,22 +567,6 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// use custom, minimalistic scrollbar
|
||||
// (doesn't work in Firefox)
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--button-gray-2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--button-gray-3);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: var(--button-gray-2);
|
||||
}
|
||||
}
|
||||
|
||||
.ErrorSplash.excalidraw {
|
||||
|
||||
@@ -6,32 +6,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin toolbarButtonColorStates {
|
||||
.ToolIcon_type_radio,
|
||||
.ToolIcon_type_checkbox {
|
||||
& + .ToolIcon__icon:active {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
&:checked + .ToolIcon__icon {
|
||||
background: var(--color-primary);
|
||||
--icon-fill-color: #{$oc-white};
|
||||
--keybinding-color: #{$oc-white};
|
||||
}
|
||||
&:checked + .ToolIcon__icon:active {
|
||||
background: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__keybinding {
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
||||
$right-sidebar-width: "302px";
|
||||
|
||||
:export {
|
||||
themeFilter: unquote($theme-filter);
|
||||
rightSidebarWidth: unquote($right-sidebar-width);
|
||||
}
|
||||
|
||||
214
src/data/blob.ts
214
src/data/blob.ts
@@ -8,7 +8,7 @@ import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { AppState, DataURL, LibraryItem } from "../types";
|
||||
import { bytesToHexString } from "../utils";
|
||||
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
|
||||
import { FileSystemHandle } from "./filesystem";
|
||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||
import { restore, restoreLibraryItems } from "./restore";
|
||||
import { ImportedLibraryData } from "./types";
|
||||
@@ -123,79 +123,48 @@ export const isSupportedImageFile = (
|
||||
);
|
||||
};
|
||||
|
||||
export const loadSceneOrLibraryFromBlob = async (
|
||||
blob: Blob | File,
|
||||
export const loadFromBlob = async (
|
||||
blob: Blob,
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemHandle | null,
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
try {
|
||||
const data = JSON.parse(contents);
|
||||
if (isValidExcalidrawData(data)) {
|
||||
return {
|
||||
type: MIME_TYPES.excalidraw,
|
||||
data: restore(
|
||||
{
|
||||
elements: clearElementsForExport(data.elements || []),
|
||||
appState: {
|
||||
theme: localAppState?.theme,
|
||||
fileHandle: fileHandle || blob.handle || null,
|
||||
...cleanAppStateForExport(data.appState || {}),
|
||||
...(localAppState
|
||||
? calculateScrollCenter(
|
||||
data.elements || [],
|
||||
localAppState,
|
||||
null,
|
||||
)
|
||||
: {}),
|
||||
},
|
||||
files: data.files,
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
return {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
data,
|
||||
};
|
||||
if (!isValidExcalidrawData(data)) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
const result = restore(
|
||||
{
|
||||
elements: clearElementsForExport(data.elements || []),
|
||||
appState: {
|
||||
theme: localAppState?.theme,
|
||||
fileHandle: blob.handle || null,
|
||||
...cleanAppStateForExport(data.appState || {}),
|
||||
...(localAppState
|
||||
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||
: {}),
|
||||
},
|
||||
files: data.files,
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error(error.message);
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
};
|
||||
|
||||
export const loadFromBlob = async (
|
||||
export const loadLibraryFromBlob = async (
|
||||
blob: Blob,
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemHandle | null,
|
||||
) => {
|
||||
const ret = await loadSceneOrLibraryFromBlob(
|
||||
blob,
|
||||
localAppState,
|
||||
localElements,
|
||||
fileHandle,
|
||||
);
|
||||
if (ret.type !== MIME_TYPES.excalidraw) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
return ret.data;
|
||||
};
|
||||
|
||||
export const parseLibraryJSON = (
|
||||
json: string,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) => {
|
||||
const data: ImportedLibraryData | undefined = JSON.parse(json);
|
||||
const contents = await parseFileContents(blob);
|
||||
const data: ImportedLibraryData | undefined = JSON.parse(contents);
|
||||
if (!isValidLibrary(data)) {
|
||||
throw new Error("Invalid library");
|
||||
}
|
||||
@@ -203,13 +172,6 @@ export const parseLibraryJSON = (
|
||||
return restoreLibraryItems(libraryItems, defaultStatus);
|
||||
};
|
||||
|
||||
export const loadLibraryFromBlob = async (
|
||||
blob: Blob,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) => {
|
||||
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
|
||||
};
|
||||
|
||||
export const canvasToBlob = async (
|
||||
canvas: HTMLCanvasElement,
|
||||
): Promise<Blob> => {
|
||||
@@ -238,7 +200,7 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
|
||||
try {
|
||||
const hashBuffer = await window.crypto.subtle.digest(
|
||||
"SHA-1",
|
||||
await blobToArrayBuffer(file),
|
||||
await file.arrayBuffer(),
|
||||
);
|
||||
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
|
||||
} catch (error: any) {
|
||||
@@ -327,125 +289,3 @@ export const SVGStringToFile = (SVGString: string, filename: string = "") => {
|
||||
type: MIME_TYPES.svg,
|
||||
}) as File & { type: typeof MIME_TYPES.svg };
|
||||
};
|
||||
|
||||
export const getFileFromEvent = async (
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const file = event.dataTransfer.files.item(0);
|
||||
const fileHandle = await getFileHandle(event);
|
||||
|
||||
return { file: file ? await normalizeFile(file) : null, fileHandle };
|
||||
};
|
||||
|
||||
export const getFileHandle = async (
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
): Promise<FileSystemHandle | null> => {
|
||||
if (nativeFileSystemSupported) {
|
||||
try {
|
||||
const item = event.dataTransfer.items[0];
|
||||
const handle: FileSystemHandle | null =
|
||||
(await (item as any).getAsFileSystemHandle()) || null;
|
||||
|
||||
return handle;
|
||||
} catch (error: any) {
|
||||
console.warn(error.name, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* attemps to detect if a buffer is a valid image by checking its leading bytes
|
||||
*/
|
||||
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
|
||||
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
|
||||
null;
|
||||
|
||||
const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
|
||||
|
||||
// uint8 leading bytes
|
||||
const headerBytes = {
|
||||
// https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
|
||||
png: "137 80 78 71 13 10 26 10 ",
|
||||
// https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
|
||||
// jpg is a bit wonky. Checking the first three bytes should be enough,
|
||||
// but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
|
||||
jpg: "255 216 255 ",
|
||||
// https://en.wikipedia.org/wiki/GIF#Example_GIF_file
|
||||
gif: "71 73 70 56 57 97 ",
|
||||
};
|
||||
|
||||
if (first8Bytes === headerBytes.png) {
|
||||
mimeType = MIME_TYPES.png;
|
||||
} else if (first8Bytes.startsWith(headerBytes.jpg)) {
|
||||
mimeType = MIME_TYPES.jpg;
|
||||
} else if (first8Bytes.startsWith(headerBytes.gif)) {
|
||||
mimeType = MIME_TYPES.gif;
|
||||
}
|
||||
return mimeType;
|
||||
};
|
||||
|
||||
export const createFile = (
|
||||
blob: File | Blob | ArrayBuffer,
|
||||
mimeType: ValueOf<typeof MIME_TYPES>,
|
||||
name: string | undefined,
|
||||
) => {
|
||||
return new File([blob], name || "", {
|
||||
type: mimeType,
|
||||
});
|
||||
};
|
||||
|
||||
/** attemps to detect correct mimeType if none is set, or if an image
|
||||
* has an incorrect extension.
|
||||
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
|
||||
export const normalizeFile = async (file: File) => {
|
||||
if (!file.type) {
|
||||
if (file?.name?.endsWith(".excalidrawlib")) {
|
||||
file = createFile(
|
||||
await blobToArrayBuffer(file),
|
||||
MIME_TYPES.excalidrawlib,
|
||||
file.name,
|
||||
);
|
||||
} else if (file?.name?.endsWith(".excalidraw")) {
|
||||
file = createFile(
|
||||
await blobToArrayBuffer(file),
|
||||
MIME_TYPES.excalidraw,
|
||||
file.name,
|
||||
);
|
||||
} else {
|
||||
const buffer = await blobToArrayBuffer(file);
|
||||
const mimeType = getActualMimeTypeFromImage(buffer);
|
||||
if (mimeType) {
|
||||
file = createFile(buffer, mimeType, file.name);
|
||||
}
|
||||
}
|
||||
// when the file is an image, make sure the extension corresponds to the
|
||||
// actual mimeType (this is an edge case, but happens sometime)
|
||||
} else if (isSupportedImageFile(file)) {
|
||||
const buffer = await blobToArrayBuffer(file);
|
||||
const mimeType = getActualMimeTypeFromImage(buffer);
|
||||
if (mimeType && mimeType !== file.type) {
|
||||
file = createFile(buffer, mimeType, file.name);
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
};
|
||||
|
||||
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
|
||||
if ("arrayBuffer" in blob) {
|
||||
return blob.arrayBuffer();
|
||||
}
|
||||
// Safari
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (!event.target?.result) {
|
||||
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
|
||||
}
|
||||
resolve(event.target.result as ArrayBuffer);
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ENCRYPTION_KEY_BITS } from "../constants";
|
||||
import { blobToArrayBuffer } from "./blob";
|
||||
|
||||
export const IV_LENGTH_BYTES = 12;
|
||||
|
||||
@@ -59,7 +58,7 @@ export const encryptData = async (
|
||||
: data instanceof Uint8Array
|
||||
? data
|
||||
: data instanceof Blob
|
||||
? await blobToArrayBuffer(data)
|
||||
? await data.arrayBuffer()
|
||||
: data;
|
||||
|
||||
// We use symmetric encryption. AES-GCM is the recommended algorithm and
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
FileWithHandle,
|
||||
fileOpen as _fileOpen,
|
||||
fileSave as _fileSave,
|
||||
FileSystemHandle,
|
||||
@@ -25,9 +26,13 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions?: FILE_EXTENSION[];
|
||||
description: string;
|
||||
multiple?: M;
|
||||
}): Promise<M extends false | undefined ? File : File[]> => {
|
||||
}): Promise<
|
||||
M extends false | undefined ? FileWithHandle : FileWithHandle[]
|
||||
> => {
|
||||
// an unsafe TS hack, alas not much we can do AFAIK
|
||||
type RetType = M extends false | undefined ? File : File[];
|
||||
type RetType = M extends false | undefined
|
||||
? FileWithHandle
|
||||
: FileWithHandle[];
|
||||
|
||||
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||
mimeTypes.push(MIME_TYPES[type]);
|
||||
|
||||
@@ -3,12 +3,28 @@ import tEXt from "png-chunk-text";
|
||||
import encodePng from "png-chunks-encode";
|
||||
import { stringToBase64, encode, decode, base64ToString } from "./encode";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import { blobToArrayBuffer } from "./blob";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PNG
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
|
||||
if ("arrayBuffer" in blob) {
|
||||
return blob.arrayBuffer();
|
||||
}
|
||||
// Safari
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (!event.target?.result) {
|
||||
return reject(new Error("couldn't convert blob to ArrayBuffer"));
|
||||
}
|
||||
resolve(event.target.result as ArrayBuffer);
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
};
|
||||
|
||||
export const getTEXtChunk = async (
|
||||
blob: Blob,
|
||||
): Promise<{ keyword: string; text: string } | null> => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||
import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob";
|
||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
||||
|
||||
import {
|
||||
ExportedDataState,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ExportedLibraryData,
|
||||
ImportedLibraryData,
|
||||
} from "./types";
|
||||
import Library from "./library";
|
||||
|
||||
/**
|
||||
* Strips out files which are only referenced by deleted elements
|
||||
@@ -92,18 +93,13 @@ export const loadFromJSON = async (
|
||||
localAppState: AppState,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
) => {
|
||||
const file = await fileOpen({
|
||||
const blob = await fileOpen({
|
||||
description: "Excalidraw files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
// extensions: ["json", "excalidraw", "png", "svg"],
|
||||
});
|
||||
return loadFromBlob(
|
||||
await normalizeFile(file),
|
||||
localAppState,
|
||||
localElements,
|
||||
file.handle,
|
||||
);
|
||||
return loadFromBlob(blob, localAppState, localElements);
|
||||
};
|
||||
|
||||
export const isValidExcalidrawData = (data?: {
|
||||
@@ -151,3 +147,15 @@ export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const importLibraryFromJSON = async (library: Library) => {
|
||||
const blob = await fileOpen({
|
||||
description: "Excalidraw library files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
*/
|
||||
});
|
||||
await library.importLibrary(blob);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import { loadLibraryFromBlob } from "./blob";
|
||||
import {
|
||||
LibraryItems,
|
||||
LibraryItem,
|
||||
ExcalidrawImperativeAPI,
|
||||
LibraryItemsSource,
|
||||
} from "../types";
|
||||
import { LibraryItems, LibraryItem } from "../types";
|
||||
import { restoreLibraryItems } from "./restore";
|
||||
import type App from "../components/App";
|
||||
import { ImportedDataState } from "./types";
|
||||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import { AbortError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants";
|
||||
|
||||
export const libraryItemsAtom = atom<{
|
||||
status: "loading" | "loaded";
|
||||
@@ -112,6 +102,36 @@ class Library {
|
||||
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.
|
||||
*/
|
||||
@@ -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 = (
|
||||
/**
|
||||
* LibraryItems that will replace current items. Can be a function which
|
||||
@@ -243,194 +204,3 @@ class 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]);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { bumpVersion } from "../element/mutateElement";
|
||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
@@ -199,7 +199,10 @@ const restoreElement = (
|
||||
y,
|
||||
});
|
||||
}
|
||||
|
||||
case "custom":
|
||||
return restoreElementWithProperties(element, {
|
||||
customType: element.customType || "custom",
|
||||
});
|
||||
// generic elements
|
||||
case "ellipse":
|
||||
return restoreElementWithProperties(element, {});
|
||||
@@ -257,7 +260,19 @@ export const restoreAppState = (
|
||||
? localValue
|
||||
: 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 {
|
||||
...nextAppState,
|
||||
cursorButton: localAppState?.cursorButton || "up",
|
||||
@@ -265,17 +280,7 @@ export const restoreAppState = (
|
||||
penDetected:
|
||||
localAppState?.penDetected ??
|
||||
(appState.penMode ? appState.penDetected ?? false : false),
|
||||
activeTool: {
|
||||
...updateActiveTool(
|
||||
defaultAppState,
|
||||
nextAppState.activeTool.type &&
|
||||
AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
|
||||
? nextAppState.activeTool
|
||||
: { type: "selection" },
|
||||
),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
locked: nextAppState.activeTool.locked ?? false,
|
||||
},
|
||||
activeTool,
|
||||
// Migrates from previous version where appState.zoom was a number
|
||||
zoom:
|
||||
typeof appState.zoom === "number"
|
||||
@@ -283,11 +288,6 @@ export const restoreAppState = (
|
||||
value: appState.zoom as NormalizedZoomValue,
|
||||
}
|
||||
: appState.zoom || defaultAppState.zoom,
|
||||
// when sidebar docked and user left it open in last session,
|
||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||
isLibraryOpen: nextAppState.isLibraryMenuDocked
|
||||
? nextAppState.isLibraryOpen
|
||||
: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
LibraryItems,
|
||||
LibraryItems_anyVersion,
|
||||
} from "../types";
|
||||
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
|
||||
import type { cleanAppStateForExport } from "../appState";
|
||||
import { VERSIONS } from "../constants";
|
||||
|
||||
@@ -24,7 +19,7 @@ export interface ImportedDataState {
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: Readonly<Partial<AppState>> | null;
|
||||
scrollToContent?: boolean;
|
||||
libraryItems?: LibraryItems_anyVersion;
|
||||
libraryItems?: LibraryItems | LibraryItems_v1;
|
||||
files?: BinaryFiles;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,20 +47,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
|
||||
return appState.isBindingEnabled;
|
||||
};
|
||||
|
||||
const getNonDeletedElements = (
|
||||
scene: Scene,
|
||||
ids: readonly ExcalidrawElement["id"][],
|
||||
): NonDeleted<ExcalidrawElement>[] => {
|
||||
const result: NonDeleted<ExcalidrawElement>[] = [];
|
||||
ids.forEach((id) => {
|
||||
const element = scene.getNonDeletedElement(id);
|
||||
if (element != null) {
|
||||
result.push(element);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const bindOrUnbindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startBindingElement: ExcalidrawBindableElement | null | "keep",
|
||||
@@ -88,17 +74,16 @@ export const bindOrUnbindLinearElement = (
|
||||
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||
(id) => !boundToElementIds.has(id),
|
||||
);
|
||||
|
||||
getNonDeletedElements(Scene.getScene(linearElement)!, onlyUnbound).forEach(
|
||||
(element) => {
|
||||
Scene.getScene(linearElement)!
|
||||
.getNonDeletedElements(onlyUnbound)
|
||||
.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(element) =>
|
||||
element.type !== "arrow" || element.id !== linearElement.id,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const bindOrUnbindLinearElementEdge = (
|
||||
@@ -268,7 +253,7 @@ export const getHoveredElementForBinding = (
|
||||
scene: Scene,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const hoveredElement = getElementAtPosition(
|
||||
scene.getNonDeletedElements(),
|
||||
scene.getElements(),
|
||||
(element) =>
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, pointerCoords),
|
||||
@@ -320,48 +305,46 @@ export const updateBoundElements = (
|
||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||
simultaneouslyUpdated,
|
||||
);
|
||||
Scene.getScene(changedElement)!
|
||||
.getNonDeletedElements(boundLinearElements.map((el) => el.id))
|
||||
.forEach((element) => {
|
||||
if (!isLinearElement(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
getNonDeletedElements(
|
||||
Scene.getScene(changedElement)!,
|
||||
boundLinearElements.map((el) => el.id),
|
||||
).forEach((element) => {
|
||||
if (!isLinearElement(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bindableElement = changedElement as ExcalidrawBindableElement;
|
||||
// In case the boundElements are stale
|
||||
if (!doesNeedUpdate(element, bindableElement)) {
|
||||
return;
|
||||
}
|
||||
const startBinding = maybeCalculateNewGapWhenScaling(
|
||||
bindableElement,
|
||||
element.startBinding,
|
||||
newSize,
|
||||
);
|
||||
const endBinding = maybeCalculateNewGapWhenScaling(
|
||||
bindableElement,
|
||||
element.endBinding,
|
||||
newSize,
|
||||
);
|
||||
// `linearElement` is being moved/scaled already, just update the binding
|
||||
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||
mutateElement(element, { startBinding, endBinding });
|
||||
return;
|
||||
}
|
||||
updateBoundPoint(
|
||||
element,
|
||||
"start",
|
||||
startBinding,
|
||||
changedElement as ExcalidrawBindableElement,
|
||||
);
|
||||
updateBoundPoint(
|
||||
element,
|
||||
"end",
|
||||
endBinding,
|
||||
changedElement as ExcalidrawBindableElement,
|
||||
);
|
||||
});
|
||||
const bindableElement = changedElement as ExcalidrawBindableElement;
|
||||
// In case the boundElements are stale
|
||||
if (!doesNeedUpdate(element, bindableElement)) {
|
||||
return;
|
||||
}
|
||||
const startBinding = maybeCalculateNewGapWhenScaling(
|
||||
bindableElement,
|
||||
element.startBinding,
|
||||
newSize,
|
||||
);
|
||||
const endBinding = maybeCalculateNewGapWhenScaling(
|
||||
bindableElement,
|
||||
element.endBinding,
|
||||
newSize,
|
||||
);
|
||||
// `linearElement` is being moved/scaled already, just update the binding
|
||||
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||
mutateElement(element, { startBinding, endBinding });
|
||||
return;
|
||||
}
|
||||
updateBoundPoint(
|
||||
element,
|
||||
"start",
|
||||
startBinding,
|
||||
changedElement as ExcalidrawBindableElement,
|
||||
);
|
||||
updateBoundPoint(
|
||||
element,
|
||||
"end",
|
||||
endBinding,
|
||||
changedElement as ExcalidrawBindableElement,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const doesNeedUpdate = (
|
||||
@@ -524,7 +507,7 @@ const getElligibleElementsForBindableElementAndWhere = (
|
||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
): SuggestedPointBinding[] => {
|
||||
return Scene.getScene(bindableElement)!
|
||||
.getNonDeletedElements()
|
||||
.getElements()
|
||||
.map((element) => {
|
||||
if (!isBindingElement(element, false)) {
|
||||
return null;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawCustomElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
@@ -32,13 +33,20 @@ import { Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isCustomElement,
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isCustomElement(element)) {
|
||||
return true;
|
||||
}
|
||||
if (element.type === "arrow") {
|
||||
return false;
|
||||
}
|
||||
@@ -166,6 +174,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "custom":
|
||||
const distance = distanceToBindableElement(args.element, args.point);
|
||||
return args.check(distance, args.threshold);
|
||||
case "freedraw": {
|
||||
@@ -199,6 +208,7 @@ export const distanceToBindableElement = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "custom":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
return distanceToDiamond(element, point);
|
||||
@@ -228,7 +238,8 @@ const distanceToRectangle = (
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement,
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawCustomElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
@@ -504,6 +515,7 @@ export const determineFocusDistance = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "custom":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
case "diamond":
|
||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
@@ -536,6 +548,7 @@ export const determineFocusPoint = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "custom":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
case "ellipse":
|
||||
@@ -586,6 +599,7 @@ const getSortedElementLineIntersections = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "custom":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
.flatMap((point, i) => {
|
||||
@@ -619,7 +633,8 @@ const getCorners = (
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawCustomElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
const hx = (scale * element.width) / 2;
|
||||
@@ -628,6 +643,7 @@ const getCorners = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "custom":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
GA.point(hx, -hy),
|
||||
@@ -770,7 +786,8 @@ export const findFocusPointForRectangulars = (
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawCustomElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
relativeDistance: number,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawCustomElement,
|
||||
} from "../element/types";
|
||||
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||
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
|
||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
||||
//
|
||||
|
||||
@@ -7,10 +7,9 @@ export const showSelectedShapeActions = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) =>
|
||||
Boolean(
|
||||
(!appState.viewModeEnabled &&
|
||||
appState.activeTool.type !== "custom" &&
|
||||
!appState.viewModeEnabled &&
|
||||
(appState.editingElement ||
|
||||
getSelectedElements(elements, appState).length ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser"))) ||
|
||||
getSelectedElements(elements, appState).length,
|
||||
appState.activeTool.type !== "eraser")),
|
||||
);
|
||||
|
||||
@@ -115,9 +115,6 @@ describe("textWysiwyg", () => {
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: text.id }],
|
||||
});
|
||||
|
||||
h.elements = [container, text];
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawCustomElement,
|
||||
} from "./types";
|
||||
|
||||
export const isGenericElement = (
|
||||
@@ -142,3 +143,7 @@ export const isBoundToContainer = (
|
||||
element !== null && isTextElement(element) && element.containerId !== null
|
||||
);
|
||||
};
|
||||
|
||||
export const isCustomElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is ExcalidrawCustomElement => element && element.type === "custom";
|
||||
|
||||
@@ -84,6 +84,9 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
scale: [number, number];
|
||||
}>;
|
||||
|
||||
export type ExcalidrawCustomElement = _ExcalidrawElementBase &
|
||||
Readonly<{ type: "custom"; customType: string }>;
|
||||
|
||||
export type InitializedExcalidrawImageElement = MarkNonNullable<
|
||||
ExcalidrawImageElement,
|
||||
"fileId"
|
||||
@@ -108,7 +111,8 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawCustomElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
@@ -134,7 +138,8 @@ export type ExcalidrawBindableElement =
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawCustomElement;
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
|
||||
@@ -6,7 +6,6 @@ export const LOAD_IMAGES_TIMEOUT = 500;
|
||||
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
||||
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
ExcalidrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../element/types";
|
||||
import {
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { getSceneVersion } from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../types";
|
||||
import {
|
||||
getFrame,
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
withBatchedUpdates,
|
||||
@@ -32,9 +30,7 @@ import {
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
getCollabServer,
|
||||
getSyncableElements,
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
} from "../data";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@@ -49,9 +45,12 @@ import {
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { createInverseContext } from "../../createInverseContext";
|
||||
import { t } from "../../i18n";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { isInvisiblySmallElement } from "../../element";
|
||||
import {
|
||||
encodeFilesForUpload,
|
||||
FileManager,
|
||||
@@ -70,45 +69,52 @@ import {
|
||||
import { decryptData } from "../../data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
export const collabDialogShownAtom = atom(false);
|
||||
export const isCollaboratingAtom = atom(false);
|
||||
|
||||
interface CollabState {
|
||||
modalIsShown: boolean;
|
||||
errorMessage: string;
|
||||
username: string;
|
||||
userState: UserIdleState;
|
||||
activeRoomLink: string;
|
||||
}
|
||||
|
||||
type CollabInstance = InstanceType<typeof Collab>;
|
||||
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
||||
|
||||
export interface CollabAPI {
|
||||
/** function so that we can access the latest value from stale callbacks */
|
||||
isCollaborating: () => boolean;
|
||||
username: CollabState["username"];
|
||||
userState: CollabState["userState"];
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
startCollaboration: CollabInstance["startCollaboration"];
|
||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: (username: string) => void;
|
||||
}
|
||||
|
||||
interface PublicProps {
|
||||
interface Props {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
onRoomClose?: () => void;
|
||||
}
|
||||
|
||||
type Props = PublicProps & { modalIsShown: boolean };
|
||||
const {
|
||||
Context: CollabContext,
|
||||
Consumer: CollabContextConsumer,
|
||||
Provider: CollabContextProvider,
|
||||
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
|
||||
|
||||
class Collab extends PureComponent<Props, CollabState> {
|
||||
export { CollabContext, CollabContextConsumer };
|
||||
|
||||
class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
portal: Portal;
|
||||
fileManager: FileManager;
|
||||
excalidrawAPI: Props["excalidrawAPI"];
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
|
||||
// marked as private to ensure we don't change it outside this class
|
||||
private _isCollaborating: boolean = false;
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<string, Collaborator>();
|
||||
@@ -116,8 +122,10 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
modalIsShown: false,
|
||||
errorMessage: "",
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
userState: UserIdleState.ACTIVE,
|
||||
activeRoomLink: "",
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
@@ -155,18 +163,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
|
||||
const collabAPI: CollabAPI = {
|
||||
isCollaborating: this.isCollaborating,
|
||||
onPointerUpdate: this.onPointerUpdate,
|
||||
startCollaboration: this.startCollaboration,
|
||||
syncElements: this.syncElements,
|
||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||
stopCollaboration: this.stopCollaboration,
|
||||
setUsername: this.setUsername,
|
||||
};
|
||||
|
||||
jotaiStore.set(collabAPIAtom, collabAPI);
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||
@@ -199,23 +195,19 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
}
|
||||
|
||||
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
|
||||
|
||||
private setIsCollaborating = (isCollaborating: boolean) => {
|
||||
jotaiStore.set(isCollaboratingAtom, isCollaborating);
|
||||
};
|
||||
isCollaborating = () => this._isCollaborating;
|
||||
|
||||
private onUnload = () => {
|
||||
this.destroySocketClient({ isUnload: true });
|
||||
};
|
||||
|
||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||
const syncableElements = getSyncableElements(
|
||||
const syncableElements = this.getSyncableElements(
|
||||
this.getSceneElementsIncludingDeleted(),
|
||||
);
|
||||
|
||||
if (
|
||||
this.isCollaborating() &&
|
||||
this._isCollaborating &&
|
||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||
!isSavedToFirebase(this.portal, syncableElements))
|
||||
) {
|
||||
@@ -240,7 +232,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly SyncableExcalidrawElement[],
|
||||
syncableElements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
try {
|
||||
const savedData = await saveToFirebase(
|
||||
@@ -259,36 +251,31 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
};
|
||||
|
||||
stopCollaboration = (keepRemoteState = true) => {
|
||||
openPortal = async () => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
return this.initializeSocketClient(null);
|
||||
};
|
||||
|
||||
closePortal = () => {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.queueSaveToFirebase.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
|
||||
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||
this.portal.socket.off(
|
||||
"connect_error",
|
||||
this.fallbackInitializationHandler,
|
||||
);
|
||||
}
|
||||
|
||||
if (!keepRemoteState) {
|
||||
LocalData.fileStorage.reset();
|
||||
this.destroySocketClient();
|
||||
} else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||
// hack to ensure that we prefer we disregard any new browser state
|
||||
// that could have been saved in other tabs while we were collaborating
|
||||
resetBrowserStateVersions();
|
||||
|
||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||
this.destroySocketClient();
|
||||
trackEvent("share", "room closed");
|
||||
|
||||
LocalData.fileStorage.reset();
|
||||
this.props.onRoomClose?.();
|
||||
|
||||
const elements = this.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
@@ -307,20 +294,20 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
this.fileManager.reset();
|
||||
if (!opts?.isUnload) {
|
||||
this.setIsCollaborating(false);
|
||||
this.setState({
|
||||
activeRoomLink: "",
|
||||
});
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
});
|
||||
this.setState({
|
||||
activeRoomLink: "",
|
||||
});
|
||||
this._isCollaborating = false;
|
||||
LocalData.resumeSave("collaboration");
|
||||
}
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
this.fileManager.reset();
|
||||
};
|
||||
|
||||
private fetchImageFilesFromFirebase = async (scene: {
|
||||
@@ -361,9 +348,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
};
|
||||
|
||||
private fallbackInitializationHandler: null | (() => any) = null;
|
||||
|
||||
startCollaboration = async (
|
||||
private initializeSocketClient = async (
|
||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||
): Promise<ImportedDataState | null> => {
|
||||
if (this.portal.socket) {
|
||||
@@ -386,23 +371,13 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
|
||||
this.setIsCollaborating(true);
|
||||
this._isCollaborating = true;
|
||||
LocalData.pauseSave("collaboration");
|
||||
|
||||
const { default: socketIOClient } = await import(
|
||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||
);
|
||||
|
||||
const fallbackInitializationHandler = () => {
|
||||
this.initializeRoom({
|
||||
roomLinkData: existingRoomLinkData,
|
||||
fetchScene: true,
|
||||
}).then((scene) => {
|
||||
scenePromise.resolve(scene);
|
||||
});
|
||||
};
|
||||
this.fallbackInitializationHandler = fallbackInitializationHandler;
|
||||
|
||||
try {
|
||||
const socketServerData = await getCollabServer();
|
||||
|
||||
@@ -415,8 +390,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
roomId,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
this.setState({ errorMessage: error.message });
|
||||
@@ -440,15 +413,18 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
commitToHistory: true,
|
||||
});
|
||||
|
||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||
this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
// initial SCENE_INIT message
|
||||
this.socketInitializationTimer = window.setTimeout(
|
||||
fallbackInitializationHandler,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
);
|
||||
this.socketInitializationTimer = window.setTimeout(() => {
|
||||
this.initializeRoom({
|
||||
roomLinkData: existingRoomLinkData,
|
||||
fetchScene: true,
|
||||
});
|
||||
scenePromise.resolve(null);
|
||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
||||
|
||||
// All socket listeners are moving to Portal
|
||||
this.portal.socket.on(
|
||||
@@ -553,12 +529,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
| { fetchScene: false; roomLinkData?: null }) => {
|
||||
clearTimeout(this.socketInitializationTimer!);
|
||||
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||
this.portal.socket.off(
|
||||
"connect_error",
|
||||
this.fallbackInitializationHandler,
|
||||
);
|
||||
}
|
||||
if (fetchScene && roomLinkData && this.portal.socket) {
|
||||
this.excalidrawAPI.resetScene();
|
||||
|
||||
@@ -596,8 +566,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
remoteElements = restoreElements(remoteElements, null);
|
||||
|
||||
const reconciledElements = _reconcileElements(
|
||||
localElements,
|
||||
remoteElements,
|
||||
@@ -703,17 +671,19 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
setCollaborators(sockets: string[]) {
|
||||
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||
new Map();
|
||||
for (const socketId of sockets) {
|
||||
if (this.collaborators.has(socketId)) {
|
||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||
} else {
|
||||
collaborators.set(socketId, {});
|
||||
this.setState((state) => {
|
||||
const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
|
||||
new Map();
|
||||
for (const socketId of sockets) {
|
||||
if (this.collaborators.has(socketId)) {
|
||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||
} else {
|
||||
collaborators.set(socketId, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawAPI.updateScene({ collaborators });
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawAPI.updateScene({ collaborators });
|
||||
});
|
||||
}
|
||||
|
||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||
@@ -742,6 +712,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
|
||||
onIdleStateChange = (userState: UserIdleState) => {
|
||||
this.setState({ userState });
|
||||
this.portal.broadcastIdleChange(userState);
|
||||
};
|
||||
|
||||
@@ -775,22 +746,18 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
queueSaveToFirebase = throttle(
|
||||
() => {
|
||||
if (this.portal.socketInitialized) {
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
{ leading: false },
|
||||
);
|
||||
queueSaveToFirebase = throttle(() => {
|
||||
if (this.portal.socketInitialized) {
|
||||
this.saveCollabRoomToFirebase(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
handleClose = () => {
|
||||
jotaiStore.set(collabDialogShownAtom, false);
|
||||
this.setState({ modalIsShown: false });
|
||||
};
|
||||
|
||||
setUsername = (username: string) => {
|
||||
@@ -802,10 +769,42 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
saveUsernameToLocalStorage(username);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { username, errorMessage, activeRoomLink } = this.state;
|
||||
onCollabButtonClick = () => {
|
||||
this.setState({
|
||||
modalIsShown: true,
|
||||
});
|
||||
};
|
||||
|
||||
const { modalIsShown } = this.props;
|
||||
isSyncableElement = (element: ExcalidrawElement) => {
|
||||
return element.isDeleted || !isInvisiblySmallElement(element);
|
||||
};
|
||||
|
||||
getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter((element) => this.isSyncableElement(element));
|
||||
|
||||
/** PRIVATE. Use `this.getContextValue()` instead. */
|
||||
private contextValue: CollabAPI | null = null;
|
||||
|
||||
/** Getter of context value. Returned object is stable. */
|
||||
getContextValue = (): CollabAPI => {
|
||||
if (!this.contextValue) {
|
||||
this.contextValue = {} as CollabAPI;
|
||||
}
|
||||
|
||||
this.contextValue.isCollaborating = this.isCollaborating;
|
||||
this.contextValue.username = this.state.username;
|
||||
this.contextValue.onPointerUpdate = this.onPointerUpdate;
|
||||
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
||||
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
||||
this.contextValue.syncElements = this.syncElements;
|
||||
this.contextValue.fetchImageFilesFromFirebase =
|
||||
this.fetchImageFilesFromFirebase;
|
||||
this.contextValue.setUsername = this.setUsername;
|
||||
return this.contextValue;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -815,8 +814,8 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
activeRoomLink={activeRoomLink}
|
||||
username={username}
|
||||
onUsernameChange={this.onUsernameChange}
|
||||
onRoomCreate={() => this.startCollaboration(null)}
|
||||
onRoomDestroy={this.stopCollaboration}
|
||||
onRoomCreate={this.openPortal}
|
||||
onRoomDestroy={this.closePortal}
|
||||
setErrorMessage={(errorMessage) => {
|
||||
this.setState({ errorMessage });
|
||||
}}
|
||||
@@ -829,6 +828,11 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
onClose={() => this.setState({ errorMessage: "" })}
|
||||
/>
|
||||
)}
|
||||
<CollabContextProvider
|
||||
value={{
|
||||
api: this.getContextValue(),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -836,7 +840,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
collab: InstanceType<typeof Collab>;
|
||||
collab: InstanceType<typeof CollabWrapper>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,11 +851,4 @@ if (
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
}
|
||||
|
||||
const _Collab: React.FC<PublicProps> = (props) => {
|
||||
const [collabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
return <Collab {...props} modalIsShown={collabDialogShown} />;
|
||||
};
|
||||
|
||||
export default _Collab;
|
||||
|
||||
export type TCollabClass = Collab;
|
||||
export default CollabWrapper;
|
||||
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
isSyncableElement,
|
||||
SocketUpdateData,
|
||||
SocketUpdateDataSource,
|
||||
} from "../data";
|
||||
import { SocketUpdateData, SocketUpdateDataSource } from "../data";
|
||||
|
||||
import { TCollabClass } from "./Collab";
|
||||
import CollabWrapper from "./CollabWrapper";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import {
|
||||
@@ -20,14 +16,14 @@ import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../data/encryption";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
collab: CollabWrapper;
|
||||
socket: SocketIOClient.Socket | null = null;
|
||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||
roomId: string | null = null;
|
||||
roomKey: string | null = null;
|
||||
broadcastedElementVersions: Map<string, number> = new Map();
|
||||
|
||||
constructor(collab: TCollabClass) {
|
||||
constructor(collab: CollabWrapper) {
|
||||
this.collab = collab;
|
||||
}
|
||||
|
||||
@@ -147,7 +143,7 @@ class Portal {
|
||||
!this.broadcastedElementVersions.has(element.id) ||
|
||||
element.version >
|
||||
this.broadcastedElementVersions.get(element.id)!) &&
|
||||
isSyncableElement(element)
|
||||
this.collab.isSyncableElement(element)
|
||||
) {
|
||||
acc.push({
|
||||
...element,
|
||||
|
||||
@@ -14,8 +14,6 @@ import { t } from "../../i18n";
|
||||
import "./RoomDialog.scss";
|
||||
import Stack from "../../components/Stack";
|
||||
import { AppState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { getFrame } from "../../utils";
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
@@ -97,10 +95,7 @@ const RoomDialog = ({
|
||||
title={t("roomDialog.button_startSession")}
|
||||
aria-label={t("roomDialog.button_startSession")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
onRoomCreate();
|
||||
}}
|
||||
onClick={onRoomCreate}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -165,10 +160,7 @@ const RoomDialog = ({
|
||||
title={t("roomDialog.button_stopSession")}
|
||||
aria-label={t("roomDialog.button_stopSession")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room closed");
|
||||
onRoomDestroy();
|
||||
}}
|
||||
onClick={onRoomDestroy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { decompressData } from "../../data/encode";
|
||||
import { encryptData, decryptData } from "../../data/encryption";
|
||||
import { MIME_TYPES } from "../../constants";
|
||||
import { reconcileElements } from "../collab/reconciliation";
|
||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -128,18 +127,7 @@ const decryptElements = async (
|
||||
return JSON.parse(decodedData);
|
||||
};
|
||||
|
||||
class FirebaseSceneVersionCache {
|
||||
private static cache = new WeakMap<SocketIOClient.Socket, number>();
|
||||
static get = (socket: SocketIOClient.Socket) => {
|
||||
return FirebaseSceneVersionCache.cache.get(socket);
|
||||
};
|
||||
static set = (
|
||||
socket: SocketIOClient.Socket,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
|
||||
};
|
||||
}
|
||||
const firebaseSceneVersionCache = new WeakMap<SocketIOClient.Socket, number>();
|
||||
|
||||
export const isSavedToFirebase = (
|
||||
portal: Portal,
|
||||
@@ -148,7 +136,7 @@ export const isSavedToFirebase = (
|
||||
if (portal.socket && portal.roomId && portal.roomKey) {
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
|
||||
return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
|
||||
return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
|
||||
}
|
||||
// if no room exists, consider the room saved so that we don't unnecessarily
|
||||
// prevent unload (there's nothing we could do at that point anyway)
|
||||
@@ -193,7 +181,7 @@ export const saveFilesToFirebase = async ({
|
||||
|
||||
const createFirebaseSceneDocument = async (
|
||||
firebase: ResolutionType<typeof loadFirestore>,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
elements: readonly ExcalidrawElement[],
|
||||
roomKey: string,
|
||||
) => {
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
@@ -209,7 +197,7 @@ const createFirebaseSceneDocument = async (
|
||||
|
||||
export const saveToFirebase = async (
|
||||
portal: Portal,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const { roomId, roomKey, socket } = portal;
|
||||
@@ -241,18 +229,18 @@ export const saveToFirebase = async (
|
||||
transaction.set(docRef, sceneDocument);
|
||||
|
||||
return {
|
||||
elements,
|
||||
sceneVersion: sceneDocument.sceneVersion,
|
||||
reconciledElements: null,
|
||||
};
|
||||
}
|
||||
|
||||
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
||||
const prevElements = getSyncableElements(
|
||||
await decryptElements(prevDocData, roomKey),
|
||||
);
|
||||
const prevElements = await decryptElements(prevDocData, roomKey);
|
||||
|
||||
const reconciledElements = getSyncableElements(
|
||||
reconcileElements(elements, prevElements, appState),
|
||||
const reconciledElements = reconcileElements(
|
||||
elements,
|
||||
prevElements,
|
||||
appState,
|
||||
);
|
||||
|
||||
const sceneDocument = await createFirebaseSceneDocument(
|
||||
@@ -263,14 +251,14 @@ export const saveToFirebase = async (
|
||||
|
||||
transaction.update(docRef, sceneDocument);
|
||||
return {
|
||||
elements,
|
||||
reconciledElements,
|
||||
sceneVersion: sceneDocument.sceneVersion,
|
||||
};
|
||||
});
|
||||
|
||||
FirebaseSceneVersionCache.set(socket, savedData.elements);
|
||||
firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
|
||||
|
||||
return { reconciledElements: savedData.reconciledElements };
|
||||
return savedData;
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
@@ -287,12 +275,10 @@ export const loadFromFirebase = async (
|
||||
return null;
|
||||
}
|
||||
const storedScene = doc.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
await decryptElements(storedScene, roomKey),
|
||||
);
|
||||
const elements = await decryptElements(storedScene, roomKey);
|
||||
|
||||
if (socket) {
|
||||
FirebaseSceneVersionCache.set(socket, elements);
|
||||
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
|
||||
}
|
||||
|
||||
return restoreElements(elements, null);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { serializeAsJSON } from "../../data/json";
|
||||
import { restore } from "../../data/restore";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { isInvisiblySmallElement } from "../../element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "../../element/typeChecks";
|
||||
import { ExcalidrawElement, FileId } from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
@@ -18,35 +17,10 @@ import {
|
||||
UserIdleState,
|
||||
} from "../../types";
|
||||
import { bytesToHexString } from "../../utils";
|
||||
import {
|
||||
DELETED_ELEMENT_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
ROOM_ID_BYTES,
|
||||
} from "../app_constants";
|
||||
import { FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES } from "../app_constants";
|
||||
import { encodeFilesForUpload } from "./FileManager";
|
||||
import { saveFilesToFirebase } from "./firebase";
|
||||
|
||||
export type SyncableExcalidrawElement = ExcalidrawElement & {
|
||||
_brand: "SyncableExcalidrawElement";
|
||||
};
|
||||
|
||||
export const isSyncableElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is SyncableExcalidrawElement => {
|
||||
if (element.isDeleted) {
|
||||
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return !isInvisiblySmallElement(element);
|
||||
};
|
||||
|
||||
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter((element) =>
|
||||
isSyncableElement(element),
|
||||
) as SyncableExcalidrawElement[];
|
||||
|
||||
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
|
||||
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
|
||||
|
||||
@@ -134,16 +108,9 @@ export type SocketUpdateData =
|
||||
_brand: "socketUpdateData";
|
||||
};
|
||||
|
||||
const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
|
||||
|
||||
export const isCollaborationLink = (link: string) => {
|
||||
const hash = new URL(link).hash;
|
||||
return RE_COLLAB_LINK.test(hash);
|
||||
};
|
||||
|
||||
export const getCollaborationLinkData = (link: string) => {
|
||||
const hash = new URL(link).hash;
|
||||
const match = hash.match(RE_COLLAB_LINK);
|
||||
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
||||
if (match && match[2].length !== 22) {
|
||||
window.alert(t("alerts.invalidEncryptionKey"));
|
||||
return null;
|
||||
|
||||
@@ -32,25 +32,3 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.plus-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.6em 0.7em;
|
||||
border-radius: var(--space-factor);
|
||||
color: var(--color-primary) !important;
|
||||
margin: 8px;
|
||||
text-decoration: none !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ErrorDialog } from "../components/ErrorDialog";
|
||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||
import {
|
||||
APP_NAME,
|
||||
COOKIES,
|
||||
EVENT,
|
||||
TITLE_TIMEOUT,
|
||||
URL_HASH_KEYS,
|
||||
VERSION_TIMEOUT,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data/blob";
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { Language, t } from "../i18n";
|
||||
import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
@@ -45,26 +45,20 @@ import {
|
||||
STORAGE_KEYS,
|
||||
SYNC_BROWSER_TABS_TIMEOUT,
|
||||
} from "./app_constants";
|
||||
import Collab, {
|
||||
import CollabWrapper, {
|
||||
CollabAPI,
|
||||
collabAPIAtom,
|
||||
collabDialogShownAtom,
|
||||
isCollaboratingAtom,
|
||||
} from "./collab/Collab";
|
||||
CollabContext,
|
||||
CollabContextConsumer,
|
||||
} from "./collab/CollabWrapper";
|
||||
import { LanguageList } from "./components/LanguageList";
|
||||
import {
|
||||
exportToBackend,
|
||||
getCollaborationLinkData,
|
||||
isCollaborationLink,
|
||||
loadScene,
|
||||
} from "./data";
|
||||
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
|
||||
import {
|
||||
getLibraryItemsFromStorage,
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
||||
import { restoreAppState, RestoredDataState } from "../data/restore";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { shield } from "../components/icons";
|
||||
|
||||
@@ -78,18 +72,14 @@ import { loadFilesFromFirebase } from "./data/firebase";
|
||||
import { LocalData } from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import clsx from "clsx";
|
||||
import { Provider, useAtom } from "jotai";
|
||||
import { jotaiStore, useAtomWithInitialValue } from "../jotai";
|
||||
import { reconcileElements } from "./collab/reconciliation";
|
||||
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
||||
|
||||
const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||
COOKIES.AUTH_STATE_COOKIE,
|
||||
);
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {},
|
||||
languageUtils: {
|
||||
formatLanguageCode: (langCode: Language["code"]) => langCode,
|
||||
isWhitelisted: () => true,
|
||||
},
|
||||
checkWhitelist: false,
|
||||
});
|
||||
|
||||
const initializeScene = async (opts: {
|
||||
@@ -179,7 +169,7 @@ const initializeScene = async (opts: {
|
||||
|
||||
if (roomLinkData) {
|
||||
return {
|
||||
scene: await opts.collabAPI.startCollaboration(roomLinkData),
|
||||
scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
|
||||
isExternalScene: true,
|
||||
id: roomLinkData.roomId,
|
||||
key: roomLinkData.roomKey,
|
||||
@@ -197,7 +187,7 @@ const initializeScene = async (opts: {
|
||||
return { scene: null, isExternalScene: false };
|
||||
};
|
||||
|
||||
const PlusLPLinkJSX = (
|
||||
const PlusLinkJSX = (
|
||||
<p style={{ direction: "ltr", unicodeBidi: "embed" }}>
|
||||
Introducing Excalidraw+
|
||||
<br />
|
||||
@@ -211,17 +201,6 @@ const PlusLPLinkJSX = (
|
||||
</p>
|
||||
);
|
||||
|
||||
const PlusAppLinkJSX = (
|
||||
<a
|
||||
href={`${process.env.REACT_APP_PLUS_APP}/#excalidraw-redirect`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="plus-button"
|
||||
>
|
||||
Go to Excalidraw+
|
||||
</a>
|
||||
);
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
let currentLangCode = languageDetector.detect() || defaultLang.code;
|
||||
@@ -251,16 +230,7 @@ const ExcalidrawWrapper = () => {
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
return isCollaborationLink(window.location.href);
|
||||
});
|
||||
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
getInitialLibraryItems: getLibraryItemsFromStorage,
|
||||
});
|
||||
const collabAPI = useContext(CollabContext)?.api;
|
||||
|
||||
useEffect(() => {
|
||||
if (!collabAPI || !excalidrawAPI) {
|
||||
@@ -331,46 +301,33 @@ const ExcalidrawWrapper = () => {
|
||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||
}
|
||||
}
|
||||
|
||||
data.scene.libraryItems = getLibraryItemsFromStorage();
|
||||
};
|
||||
|
||||
initializeScene({ collabAPI }).then(async (data) => {
|
||||
initializeScene({ collabAPI }).then((data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
|
||||
initialStatePromiseRef.current.promise.resolve({
|
||||
...data.scene,
|
||||
// at this point the state may have already been updated (e.g. when
|
||||
// collaborating, we may have received updates from other clients)
|
||||
appState: restoreAppState(
|
||||
data.scene?.appState,
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
elements: reconcileElements(
|
||||
data.scene?.elements || [],
|
||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
});
|
||||
initialStatePromiseRef.current.promise.resolve(data.scene);
|
||||
});
|
||||
|
||||
const onHashChange = async (event: HashChangeEvent) => {
|
||||
const onHashChange = (event: HashChangeEvent) => {
|
||||
event.preventDefault();
|
||||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||
if (!libraryUrlTokens) {
|
||||
if (
|
||||
collabAPI.isCollaborating() &&
|
||||
!isCollaborationLink(window.location.href)
|
||||
) {
|
||||
collabAPI.stopCollaboration(false);
|
||||
}
|
||||
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
||||
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||
const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
|
||||
if (libraryUrl) {
|
||||
// If hash changed and it contains library url, import it and replace
|
||||
// the url to its previous state (important in case of collaboration
|
||||
// and similar).
|
||||
// Using history API won't trigger another hashchange.
|
||||
window.history.replaceState({}, "", event.oldURL);
|
||||
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
|
||||
} else {
|
||||
initializeScene({ collabAPI }).then((data) => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null),
|
||||
commitToHistory: true,
|
||||
appState: restoreAppState(data.scene.appState, null),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -398,8 +355,6 @@ const ExcalidrawWrapper = () => {
|
||||
setLangCode(langCode);
|
||||
excalidrawAPI.updateScene({
|
||||
...localDataState,
|
||||
});
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: getLibraryItemsFromStorage(),
|
||||
});
|
||||
collabAPI.setUsername(username || "");
|
||||
@@ -511,17 +466,19 @@ const ExcalidrawWrapper = () => {
|
||||
if (excalidrawAPI) {
|
||||
let didChange = false;
|
||||
|
||||
let pendingImageElement = appState.pendingImageElement;
|
||||
const elements = excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (
|
||||
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
|
||||
) {
|
||||
const newElement = newElementWith(element, { status: "saved" });
|
||||
if (newElement !== element) {
|
||||
didChange = true;
|
||||
didChange = true;
|
||||
const newEl = newElementWith(element, { status: "saved" });
|
||||
if (pendingImageElement === element) {
|
||||
pendingImageElement = newEl;
|
||||
}
|
||||
return newElement;
|
||||
return newEl;
|
||||
}
|
||||
return element;
|
||||
});
|
||||
@@ -529,6 +486,9 @@ const ExcalidrawWrapper = () => {
|
||||
if (didChange) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements,
|
||||
appState: {
|
||||
pendingImageElement,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -572,16 +532,17 @@ const ExcalidrawWrapper = () => {
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: isExcalidrawPlusSignedUser ? "21ch" : "23ch",
|
||||
width: "24ch",
|
||||
fontSize: "0.7em",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
|
||||
{/* <GitHubCorner theme={appState.theme} dir={document.dir} /> */}
|
||||
{/* FIXME remove after 2021-05-20 */}
|
||||
{PlusLinkJSX}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -633,14 +594,12 @@ const ExcalidrawWrapper = () => {
|
||||
marginTop: isTinyDevice ? 16 : undefined,
|
||||
marginLeft: "auto",
|
||||
marginRight: isTinyDevice ? "auto" : undefined,
|
||||
padding: isExcalidrawPlusSignedUser ? undefined : "4px 2px",
|
||||
border: isExcalidrawPlusSignedUser
|
||||
? undefined
|
||||
: "1px dashed #aaa",
|
||||
padding: "4px 2px",
|
||||
border: "1px dashed #aaa",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
{isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
|
||||
{PlusLinkJSX}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -672,19 +631,23 @@ const ExcalidrawWrapper = () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||
};
|
||||
|
||||
const onRoomClose = useCallback(() => {
|
||||
LocalData.fileStorage.reset();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
className={clsx("excalidraw-app", {
|
||||
"is-collaborating": isCollaborating,
|
||||
"is-collaborating": collabAPI?.isCollaborating(),
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
ref={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
onCollabButtonClick={() => setCollabDialogShown(true)}
|
||||
isCollaborating={isCollaborating}
|
||||
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
||||
isCollaborating={collabAPI?.isCollaborating()}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
@@ -718,7 +681,12 @@ const ExcalidrawWrapper = () => {
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
/>
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{excalidrawAPI && (
|
||||
<CollabWrapper
|
||||
excalidrawAPI={excalidrawAPI}
|
||||
onRoomClose={onRoomClose}
|
||||
/>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<ErrorDialog
|
||||
message={errorMessage}
|
||||
@@ -732,9 +700,9 @@ const ExcalidrawWrapper = () => {
|
||||
const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider unstable_createStore={() => jotaiStore}>
|
||||
<CollabContextConsumer>
|
||||
<ExcalidrawWrapper />
|
||||
</Provider>
|
||||
</CollabContextConsumer>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
2
src/global.d.ts
vendored
2
src/global.d.ts
vendored
@@ -35,8 +35,6 @@ type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
type Merge<M, N> = Omit<M, keyof N> & N;
|
||||
|
||||
/** utility type to assert that the second type is a subtype of the first type.
|
||||
|
||||
@@ -86,7 +86,7 @@ export const setLanguage = async (lang: Language) => {
|
||||
currentLangData = {};
|
||||
} else {
|
||||
currentLangData = await import(
|
||||
/* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json`
|
||||
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
25
src/jotai.ts
25
src/jotai.ts
@@ -1,27 +1,4 @@
|
||||
import { unstable_createStore, useAtom, WritableAtom } from "jotai";
|
||||
import { useLayoutEffect } from "react";
|
||||
import { unstable_createStore } from "jotai";
|
||||
|
||||
export const jotaiScope = Symbol();
|
||||
export const jotaiStore = unstable_createStore();
|
||||
|
||||
export const useAtomWithInitialValue = <
|
||||
T extends unknown,
|
||||
A extends WritableAtom<T, T>,
|
||||
>(
|
||||
atom: A,
|
||||
initialValue: T | (() => T),
|
||||
) => {
|
||||
const [value, setValue] = useAtom(atom);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (typeof initialValue === "function") {
|
||||
// @ts-ignore
|
||||
setValue(initialValue());
|
||||
} else {
|
||||
setValue(initialValue);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [value, setValue] as const;
|
||||
};
|
||||
|
||||
@@ -119,8 +119,7 @@
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": ""
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "إعادة تعيين اللوحة",
|
||||
@@ -173,6 +172,7 @@
|
||||
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
|
||||
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
||||
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)",
|
||||
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
|
||||
"errorAddingToLibrary": "تعذر إضافة العنصر للمكتبة",
|
||||
"errorRemovingFromLibrary": "تعذر إزالة العنصر من المكتبة",
|
||||
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
||||
@@ -189,8 +189,7 @@
|
||||
"fileTooBig": "الملف كبير جداً. الحد الأقصى المسموح به للحجم هو {{maxSize}}.",
|
||||
"svgImageInsertError": "تعذر إدراج صورة SVG. يبدو أن ترميز SVG غير صحيح.",
|
||||
"invalidSVGString": "SVG غير صالح.",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"cannotResolveCollabServer": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "تحديد",
|
||||
@@ -342,8 +341,7 @@
|
||||
"post": "وهو ما يعني باختصار أنه يمكن لأي شخص استخدامها دون قيود."
|
||||
},
|
||||
"noteItems": "يجب أن يكون لكل عنصر مكتبة اسمه الخاص حتى يكون قابلاً للتصفية. سيتم تضمين عناصر المكتبة التالية:",
|
||||
"atleastOneLibItem": "يرجى تحديد عنصر مكتبة واحد على الأقل للبدء",
|
||||
"republishWarning": ""
|
||||
"atleastOneLibItem": "يرجى تحديد عنصر مكتبة واحد على الأقل للبدء"
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "تم إرسال المكتبة",
|
||||
|
||||
@@ -119,8 +119,7 @@
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": ""
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Нулиране на платно",
|
||||
@@ -173,6 +172,7 @@
|
||||
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
|
||||
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
|
||||
"collabStopOverridePrompt": "Прекратяването на сесията ще презапише предишната, локално запазена, рисунка. Сигурни ли сте?\n\n(Ако искате да продължите с локалната рисунка, просто затворете таба на браузъра.)",
|
||||
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
|
||||
@@ -189,8 +189,7 @@
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"cannotResolveCollabServer": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Селекция",
|
||||
@@ -342,8 +341,7 @@
|
||||
"post": ""
|
||||
},
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
"atleastOneLibItem": ""
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "",
|
||||
|
||||
@@ -119,8 +119,7 @@
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": ""
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "",
|
||||
@@ -173,6 +172,7 @@
|
||||
"uploadedSecurly": "",
|
||||
"loadSceneOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorLoadingLibrary": "",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "",
|
||||
@@ -189,8 +189,7 @@
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"cannotResolveCollabServer": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "",
|
||||
@@ -342,8 +341,7 @@
|
||||
"post": ""
|
||||
},
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
"atleastOneLibItem": ""
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "",
|
||||
|
||||
@@ -119,8 +119,7 @@
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": ""
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"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.",
|
||||
"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ó).",
|
||||
"errorLoadingLibrary": "S'ha produït un error en carregar la biblioteca de tercers.",
|
||||
"errorAddingToLibrary": "No s'ha pogut afegir l'element a 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?",
|
||||
@@ -189,8 +189,7 @@
|
||||
"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.",
|
||||
"invalidSVGString": "SVG no vàlid.",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"cannotResolveCollabServer": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selecció",
|
||||
@@ -342,8 +341,7 @@
|
||||
"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:",
|
||||
"atleastOneLibItem": "Si us plau, seleccioneu si més no un element de la biblioteca per a començar",
|
||||
"republishWarning": ""
|
||||
"atleastOneLibItem": "Si us plau, seleccioneu si més no un element de la biblioteca per a començar"
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "Biblioteca enviada",
|
||||
|
||||
@@ -119,8 +119,7 @@
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": ""
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "",
|
||||
@@ -173,6 +172,7 @@
|
||||
"uploadedSecurly": "",
|
||||
"loadSceneOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorLoadingLibrary": "",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "",
|
||||
@@ -189,8 +189,7 @@
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"cannotResolveCollabServer": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Výběr",
|
||||
@@ -342,8 +341,7 @@
|
||||
"post": ""
|
||||
},
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
"atleastOneLibItem": ""
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "",
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
{
|
||||
"labels": {
|
||||
"paste": "Indsæt",
|
||||
"pasteCharts": "Indsæt diagrammer",
|
||||
"pasteCharts": "",
|
||||
"selectAll": "Marker alle",
|
||||
"multiSelect": "Tilføj element til markering",
|
||||
"moveCanvas": "Flyt lærred",
|
||||
"multiSelect": "",
|
||||
"moveCanvas": "",
|
||||
"cut": "Klip",
|
||||
"copy": "Kopier",
|
||||
"copyAsPng": "Kopier til klippebord som PNG",
|
||||
"copyAsSvg": "Kopier til klippebord som SVG",
|
||||
"copyText": "Kopiér til udklipsholder som tekst",
|
||||
"bringForward": "Flyt fremad",
|
||||
"sendToBack": "Placer bagest",
|
||||
"bringToFront": "Placer forrest",
|
||||
"sendBackward": "Send bagud",
|
||||
"copyText": "",
|
||||
"bringForward": "",
|
||||
"sendToBack": "",
|
||||
"bringToFront": "",
|
||||
"sendBackward": "",
|
||||
"delete": "Fjern",
|
||||
"copyStyles": "Kopier stil",
|
||||
"pasteStyles": "Indsæt stil",
|
||||
"copyStyles": "",
|
||||
"pasteStyles": "",
|
||||
"stroke": "Linje",
|
||||
"background": "Baggrund",
|
||||
"fill": "Udfyld",
|
||||
"fill": "",
|
||||
"strokeWidth": "Linjebredde",
|
||||
"strokeStyle": "Linjeform",
|
||||
"strokeStyle_solid": "Solid",
|
||||
"strokeStyle_dashed": "Stiplet",
|
||||
"strokeStyle_dotted": "Prikket",
|
||||
"sloppiness": "Sjuskethed",
|
||||
"opacity": "Gennemsigtighed",
|
||||
"textAlign": "Tekstjustering",
|
||||
"edges": "Kanter",
|
||||
"sharp": "Skarp",
|
||||
"round": "Rund",
|
||||
"arrowheads": "Pilehoveder",
|
||||
"arrowhead_none": "Ingen",
|
||||
"strokeStyle": "",
|
||||
"strokeStyle_solid": "",
|
||||
"strokeStyle_dashed": "",
|
||||
"strokeStyle_dotted": "",
|
||||
"sloppiness": "",
|
||||
"opacity": "",
|
||||
"textAlign": "",
|
||||
"edges": "",
|
||||
"sharp": "",
|
||||
"round": "",
|
||||
"arrowheads": "",
|
||||
"arrowhead_none": "",
|
||||
"arrowhead_arrow": "Pil",
|
||||
"arrowhead_bar": "Bjælke",
|
||||
"arrowhead_dot": "Prik",
|
||||
"arrowhead_triangle": "Trekant",
|
||||
"fontSize": "Skriftstørrelse",
|
||||
"fontFamily": "Skrifttypefamilie",
|
||||
"onlySelected": "Kun valgte",
|
||||
"withBackground": "Baggrund",
|
||||
"exportEmbedScene": "Indlejr scene",
|
||||
"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.",
|
||||
"addWatermark": "Tilføj \"Lavet med Excalidraw\"",
|
||||
"handDrawn": "Hånd-tegnet",
|
||||
"normal": "Normal",
|
||||
"code": "Kode",
|
||||
"small": "Lille",
|
||||
"medium": "Mellem",
|
||||
"large": "Stor",
|
||||
"veryLarge": "Meget stor",
|
||||
"solid": "Solid",
|
||||
"hachure": "Skravering",
|
||||
"crossHatch": "Krydsskravering",
|
||||
"thin": "Tynd",
|
||||
"arrowhead_bar": "",
|
||||
"arrowhead_dot": "",
|
||||
"arrowhead_triangle": "",
|
||||
"fontSize": "",
|
||||
"fontFamily": "",
|
||||
"onlySelected": "",
|
||||
"withBackground": "",
|
||||
"exportEmbedScene": "",
|
||||
"exportEmbedScene_details": "",
|
||||
"addWatermark": "",
|
||||
"handDrawn": "",
|
||||
"normal": "",
|
||||
"code": "",
|
||||
"small": "",
|
||||
"medium": "",
|
||||
"large": "",
|
||||
"veryLarge": "",
|
||||
"solid": "",
|
||||
"hachure": "",
|
||||
"crossHatch": "",
|
||||
"thin": "",
|
||||
"bold": "Fed",
|
||||
"left": "Venstre",
|
||||
"center": "Centrere",
|
||||
"right": "Højre",
|
||||
"extraBold": "Extra fed",
|
||||
"architect": "Arkitekt",
|
||||
"artist": "Kunstner",
|
||||
"cartoonist": "Tegneserietegner",
|
||||
"architect": "",
|
||||
"artist": "",
|
||||
"cartoonist": "",
|
||||
"fileTitle": "Filnavn",
|
||||
"colorPicker": "Farvevælger",
|
||||
"canvasColors": "Brugt på lærred",
|
||||
"canvasBackground": "Lærredsbaggrund",
|
||||
"drawingCanvas": "Tegnelærred",
|
||||
"layers": "Lag",
|
||||
"canvasBackground": "",
|
||||
"drawingCanvas": "",
|
||||
"layers": "",
|
||||
"actions": "",
|
||||
"language": "Sprog",
|
||||
"liveCollaboration": "Direkte samarbejde",
|
||||
@@ -119,8 +119,7 @@
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": ""
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "",
|
||||
@@ -133,10 +132,10 @@
|
||||
"copyPngToClipboard": "Kopier PNG til klippebord",
|
||||
"scale": "",
|
||||
"save": "",
|
||||
"saveAs": "Gem som",
|
||||
"load": "Indlæs",
|
||||
"getShareableLink": "Lav et delbart link",
|
||||
"close": "Luk",
|
||||
"saveAs": "",
|
||||
"load": "",
|
||||
"getShareableLink": "",
|
||||
"close": "",
|
||||
"selectLanguage": "Vælg sprog",
|
||||
"scrollBackToContent": "Scroll tilbage til indhold",
|
||||
"zoomIn": "Zoom ind",
|
||||
@@ -147,32 +146,33 @@
|
||||
"edit": "Rediger",
|
||||
"undo": "Fortryd",
|
||||
"redo": "Gendan",
|
||||
"resetLibrary": "Nulstil bibliotek",
|
||||
"resetLibrary": "",
|
||||
"createNewRoom": "Opret nyt rum",
|
||||
"fullScreen": "Fuld skærm",
|
||||
"darkMode": "Mørk tilstand",
|
||||
"lightMode": "Lys baggrund",
|
||||
"zenMode": "Zentilstand",
|
||||
"exitZenMode": "Stop zentilstand",
|
||||
"cancel": "Annuller",
|
||||
"clear": "Ryd",
|
||||
"remove": "Fjern",
|
||||
"publishLibrary": "Publicér",
|
||||
"submit": "Gem",
|
||||
"confirm": "Bekræft"
|
||||
"zenMode": "",
|
||||
"exitZenMode": "",
|
||||
"cancel": "",
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Dette vil rydde hele lærredet. Er du sikker?",
|
||||
"couldNotCreateShareableLink": "Kunne ikke oprette delbart link.",
|
||||
"couldNotCreateShareableLinkTooBig": "Kunne ikke oprette delbart link: scenen er for stor",
|
||||
"couldNotLoadInvalidFile": "Kunne ikke indlæse ugyldig fil",
|
||||
"importBackendFailed": "Import fra backend mislykkedes.",
|
||||
"clearReset": "",
|
||||
"couldNotCreateShareableLink": "",
|
||||
"couldNotCreateShareableLinkTooBig": "",
|
||||
"couldNotLoadInvalidFile": "",
|
||||
"importBackendFailed": "",
|
||||
"cannotExportEmptyCanvas": "",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"decryptFailed": "",
|
||||
"uploadedSecurly": "",
|
||||
"loadSceneOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorLoadingLibrary": "",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "",
|
||||
@@ -189,8 +189,7 @@
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"cannotResolveCollabServer": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "",
|
||||
@@ -342,8 +341,7 @@
|
||||
"post": ""
|
||||
},
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
"atleastOneLibItem": ""
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user