mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-26 23:54:20 +01:00
Compare commits
104 Commits
aakansha-p
...
close_canv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c96b0404ba | ||
|
|
790c9fd02e | ||
|
|
357266e9ab | ||
|
|
0bbb4535cf | ||
|
|
d201d0be1b | ||
|
|
5662c5141d | ||
|
|
044614dcf3 | ||
|
|
9ec15989ab | ||
|
|
08aafcd248 | ||
|
|
ea5602457f | ||
|
|
3c58d19d45 | ||
|
|
fcfcdebc99 | ||
|
|
aa97c074a7 | ||
|
|
d65d2c5279 | ||
|
|
6d40039f08 | ||
|
|
f4e10c93e1 | ||
|
|
82c6df0e1f | ||
|
|
c37bd59ddd | ||
|
|
198a5e3b53 | ||
|
|
a78e1fa99b | ||
|
|
fc5db9248c | ||
|
|
ebf64036fd | ||
|
|
6271a031a3 | ||
|
|
78da4c075e | ||
|
|
f1cf28a84e | ||
|
|
3b9290831a | ||
|
|
bec34f2d57 | ||
|
|
07839f8d20 | ||
|
|
8068d1f853 | ||
|
|
92c7d3257f | ||
|
|
a8a5e7b6ff | ||
|
|
45a4a00b69 | ||
|
|
436e539d3a | ||
|
|
ff19167063 | ||
|
|
3fc531ed6e | ||
|
|
6f55c00814 | ||
|
|
a7eb6e1168 | ||
|
|
641bbdd2da | ||
|
|
42b0f7a614 | ||
|
|
c11e3818ac | ||
|
|
4b6aa5c53b | ||
|
|
ebd0408d7d | ||
|
|
f4fefbcee8 | ||
|
|
11b8cc2caa | ||
|
|
6bebfe63be | ||
|
|
91ab7f36e2 | ||
|
|
5ee8e8249c | ||
|
|
49c6bdd520 | ||
|
|
198800136e | ||
|
|
178ee04d82 | ||
|
|
18cdafbcbe | ||
|
|
286e9a1524 | ||
|
|
bac76778ce | ||
|
|
f28f7ffb6e | ||
|
|
12e8cc853f | ||
|
|
81108bf580 | ||
|
|
23030a15f2 | ||
|
|
4ef7cb7365 | ||
|
|
5cc3f7db80 | ||
|
|
5c42cb5be4 | ||
|
|
004d3180b5 | ||
|
|
c12119278a | ||
|
|
4d628844de | ||
|
|
946a209927 | ||
|
|
811437724b | ||
|
|
9dcde502aa | ||
|
|
d3106495b2 | ||
|
|
891ac82447 | ||
|
|
354976e08e | ||
|
|
5c73c5813c | ||
|
|
3a0b6fb41b | ||
|
|
37d513ad59 | ||
|
|
46624cc953 | ||
|
|
0d23c8dd76 | ||
|
|
51ef4cd97b | ||
|
|
b558d19d37 | ||
|
|
b8fb6580ef | ||
|
|
6730eb41c2 | ||
|
|
87c42cb327 | ||
|
|
8cfd05aa95 | ||
|
|
3ed8271344 | ||
|
|
73515b5a83 | ||
|
|
63d3da9a54 | ||
|
|
215fb5e357 | ||
|
|
886177816b | ||
|
|
7d29351d66 | ||
|
|
c0047269c1 | ||
|
|
793b69e592 | ||
|
|
e0a449aa40 | ||
|
|
d5a270f643 | ||
|
|
d126d04d17 | ||
|
|
153ca6a7c6 | ||
|
|
2618ac9f6e | ||
|
|
f64fd80493 | ||
|
|
a884351137 | ||
|
|
e546a85a8d | ||
|
|
29e630086c | ||
|
|
a82165cb50 | ||
|
|
4dc0159a05 | ||
|
|
458787d1d7 | ||
|
|
815977296e | ||
|
|
58f840aa93 | ||
|
|
422149c249 | ||
|
|
a7cbe68ae8 |
26
.github/workflows/autorelease-excalidraw.yml
vendored
Normal file
26
.github/workflows/autorelease-excalidraw.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Auto release @excalidraw/excalidraw-next
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-next:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn autorelease
|
||||
14
README.md
14
README.md
@@ -102,6 +102,20 @@ These instructions will get you a copy of the project up and running on your loc
|
||||
git clone https://github.com/excalidraw/excalidraw.git
|
||||
```
|
||||
|
||||
#### Install the dependencies
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
#### Start the server
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
||||
|
||||
#### Commands
|
||||
|
||||
| Command | Description |
|
||||
|
||||
12
package.json
12
package.json
@@ -29,12 +29,13 @@
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.16.4",
|
||||
"clsx": "1.1.1",
|
||||
"firebase": "8.3.2",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.0",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.1.22",
|
||||
"open-color": "1.8.0",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "0.4.7",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
@@ -43,8 +44,8 @@
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.3.1",
|
||||
"sass": "1.32.8",
|
||||
"roughjs": "4.4.1",
|
||||
"sass": "1.32.10",
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.2.4"
|
||||
},
|
||||
@@ -54,7 +55,7 @@
|
||||
"@types/lodash.throttle": "4.1.6",
|
||||
"@types/pako": "1.0.1",
|
||||
"@types/resize-observer-browser": "0.1.5",
|
||||
"eslint-config-prettier": "8.1.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"firebase-tools": "9.9.0",
|
||||
"husky": "4.3.8",
|
||||
@@ -103,6 +104,7 @@
|
||||
"test:other": "yarn prettier --list-different",
|
||||
"test:typecheck": "tsc",
|
||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||
"test": "yarn test:app"
|
||||
"test": "yarn test:app",
|
||||
"autorelease": "node scripts/autorelease.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,15 +107,16 @@
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
body {
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
@@ -125,6 +126,7 @@
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap; /* added line */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LoadingMessage {
|
||||
@@ -149,6 +151,21 @@
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
-moz-user-select: auto;
|
||||
-ms-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
51
scripts/autorelease.js
Normal file
51
scripts/autorelease.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const fs = require("fs");
|
||||
const { exec, execSync } = require("child_process");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
|
||||
const getShortCommitHash = () => {
|
||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// get files changed between prev and head commit
|
||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
if (error || stderr) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const changedFiles = stdout.trim().split("\n");
|
||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||
|
||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
||||
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
|
||||
});
|
||||
|
||||
if (!excalidrawPackageFiles.length) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// update package.json
|
||||
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
|
||||
pkg.name = "@excalidraw/excalidraw-next";
|
||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||
|
||||
// update readme
|
||||
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
|
||||
publish();
|
||||
});
|
||||
@@ -37,6 +37,7 @@ const crowdinMap = {
|
||||
"uk-UA": "en-uk",
|
||||
"zh-CN": "en-zhcn",
|
||||
"zh-TW": "en-zhtw",
|
||||
"lv-LV": "en-lv",
|
||||
};
|
||||
|
||||
const flags = {
|
||||
@@ -74,6 +75,7 @@ const flags = {
|
||||
"uk-UA": "🇺🇦",
|
||||
"zh-CN": "🇨🇳",
|
||||
"zh-TW": "🇹🇼",
|
||||
"lv-LV": "🇱🇻",
|
||||
};
|
||||
|
||||
const languages = {
|
||||
@@ -111,6 +113,7 @@ const languages = {
|
||||
"uk-UA": "Українська",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "繁體中文",
|
||||
"lv-LV": "Latviešu",
|
||||
};
|
||||
|
||||
const percentages = fs.readFileSync(
|
||||
|
||||
@@ -2,18 +2,20 @@ import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { Library } from "../data/library";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
Library.loadLibrary().then((items) => {
|
||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||
app.library.loadLibrary().then((items) => {
|
||||
app.library.saveLibrary([
|
||||
...items,
|
||||
selectedElements.map(deepCopyElement),
|
||||
]);
|
||||
});
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getDefaultAppState } from "../appState";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
@@ -260,3 +261,27 @@ export const actionZoomToFit = register({
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
theme: value || (appState.theme === "light" ? "dark" : "light"),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.theme}
|
||||
onChange={(theme) => {
|
||||
updateData(theme);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
});
|
||||
|
||||
@@ -50,7 +50,6 @@ export const actionCopyAsSvg = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.canvas,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
@@ -89,7 +88,6 @@ export const actionCopyAsPng = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.canvas,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,8 @@ import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { supported } from "browser-fs-access";
|
||||
import { supported as fsSupported } from "browser-fs-access";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
@@ -40,14 +41,12 @@ export const actionChangeExportBackground = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportBackground}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
<CheckboxItem
|
||||
checked={appState.exportBackground}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.withBackground")}
|
||||
</label>
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -60,21 +59,15 @@ export const actionChangeExportEmbedScene = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label style={{ display: "flex" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
<CheckboxItem
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.exportEmbedScene")}
|
||||
<Tooltip
|
||||
label={t("labels.exportEmbedScene_details")}
|
||||
position="above"
|
||||
long={true}
|
||||
>
|
||||
<div className="TooltipIcon">{questionCircle}</div>
|
||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||
<div className="Tooltip-icon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -87,14 +80,12 @@ export const actionChangeShouldAddWatermark = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.shouldAddWatermark}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
<CheckboxItem
|
||||
checked={appState.shouldAddWatermark}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.addWatermark")}
|
||||
</label>
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -130,11 +121,10 @@ export const actionSaveScene = register({
|
||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
type="icon"
|
||||
icon={save}
|
||||
title={t("buttons.save")}
|
||||
aria-label={t("buttons.save")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-button"
|
||||
/>
|
||||
@@ -166,7 +156,7 @@ export const actionSaveAsScene = register({
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
hidden={!supported}
|
||||
hidden={!fsSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { isBindingElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
perform: (elements, appState, _, { canvas }) => {
|
||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
@@ -51,19 +51,19 @@ export const actionFinalize = register({
|
||||
|
||||
let newElements = elements;
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
window.document.activeElement.blur();
|
||||
focusContainer();
|
||||
}
|
||||
|
||||
const multiPointElement = appState.multiElement
|
||||
? appState.multiElement
|
||||
: appState.editingElement?.type === "draw"
|
||||
: appState.editingElement?.type === "freedraw"
|
||||
? appState.editingElement
|
||||
: null;
|
||||
|
||||
if (multiPointElement) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
multiPointElement.type !== "draw" &&
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
@@ -86,7 +86,7 @@ export const actionFinalize = register({
|
||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||
if (
|
||||
multiPointElement.type === "line" ||
|
||||
multiPointElement.type === "draw"
|
||||
multiPointElement.type === "freedraw"
|
||||
) {
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
@@ -118,22 +118,24 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (!appState.elementLocked && appState.elementType !== "draw") {
|
||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.elementLocked && appState.elementType !== "draw") ||
|
||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
}
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
elementType:
|
||||
(appState.elementLocked || appState.elementType === "draw") &&
|
||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
||||
multiPointElement
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
@@ -145,14 +147,14 @@ export const actionFinalize = register({
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.elementLocked &&
|
||||
appState.elementType !== "draw"
|
||||
appState.elementType !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
},
|
||||
commitToHistory: appState.elementType === "draw",
|
||||
commitToHistory: appState.elementType === "freedraw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||
import { updateBoundElements } from "../element/binding";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
@@ -114,7 +114,7 @@ const flipElement = (
|
||||
const originalAngle = normalizeAngle(element.angle);
|
||||
|
||||
let finalOffsetX = 0;
|
||||
if (isLinearElement(element)) {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
finalOffsetX =
|
||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
||||
element.width;
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { undo, redo } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { SceneHistory, HistoryEntry } from "../history";
|
||||
import History, { HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { isWindows, KEYS } from "../keys";
|
||||
@@ -59,7 +59,7 @@ const writeData = (
|
||||
return { commitToHistory };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: SceneHistory) => Action;
|
||||
type ActionCreator = (history: History) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
|
||||
@@ -70,7 +70,10 @@ export const actionFullScreen = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
perform: (_elements, appState) => {
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.showHelpDialog) {
|
||||
focusContainer();
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
@@ -13,4 +14,6 @@ export const actionToggleStats = register({
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
contextItemLabel: "stats.title",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
actionZoomOut,
|
||||
actionResetZoom,
|
||||
actionZoomToFit,
|
||||
actionToggleTheme,
|
||||
} from "./actionCanvas";
|
||||
|
||||
export { actionFinalize } from "./actionFinalize";
|
||||
|
||||
@@ -9,10 +9,16 @@ import {
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppProps, AppState } from "../types";
|
||||
import { MODES } from "../constants";
|
||||
import Library from "../data/library";
|
||||
|
||||
// This is the <App> component, but for now we don't care about anything but its
|
||||
// `canvas` state.
|
||||
type App = { canvas: HTMLCanvasElement | null; props: AppProps };
|
||||
type App = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
focusContainer: () => void;
|
||||
props: AppProps;
|
||||
library: Library;
|
||||
};
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
@@ -51,7 +57,7 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
actions.forEach((action) => this.registerAction(action));
|
||||
}
|
||||
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
const data = Object.values(this.actions)
|
||||
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
|
||||
|
||||
@@ -57,7 +57,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
stats: [],
|
||||
stats: [getShortcutKey("Alt+/")],
|
||||
addToLibrary: [],
|
||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||
flipVertical: [getShortcutKey("Shift+V")],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import Library from "../data/library";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
@@ -15,11 +16,17 @@ export type ActionResult =
|
||||
}
|
||||
| false;
|
||||
|
||||
type AppAPI = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
focusContainer(): void;
|
||||
library: Library;
|
||||
};
|
||||
|
||||
type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: { canvas: HTMLCanvasElement | null },
|
||||
app: AppAPI,
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
@@ -45,6 +52,7 @@ export type ActionName =
|
||||
| "changeBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
@@ -91,7 +99,8 @@ export type ActionName =
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode";
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme";
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
@@ -105,7 +114,7 @@ export interface Action {
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
keyTest?: (
|
||||
event: KeyboardEvent,
|
||||
event: React.KeyboardEvent | KeyboardEvent,
|
||||
appState: AppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => boolean;
|
||||
@@ -120,6 +129,7 @@ export interface Action {
|
||||
export interface ActionsManagerInterface {
|
||||
actions: Record<ActionName, Action>;
|
||||
registerAction: (action: Action) => void;
|
||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||
executeAction: (action: Action) => void;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getSelectedElements } from "./scene";
|
||||
import { AppState } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { canvasToBlob } from "./data/blob";
|
||||
import { EXPORT_DATA_TYPES } from "./constants";
|
||||
|
||||
type ElementsClipboard = {
|
||||
@@ -152,8 +151,7 @@ export const parseClipboard = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
|
||||
const blob = await canvasToBlob(canvas);
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ "image/png": blob }),
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
canHaveArrowheads,
|
||||
getTargetElements,
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
hasStrokeStyle,
|
||||
hasStrokeWidth,
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
@@ -53,10 +54,17 @@ export const SelectedShapeActions = ({
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStroke(elementType) ||
|
||||
targetElements.some((element) => hasStroke(element.type))) && (
|
||||
{(hasStrokeWidth(elementType) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(elementType === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(elementType) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeWidth")}
|
||||
{renderAction("changeStrokeStyle")}
|
||||
{renderAction("changeSloppiness")}
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { AppState } from "../types";
|
||||
import { DarkModeToggle } from "./DarkModeToggle";
|
||||
|
||||
export const BackgroundPickerAndDarkModeToggle = ({
|
||||
appState,
|
||||
@@ -16,14 +15,10 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
||||
}) => (
|
||||
<div style={{ display: "flex" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
{showThemeBtn && (
|
||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||
{appState.fileHandle && (
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.theme}
|
||||
onChange={(theme) => {
|
||||
setAppState({ theme });
|
||||
}}
|
||||
/>
|
||||
{actionManager.renderAction("saveScene")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
53
src/components/Card.scss
Normal file
53
src/components/Card.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
max-width: 290px;
|
||||
|
||||
margin: 1em;
|
||||
|
||||
text-align: center;
|
||||
|
||||
.Card-icon {
|
||||
font-size: 2.6em;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
padding: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--card-color);
|
||||
color: $oc-white;
|
||||
|
||||
svg {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.Card-details {
|
||||
font-size: 0.96em;
|
||||
min-height: 90px;
|
||||
padding: 0 1em;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
& .Card-button.ToolIcon_type_button {
|
||||
height: 2.5rem;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.3em;
|
||||
background-color: var(--card-color);
|
||||
&:hover {
|
||||
background-color: var(--card-color-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--card-color-darkest);
|
||||
}
|
||||
.ToolIcon__label {
|
||||
color: $oc-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/components/Card.tsx
Normal file
20
src/components/Card.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import OpenColor from "open-color";
|
||||
|
||||
import "./Card.scss";
|
||||
|
||||
export const Card: React.FC<{
|
||||
color: keyof OpenColor;
|
||||
}> = ({ children, color }) => {
|
||||
return (
|
||||
<div
|
||||
className="Card"
|
||||
style={{
|
||||
["--card-color" as any]: OpenColor[color][7],
|
||||
["--card-color-darker" as any]: OpenColor[color][8],
|
||||
["--card-color-darkest" as any]: OpenColor[color][9],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
85
src/components/CheckboxItem.scss
Normal file
85
src/components/CheckboxItem.scss
Normal file
@@ -0,0 +1,85 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Checkbox {
|
||||
margin: 3px 0.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover:not(.is-checked) .Checkbox-box {
|
||||
box-shadow: 0 0 0 2px #{$oc-blue-4};
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
.Checkbox-box {
|
||||
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.Checkbox-box {
|
||||
background-color: fade-out($oc-blue-1, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
.Checkbox-box {
|
||||
background-color: #{$oc-blue-1};
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:hover .Checkbox-box {
|
||||
background-color: #{$oc-blue-2};
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox-box {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
|
||||
margin: 0 1em;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
box-shadow: 0 0 0 2px #{$oc-blue-7};
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
color: #{$oc-blue-7};
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px #{$oc-blue-7};
|
||||
}
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Tooltip-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/components/CheckboxItem.tsx
Normal file
26
src/components/CheckboxItem.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import clsx from "clsx";
|
||||
import { checkIcon } from "./icons";
|
||||
|
||||
import "./CheckboxItem.scss";
|
||||
|
||||
export const CheckboxItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}> = ({ children, checked, onChange }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("Checkbox", { "is-checked": checked })}
|
||||
onClick={(event) => {
|
||||
onChange(!checked);
|
||||
((event.currentTarget as HTMLDivElement).querySelector(
|
||||
".Checkbox-box",
|
||||
) as HTMLButtonElement).focus();
|
||||
}}
|
||||
>
|
||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||
{checkIcon}
|
||||
</button>
|
||||
<div className="Checkbox-label">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -160,7 +160,7 @@
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
width: 12ch; /* length of `transparent` + 1 */
|
||||
width: 11ch; /* length of `transparent` */
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
background-color: var(--input-bg-color);
|
||||
|
||||
@@ -115,6 +115,7 @@ const Picker = ({
|
||||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
export type Appearence = "light" | "dark";
|
||||
|
||||
@@ -12,31 +13,19 @@ export const DarkModeToggle = (props: {
|
||||
onChange: (value: Appearence) => void;
|
||||
title?: string;
|
||||
}) => {
|
||||
const title = props.title
|
||||
? props.title
|
||||
: props.value === "dark"
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode");
|
||||
const title =
|
||||
props.title ||
|
||||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
|
||||
|
||||
return (
|
||||
<label
|
||||
className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
|
||||
data-testid="toggle-dark-mode"
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||
title={title}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
||||
type="checkbox"
|
||||
onChange={(event) =>
|
||||
props.onChange(event.target.checked ? "dark" : "light")
|
||||
}
|
||||
checked={props.value === "dark"}
|
||||
aria-label={title}
|
||||
/>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||
</div>
|
||||
</label>
|
||||
aria-label={title}
|
||||
onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
|
||||
data-testid="toggle-dark-mode"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
@@ -8,6 +8,7 @@ import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const Dialog = (props: {
|
||||
children: React.ReactNode;
|
||||
@@ -16,8 +17,10 @@ export const Dialog = (props: {
|
||||
onCloseRequest(): void;
|
||||
title: React.ReactNode;
|
||||
autofocus?: boolean;
|
||||
theme?: AppState["theme"];
|
||||
}) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandNode) {
|
||||
@@ -65,19 +68,25 @@ export const Dialog = (props: {
|
||||
return focusableElements ? Array.from(focusableElements) : [];
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
(lastActiveElement as HTMLElement).focus();
|
||||
props.onCloseRequest();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={clsx("Dialog", props.className)}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={props.small ? 550 : 800}
|
||||
onCloseRequest={props.onCloseRequest}
|
||||
onCloseRequest={onClose}
|
||||
theme={props.theme}
|
||||
>
|
||||
<Island ref={setIslandNode}>
|
||||
<h2 id="dialog-title" className="Dialog__title">
|
||||
<span className="Dialog__titleContent">{props.title}</span>
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={props.onCloseRequest}
|
||||
onClick={onClose}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useIsMobile() ? back : close}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
export const ErrorDialog = ({
|
||||
message,
|
||||
@@ -11,6 +12,7 @@ export const ErrorDialog = ({
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||
const excalidrawContainer = useExcalidrawContainer();
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
@@ -18,7 +20,9 @@ export const ErrorDialog = ({
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
|
||||
excalidrawContainer?.focus();
|
||||
}, [onClose, excalidrawContainer]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -28,33 +28,6 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ExportDialog__name {
|
||||
grid-column: project-name;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.TextInput {
|
||||
height: calc(1rem - 3px);
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&--readonly {
|
||||
background: none;
|
||||
border: none;
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
width: auto;
|
||||
max-width: 200px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
.ExportDialog {
|
||||
display: flex;
|
||||
@@ -84,4 +57,62 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ExportDialog--json {
|
||||
.ExportDialog-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
justify-items: center;
|
||||
row-gap: 2em;
|
||||
|
||||
@media (max-width: 460px) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
.Card-details {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName {
|
||||
width: fit-content;
|
||||
margin: 1em auto;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.TextInput {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName-label {
|
||||
margin: 0.625em 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.ExportDialog-imageExportButton {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin: 0 0.2em;
|
||||
|
||||
border-radius: 1rem;
|
||||
background-color: var(--button-color);
|
||||
box-shadow: 0 3px 5px -1px rgb(0 0 0 / 28%), 0 6px 10px 0 rgb(0 0 0 / 14%);
|
||||
|
||||
font-family: Cascadia;
|
||||
font-size: 1.8em;
|
||||
color: $oc-white;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-color-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-color-darkest);
|
||||
box-shadow: 0 3px 5px -1px rgb(0 0 0 / 20%), 0 6px 10px 0 rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.excalidraw {
|
||||
.FixedSideContainer {
|
||||
--margin: 0.25rem;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -10,9 +9,9 @@
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top {
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
right: var(--margin);
|
||||
left: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
right: var(--space-factor);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -23,16 +22,16 @@
|
||||
|
||||
/* TODO: if these are used, make sure to implement RTL support
|
||||
.FixedSideContainer_side_left {
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
left: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_right {
|
||||
right: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
right: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
z-index: 3;
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -153,7 +153,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.draw")}
|
||||
label={t("toolBar.freedraw")}
|
||||
shortcuts={["Shift+P", "7"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
@@ -231,6 +231,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("labels.viewMode")}
|
||||
shortcuts={[getShortcutKey("Alt+R")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.toggleTheme")}
|
||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("stats.title")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
<Column>
|
||||
|
||||
@@ -23,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
return t("hints.linearElementMulti");
|
||||
}
|
||||
|
||||
if (elementType === "draw") {
|
||||
if (elementType === "freedraw") {
|
||||
return t("hints.freeDraw");
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ function Picker<T>({
|
||||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,16 +6,20 @@ import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useIsMobile } from "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas, getExportSize } from "../scene/export";
|
||||
import { AppState } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import "./ExportDialog.scss";
|
||||
import { clipboard, exportFile, link } from "./icons";
|
||||
import { clipboard, exportImage } from "./icons";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
import { supported as fsSupported } from "browser-fs-access";
|
||||
import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
|
||||
const scales = [1, 2, 3];
|
||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
||||
|
||||
@@ -52,7 +56,30 @@ export type ExportCB = (
|
||||
scale?: number,
|
||||
) => void;
|
||||
|
||||
const ExportModal = ({
|
||||
const ExportButton: React.FC<{
|
||||
color: keyof OpenColor;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
shade?: number;
|
||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
|
||||
return (
|
||||
<button
|
||||
className="ExportDialog-imageExportButton"
|
||||
style={{
|
||||
["--button-color" as any]: OpenColor[color][shade],
|
||||
["--button-color-darker" as any]: OpenColor[color][shade + 1],
|
||||
["--button-color-darkest" as any]: OpenColor[color][shade + 2],
|
||||
}}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
@@ -60,7 +87,6 @@ const ExportModal = ({
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportToBackend,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
@@ -69,7 +95,6 @@ const ExportModal = ({
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend?: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
}) => {
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
@@ -133,98 +158,103 @@ const ExportModal = ({
|
||||
<div className="ExportDialog__preview" ref={previewRef} />
|
||||
{supportsContextFilters &&
|
||||
actionManager.renderAction("exportWithDarkMode")}
|
||||
<Stack.Col gap={2} align="center">
|
||||
<div className="ExportDialog__actions">
|
||||
<Stack.Row gap={2}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
label="PNG"
|
||||
title={t("buttons.exportToPng")}
|
||||
aria-label={t("buttons.exportToPng")}
|
||||
onClick={() => onExportToPng(exportedElements, scale)}
|
||||
/>
|
||||
<ToolButton
|
||||
type="button"
|
||||
label="SVG"
|
||||
title={t("buttons.exportToSvg")}
|
||||
aria-label={t("buttons.exportToSvg")}
|
||||
onClick={() => onExportToSvg(exportedElements, scale)}
|
||||
/>
|
||||
{probablySupportsClipboardBlob && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={clipboard}
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
aria-label={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||
/>
|
||||
)}
|
||||
{onExportToBackend && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={link}
|
||||
title={t("buttons.getShareableLink")}
|
||||
aria-label={t("buttons.getShareableLink")}
|
||||
onClick={() => onExportToBackend(exportedElements)}
|
||||
/>
|
||||
)}
|
||||
</Stack.Row>
|
||||
<div className="ExportDialog__name">
|
||||
{actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<Stack.Row gap={2}>
|
||||
{scales.map((s) => {
|
||||
const [width, height] = getExportSize(
|
||||
exportedElements,
|
||||
exportPadding,
|
||||
shouldAddWatermark,
|
||||
s,
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
)} ${s}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={s}
|
||||
size="s"
|
||||
type="radio"
|
||||
icon={`${s}x`}
|
||||
name="export-canvas-scale"
|
||||
title={scaleButtonTitle}
|
||||
aria-label={scaleButtonTitle}
|
||||
id="export-canvas-scale"
|
||||
checked={s === scale}
|
||||
onChange={() => setScale(s)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack.Row>
|
||||
</div>
|
||||
{actionManager.renderAction("changeExportBackground")}
|
||||
{someElementIsSelected && (
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportSelected}
|
||||
onChange={(event) =>
|
||||
setExportSelected(event.currentTarget.checked)
|
||||
}
|
||||
/>{" "}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
|
||||
// dunno why this is needed, but when the items wrap it creates
|
||||
// an overflow
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{actionManager.renderAction("changeExportBackground")}
|
||||
{someElementIsSelected && (
|
||||
<CheckboxItem
|
||||
checked={exportSelected}
|
||||
onChange={(checked) => setExportSelected(checked)}
|
||||
>
|
||||
{t("labels.onlySelected")}
|
||||
</label>
|
||||
</div>
|
||||
</CheckboxItem>
|
||||
)}
|
||||
{actionManager.renderAction("changeExportEmbedScene")}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
|
||||
<Stack.Row gap={2} justifyContent={"center"}>
|
||||
{scales.map((_scale) => {
|
||||
const [width, height] = getExportSize(
|
||||
exportedElements,
|
||||
exportPadding,
|
||||
shouldAddWatermark,
|
||||
_scale,
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
)} ${_scale}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={_scale}
|
||||
size="s"
|
||||
type="radio"
|
||||
icon={`${_scale}x`}
|
||||
name="export-canvas-scale"
|
||||
title={scaleButtonTitle}
|
||||
aria-label={scaleButtonTitle}
|
||||
id="export-canvas-scale"
|
||||
checked={_scale === scale}
|
||||
onChange={() => setScale(_scale)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack.Row>
|
||||
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: ".6em 0",
|
||||
}}
|
||||
>
|
||||
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
||||
<ExportButton
|
||||
color="indigo"
|
||||
title={t("buttons.exportToPng")}
|
||||
aria-label={t("buttons.exportToPng")}
|
||||
onClick={() => onExportToPng(exportedElements, scale)}
|
||||
>
|
||||
PNG
|
||||
</ExportButton>
|
||||
<ExportButton
|
||||
color="red"
|
||||
title={t("buttons.exportToSvg")}
|
||||
aria-label={t("buttons.exportToSvg")}
|
||||
onClick={() => onExportToSvg(exportedElements, scale)}
|
||||
>
|
||||
SVG
|
||||
</ExportButton>
|
||||
{probablySupportsClipboardBlob && (
|
||||
<ExportButton
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||
color="gray"
|
||||
shade={7}
|
||||
>
|
||||
{clipboard}
|
||||
</ExportButton>
|
||||
)}
|
||||
{actionManager.renderAction("changeExportEmbedScene")}
|
||||
{actionManager.renderAction("changeShouldAddWatermark")}
|
||||
</Stack.Col>
|
||||
</Stack.Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExportDialog = ({
|
||||
export const ImageExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
@@ -232,7 +262,6 @@ export const ExportDialog = ({
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportToBackend,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
@@ -241,14 +270,11 @@ export const ExportDialog = ({
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend?: ExportCB;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
triggerButton.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -257,17 +283,16 @@ export const ExportDialog = ({
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
data-testid="export-button"
|
||||
icon={exportFile}
|
||||
data-testid="image-export-button"
|
||||
icon={exportImage}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.export")}
|
||||
ref={triggerButton}
|
||||
title={t("buttons.exportImage")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<ExportModal
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
|
||||
<ImageExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
exportPadding={exportPadding}
|
||||
@@ -275,7 +300,6 @@ export const ExportDialog = ({
|
||||
onExportToPng={onExportToPng}
|
||||
onExportToSvg={onExportToSvg}
|
||||
onExportToClipboard={onExportToClipboard}
|
||||
onExportToBackend={onExportToBackend}
|
||||
onCloseRequest={handleClose}
|
||||
/>
|
||||
</Dialog>
|
||||
@@ -2,7 +2,6 @@
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
backdrop-filter: saturate(100%) blur(10px);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: 4px;
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
|
||||
117
src/components/JSONExportDialog.tsx
Normal file
117
src/components/JSONExportDialog.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from "react";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { actionSaveAsScene } from "../actions/actionExport";
|
||||
import { Card } from "./Card";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
import { supported as fsSupported } from "browser-fs-access";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scale?: number,
|
||||
) => void;
|
||||
|
||||
const JSONExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
actionManager,
|
||||
onExportToBackend,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToBackend?: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="ExportDialog ExportDialog--json">
|
||||
<div className="ExportDialog-cards">
|
||||
<Card color="lime">
|
||||
<div className="Card-icon">{exportToFileIcon}</div>
|
||||
<h2>{t("exportDialog.disk_title")}</h2>
|
||||
<div className="Card-details">
|
||||
{t("exportDialog.disk_details")}
|
||||
{!fsSupported && actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.disk_button")}
|
||||
aria-label={t("exportDialog.disk_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveAsScene);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
{onExportToBackend && (
|
||||
<Card color="pink">
|
||||
<div className="Card-icon">{link}</div>
|
||||
<h2>{t("exportDialog.link_title")}</h2>
|
||||
<div className="Card-details">{t("exportDialog.link_details")}</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => onExportToBackend(elements)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const JSONExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
actionManager,
|
||||
onExportToBackend,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToBackend?: ExportCB;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
data-testid="json-export-button"
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.export")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
onExportToBackend={onExportToBackend}
|
||||
onCloseRequest={handleClose}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -40,50 +40,17 @@
|
||||
.layer-ui__wrapper {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
.encrypted-icon {
|
||||
position: relative;
|
||||
margin-inline-start: 15px;
|
||||
&__top-right {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--space-factor);
|
||||
color: $oc-green-9;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__github-corner {
|
||||
top: 0;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
&-right {
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.zen-mode-transition {
|
||||
@@ -105,12 +72,16 @@
|
||||
transform: translate(-999px, 0);
|
||||
}
|
||||
|
||||
:root[dir="ltr"] &.App-menu_bottom--transition-left {
|
||||
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||
transform: translate(-92px, 0);
|
||||
}
|
||||
:root[dir="rtl"] &.App-menu_bottom--transition-left {
|
||||
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||
transform: translate(92px, 0);
|
||||
}
|
||||
|
||||
&.layer-ui__wrapper__footer-left--transition-bottom {
|
||||
transform: translate(0, 92px);
|
||||
}
|
||||
}
|
||||
|
||||
.disable-zen-mode {
|
||||
@@ -137,5 +108,16 @@
|
||||
transition-delay: 0.8s;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-center {
|
||||
pointer-events: none;
|
||||
& > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
.layer-ui__wrapper__footer-left,
|
||||
.layer-ui__wrapper__footer-right {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import { Library } from "../data/library";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
@@ -29,11 +28,10 @@ import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ExportDialog } from "./ExportDialog";
|
||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { GitHubCorner } from "./GitHubCorner";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { exportFile, load, shield, trash } from "./icons";
|
||||
import { exportFile, load, trash } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./LayerUI.scss";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
@@ -47,6 +45,8 @@ import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -68,10 +68,14 @@ interface LayerUIProps {
|
||||
appState: AppState,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
) => void;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const useOnClickOutside = (
|
||||
@@ -103,7 +107,7 @@ const useOnClickOutside = (
|
||||
};
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
library,
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
@@ -111,8 +115,11 @@ const LibraryMenuItems = ({
|
||||
setAppState,
|
||||
setLibraryItems,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}: {
|
||||
library: LibraryItems;
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem;
|
||||
onRemoveFromLibrary: (index: number) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
@@ -120,9 +127,12 @@ const LibraryMenuItems = ({
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
setLibraryItems: (library: LibraryItems) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
|
||||
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
|
||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
||||
const rows = [];
|
||||
@@ -140,7 +150,7 @@ const LibraryMenuItems = ({
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={() => {
|
||||
importLibraryFromJSON()
|
||||
importLibraryFromJSON(library)
|
||||
.then(() => {
|
||||
// Close and then open to get the libraries updated
|
||||
setAppState({ isLibraryOpen: false });
|
||||
@@ -152,7 +162,7 @@ const LibraryMenuItems = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!!library.length && (
|
||||
{!!libraryItems.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
@@ -161,7 +171,7 @@ const LibraryMenuItems = ({
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportFile}
|
||||
onClick={() => {
|
||||
saveLibraryAsJSON()
|
||||
saveLibraryAsJSON(library)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
@@ -176,8 +186,9 @@ const LibraryMenuItems = ({
|
||||
icon={trash}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("alerts.resetLibrary"))) {
|
||||
Library.resetLibrary();
|
||||
library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -186,7 +197,7 @@ const LibraryMenuItems = ({
|
||||
<a
|
||||
href={`https://libraries.excalidraw.com?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`}
|
||||
}&referrer=${referrer}&useHash=true&token=${id}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
@@ -201,13 +212,13 @@ const LibraryMenuItems = ({
|
||||
const shouldAddPendingElements: boolean =
|
||||
pendingElements.length > 0 &&
|
||||
!addedPendingElements &&
|
||||
y + x >= library.length;
|
||||
y + x >= libraryItems.length;
|
||||
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
||||
|
||||
children.push(
|
||||
<Stack.Col key={x}>
|
||||
<LibraryUnit
|
||||
elements={library[y + x]}
|
||||
elements={libraryItems[y + x]}
|
||||
pendingElements={
|
||||
shouldAddPendingElements ? pendingElements : undefined
|
||||
}
|
||||
@@ -215,7 +226,7 @@ const LibraryMenuItems = ({
|
||||
onClick={
|
||||
shouldAddPendingElements
|
||||
? onAddToLibrary.bind(null, pendingElements)
|
||||
: onInsertShape.bind(null, library[y + x])
|
||||
: onInsertShape.bind(null, libraryItems[y + x])
|
||||
}
|
||||
/>
|
||||
</Stack.Col>,
|
||||
@@ -242,6 +253,9 @@ const LibraryMenu = ({
|
||||
onAddToLibrary,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}: {
|
||||
pendingElements: LibraryItem;
|
||||
onClickOutside: (event: MouseEvent) => void;
|
||||
@@ -249,6 +263,9 @@ const LibraryMenu = ({
|
||||
onAddToLibrary: () => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useOnClickOutside(ref, (event) => {
|
||||
@@ -274,7 +291,7 @@ const LibraryMenu = ({
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
Library.loadLibrary().then((items) => {
|
||||
library.loadLibrary().then((items) => {
|
||||
setLibraryItems(items);
|
||||
setIsLoading("ready");
|
||||
}),
|
||||
@@ -286,24 +303,33 @@ const LibraryMenu = ({
|
||||
return () => {
|
||||
clearTimeout(loadingTimerRef.current!);
|
||||
};
|
||||
}, []);
|
||||
}, [library]);
|
||||
|
||||
const removeFromLibrary = useCallback(async (indexToRemove) => {
|
||||
const items = await Library.loadLibrary();
|
||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||
Library.saveLibrary(nextItems);
|
||||
setLibraryItems(nextItems);
|
||||
}, []);
|
||||
const removeFromLibrary = useCallback(
|
||||
async (indexToRemove) => {
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[library, setAppState],
|
||||
);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem) => {
|
||||
const items = await Library.loadLibrary();
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems = [...items, elements];
|
||||
onAddToLibrary();
|
||||
Library.saveLibrary(nextItems);
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary],
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
|
||||
return loadingState === "preloading" ? null : (
|
||||
@@ -314,7 +340,7 @@ const LibraryMenu = ({
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuItems
|
||||
library={libraryItems}
|
||||
libraryItems={libraryItems}
|
||||
onRemoveFromLibrary={removeFromLibrary}
|
||||
onAddToLibrary={addToLibrary}
|
||||
onInsertShape={onInsertShape}
|
||||
@@ -322,6 +348,9 @@ const LibraryMenu = ({
|
||||
setAppState={setAppState}
|
||||
setLibraryItems={setLibraryItems}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
id={id}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
@@ -343,62 +372,27 @@ const LayerUI = ({
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
onExportToBackend,
|
||||
renderTopRightUI,
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderEncryptedIcon = () => (
|
||||
<a
|
||||
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
||||
"zen-mode-visibility--hidden": zenModeEnabled,
|
||||
})}
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
|
||||
const renderExportDialog = () => {
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createExporter = (type: ExportType): ExportCB => async (
|
||||
exportedElements,
|
||||
scale,
|
||||
) => {
|
||||
if (canvas) {
|
||||
await exportCanvas(type, exportedElements, appState, canvas, {
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
scale,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExportDialog
|
||||
<JSONExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={createExporter("png")}
|
||||
onExportToSvg={createExporter("svg")}
|
||||
onExportToClipboard={createExporter("clipboard")}
|
||||
onExportToBackend={
|
||||
onExportToBackend
|
||||
? (elements) => {
|
||||
@@ -411,6 +405,45 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderImageExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createExporter = (type: ExportType): ExportCB => async (
|
||||
exportedElements,
|
||||
scale,
|
||||
) => {
|
||||
await exportCanvas(type, exportedElements, appState, {
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
scale,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={createExporter("png")}
|
||||
onExportToSvg={createExporter("svg")}
|
||||
onExportToClipboard={createExporter("clipboard")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Separator = () => {
|
||||
return <div style={{ width: ".625em" }} />;
|
||||
};
|
||||
|
||||
const renderViewModeCanvasActions = () => {
|
||||
return (
|
||||
<Section
|
||||
@@ -424,9 +457,8 @@ const LayerUI = ({
|
||||
<Island padding={2} style={{ zIndex: 1 }}>
|
||||
<Stack.Col gap={4}>
|
||||
<Stack.Row gap={1} justifyContent="space-between">
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{renderExportDialog()}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
@@ -445,11 +477,12 @@ const LayerUI = ({
|
||||
<Island padding={2} style={{ zIndex: 1 }}>
|
||||
<Stack.Col gap={4}>
|
||||
<Stack.Row gap={1} justifyContent="space-between">
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{renderExportDialog()}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
<Separator />
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
<Separator />
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
@@ -517,6 +550,9 @@ const LayerUI = ({
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
id={id}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
@@ -570,24 +606,30 @@ const LayerUI = ({
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
<UserList
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-right": zenModeEnabled,
|
||||
})}
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||
{
|
||||
"transition-right": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{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", clientId)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
<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", clientId)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
{renderTopRightUI?.(isMobile, appState)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
);
|
||||
@@ -595,61 +637,61 @@ const LayerUI = ({
|
||||
|
||||
const renderBottomAppMenu = () => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
|
||||
"App-menu_bottom--transition-left": zenModeEnabled,
|
||||
})}
|
||||
<footer
|
||||
role="contentinfo"
|
||||
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{renderEncryptedIcon()}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-left zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{renderCustomFooter?.(false, appState)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-right zen-mode-transition",
|
||||
{
|
||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={toggleZenMode}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGitHubCorner = () => {
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__github-corner zen-mode-transition",
|
||||
{
|
||||
"transition-right": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<GitHubCorner theme={appState.theme} />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
const renderFooter = () => (
|
||||
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
||||
<div
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{renderCustomFooter?.(false)}
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={toggleZenMode}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
</footer>
|
||||
);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
@@ -660,7 +702,11 @@ const LayerUI = ({
|
||||
/>
|
||||
)}
|
||||
{appState.showHelpDialog && (
|
||||
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
||||
<HelpDialog
|
||||
onClose={() => {
|
||||
setAppState({ showHelpDialog: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.pasteDialog.shown && (
|
||||
<PasteChartDialog
|
||||
@@ -685,7 +731,8 @@ const LayerUI = ({
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
libraryMenu={libraryMenu}
|
||||
exportButton={renderExportDialog()}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={onLockToggle}
|
||||
@@ -708,8 +755,6 @@ const LayerUI = ({
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{renderGitHubCorner()}
|
||||
{renderFooter()}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
|
||||
@@ -20,7 +20,8 @@ import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkMode
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
actionManager: ActionManager;
|
||||
exportButton: React.ReactNode;
|
||||
renderJSONExportDialog: () => React.ReactNode;
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
libraryMenu: JSX.Element | null;
|
||||
@@ -28,7 +29,7 @@ type MobileMenuProps = {
|
||||
onLockToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
showThemeBtn: boolean;
|
||||
};
|
||||
@@ -38,7 +39,8 @@ export const MobileMenu = ({
|
||||
elements,
|
||||
libraryMenu,
|
||||
actionManager,
|
||||
exportButton,
|
||||
renderJSONExportDialog,
|
||||
renderImageExportDialog,
|
||||
setAppState,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
@@ -107,19 +109,17 @@ export const MobileMenu = ({
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<>
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
@@ -155,7 +155,7 @@ export const MobileMenu = ({
|
||||
<div className="panelColumn">
|
||||
<Stack.Col gap={4}>
|
||||
{renderCanvasActions()}
|
||||
{renderCustomFooter?.(true)}
|
||||
{renderCustomFooter?.(true, appState)}
|
||||
{appState.collaborators.size > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background-color: transparentize($oc-black, 0.7);
|
||||
backdrop-filter: blur(2px);
|
||||
background-color: transparentize($oc-black, 0.3);
|
||||
}
|
||||
|
||||
.Modal__content {
|
||||
@@ -45,13 +44,16 @@
|
||||
|
||||
// for modals, reset blurry bg
|
||||
background: var(--island-bg-color);
|
||||
backdrop-filter: none;
|
||||
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
|
||||
@@ -4,7 +4,8 @@ import React, { useState, useLayoutEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const Modal = (props: {
|
||||
className?: string;
|
||||
@@ -12,8 +13,10 @@ export const Modal = (props: {
|
||||
maxWidth?: number;
|
||||
onCloseRequest(): void;
|
||||
labelledBy: string;
|
||||
theme?: AppState["theme"];
|
||||
}) => {
|
||||
const modalRoot = useBodyRoot();
|
||||
const { theme = "light" } = props;
|
||||
const modalRoot = useBodyRoot(theme);
|
||||
|
||||
if (!modalRoot) {
|
||||
return null;
|
||||
@@ -22,6 +25,7 @@ export const Modal = (props: {
|
||||
const handleKeydown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
props.onCloseRequest();
|
||||
}
|
||||
};
|
||||
@@ -38,6 +42,7 @@ export const Modal = (props: {
|
||||
<div
|
||||
className="Modal__content"
|
||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||
tabIndex={0}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
@@ -46,13 +51,15 @@ export const Modal = (props: {
|
||||
);
|
||||
};
|
||||
|
||||
const useBodyRoot = () => {
|
||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileRef = useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
|
||||
const excalidrawContainer = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (div) {
|
||||
div.classList.toggle("excalidraw--mobile", isMobile);
|
||||
@@ -60,9 +67,9 @@ const useBodyRoot = () => {
|
||||
}, [div, isMobile]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isDarkTheme = !!document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.contains("theme--dark");
|
||||
const isDarkTheme =
|
||||
!!excalidrawContainer?.classList.contains("theme--dark") ||
|
||||
theme === "dark";
|
||||
const div = document.createElement("div");
|
||||
|
||||
div.classList.add("excalidraw", "excalidraw-modal-container");
|
||||
@@ -79,7 +86,7 @@ const useBodyRoot = () => {
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
};
|
||||
}, []);
|
||||
}, [excalidrawContainer, theme]);
|
||||
|
||||
return div;
|
||||
};
|
||||
|
||||
25
src/components/ProjectName.scss
Normal file
25
src/components/ProjectName.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
.ProjectName {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.TextInput {
|
||||
height: calc(1rem - 3px);
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&--readonly {
|
||||
background: none;
|
||||
border: none;
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
width: auto;
|
||||
max-width: 200px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import "./TextInput.scss";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import { focusNearestParent } from "../utils";
|
||||
|
||||
import "./ProjectName.scss";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
@@ -17,6 +20,7 @@ export class ProjectName extends Component<Props, State> {
|
||||
fileName: this.props.value,
|
||||
};
|
||||
private handleBlur = (event: any) => {
|
||||
focusNearestParent(event.target);
|
||||
const value = event.target.value;
|
||||
if (value !== this.props.value) {
|
||||
this.props.onChange(value);
|
||||
@@ -35,8 +39,8 @@ export class ProjectName extends Component<Props, State> {
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="file-name">
|
||||
<div className="ProjectName">
|
||||
<label className="ProjectName-label" htmlFor="filename">
|
||||
{`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
|
||||
</label>
|
||||
{this.props.isNameEditable ? (
|
||||
@@ -44,18 +48,18 @@ export class ProjectName extends Component<Props, State> {
|
||||
className="TextInput"
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
id="file-name"
|
||||
id="filename"
|
||||
value={this.state.fileName}
|
||||
onChange={(event) =>
|
||||
this.setState({ fileName: event.target.value })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="TextInput TextInput--readonly" id="file-name">
|
||||
<span className="TextInput TextInput--readonly" id="filename">
|
||||
{this.props.value}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
top: 64px;
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
z-index: 999;
|
||||
z-index: 10;
|
||||
|
||||
h3 {
|
||||
margin: 0 24px 8px 0;
|
||||
|
||||
@@ -29,9 +29,13 @@ type ToolButtonProps =
|
||||
children?: React.ReactNode;
|
||||
onClick?(): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "icon";
|
||||
children?: React.ReactNode;
|
||||
onClick?(): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "radio";
|
||||
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
});
|
||||
@@ -43,7 +47,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
||||
|
||||
if (props.type === "button") {
|
||||
if (props.type === "button" || props.type === "icon") {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -56,6 +60,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
{
|
||||
ToolIcon: !props.hidden,
|
||||
"ToolIcon--selected": props.selected,
|
||||
"ToolIcon--plain": props.type === "icon",
|
||||
},
|
||||
)}
|
||||
data-testid={props["data-testid"]}
|
||||
@@ -66,14 +71,16 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
onClick={props.onClick}
|
||||
ref={innerRef}
|
||||
>
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
{props.icon || props.label}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(props.icon || props.label) && (
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
{props.icon || props.label}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.showAriaLabel && (
|
||||
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,15 @@
|
||||
background-color: var(--button-gray-1);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
border-radius: var(--space-factor);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ToolIcon--plain {
|
||||
background-color: transparent;
|
||||
.ToolIcon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
@@ -187,17 +196,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.TooltipIcon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.unlocked-icon {
|
||||
:root[dir="ltr"] & {
|
||||
left: 2px;
|
||||
|
||||
@@ -1,58 +1,39 @@
|
||||
@import "../css/variables.module";
|
||||
.excalidraw {
|
||||
.Tooltip {
|
||||
position: relative;
|
||||
}
|
||||
.excalidraw-tooltip {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
|
||||
.Tooltip__label {
|
||||
--arrow-size: 4px;
|
||||
visibility: hidden;
|
||||
background: $oc-black;
|
||||
color: $oc-white;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
// extra pixel offset for unknown reasons
|
||||
left: calc(50% + var(--arrow-size) / 2 - 1px);
|
||||
transform: translateX(-50%);
|
||||
word-wrap: break-word;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
word-wrap: break-word;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
border: var(--arrow-size) solid transparent;
|
||||
position: absolute;
|
||||
left: calc(50% - var(--arrow-size));
|
||||
}
|
||||
background: $oc-black;
|
||||
|
||||
&--above {
|
||||
bottom: calc(100% + var(--arrow-size) + 3px);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $oc-white;
|
||||
|
||||
&::after {
|
||||
border-top-color: $oc-black;
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
display: none;
|
||||
|
||||
&--below {
|
||||
top: calc(100% + var(--arrow-size) + 3px);
|
||||
|
||||
&::after {
|
||||
border-bottom-color: $oc-black;
|
||||
bottom: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Tooltip:hover .Tooltip__label {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.Tooltip__label:hover {
|
||||
visibility: visible;
|
||||
&.excalidraw-tooltip--visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.Tooltip-icon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
display: flex;
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,92 @@
|
||||
import "./Tooltip.scss";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
const getTooltipDiv = () => {
|
||||
const existingDiv = document.querySelector<HTMLDivElement>(
|
||||
".excalidraw-tooltip",
|
||||
);
|
||||
if (existingDiv) {
|
||||
return existingDiv;
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
div.classList.add("excalidraw-tooltip");
|
||||
return div;
|
||||
};
|
||||
|
||||
const updateTooltip = (
|
||||
item: HTMLDivElement,
|
||||
tooltip: HTMLDivElement,
|
||||
label: string,
|
||||
long: boolean,
|
||||
) => {
|
||||
tooltip.classList.add("excalidraw-tooltip--visible");
|
||||
tooltip.style.minWidth = long ? "50ch" : "10ch";
|
||||
tooltip.style.maxWidth = long ? "50ch" : "15ch";
|
||||
|
||||
tooltip.textContent = label;
|
||||
|
||||
const {
|
||||
x: itemX,
|
||||
bottom: itemBottom,
|
||||
top: itemTop,
|
||||
width: itemWidth,
|
||||
} = item.getBoundingClientRect();
|
||||
|
||||
const {
|
||||
width: labelWidth,
|
||||
height: labelHeight,
|
||||
} = tooltip.getBoundingClientRect();
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const margin = 5;
|
||||
|
||||
const left = itemX + itemWidth / 2 - labelWidth / 2;
|
||||
const offsetLeft =
|
||||
left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
|
||||
|
||||
const top = itemBottom + margin;
|
||||
const offsetTop =
|
||||
top + labelHeight >= viewportHeight
|
||||
? itemBottom - itemTop + labelHeight + margin * 2
|
||||
: 0;
|
||||
|
||||
Object.assign(tooltip.style, {
|
||||
top: `${top - offsetTop}px`,
|
||||
left: `${left - offsetLeft}px`,
|
||||
});
|
||||
};
|
||||
|
||||
type TooltipProps = {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
position?: "above" | "below";
|
||||
long?: boolean;
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
label,
|
||||
position = "below",
|
||||
long = false,
|
||||
}: TooltipProps) => (
|
||||
<div className="Tooltip">
|
||||
<span
|
||||
className={
|
||||
position === "above"
|
||||
? "Tooltip__label Tooltip__label--above"
|
||||
: "Tooltip__label Tooltip__label--below"
|
||||
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
||||
useEffect(() => {
|
||||
return () =>
|
||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={(event) =>
|
||||
updateTooltip(
|
||||
event.currentTarget as HTMLDivElement,
|
||||
getTooltipDiv(),
|
||||
label,
|
||||
long,
|
||||
)
|
||||
}
|
||||
onPointerLeave={() =>
|
||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
|
||||
}
|
||||
style={{ width: long ? "50ch" : "10ch" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
.UserList {
|
||||
pointer-events: none;
|
||||
/*github corner*/
|
||||
padding: var(--space-factor) 40px var(--space-factor) var(--space-factor);
|
||||
padding: var(--space-factor) var(--space-factor) var(--space-factor)
|
||||
var(--space-factor);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -41,6 +41,14 @@ const createIcon = (d: string | React.ReactNode, opts: number | Opts = 512) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const checkIcon = createIcon(
|
||||
<polyline fill="none" stroke="currentColor" points="20 6 9 17 4 12" />,
|
||||
{
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
);
|
||||
|
||||
export const link = createIcon(
|
||||
"M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.584-1.731a152.482 152.482 0 0 1 20.522 17.197zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.196c6.402 4.468 15.064 3.789 20.584-1.731l21.054-21.055c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.454 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z",
|
||||
{ mirror: true },
|
||||
@@ -80,6 +88,25 @@ export const exportFile = createIcon(
|
||||
{ width: 576, height: 512, mirror: true },
|
||||
);
|
||||
|
||||
export const exportImage = createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-187 44v-64 64z"
|
||||
fill-rule="nonzero"
|
||||
/>
|
||||
<path
|
||||
d="M384 121.941V128H256V0h6.059c6.362 0 12.471 2.53 16.97 7.029l97.941 97.941a24.01 24.01 0 017.03 16.971zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zm-135.455 16c26.51 0 48 21.49 48 48s-21.49 48-48 48-48-21.49-48-48 21.491-48 48-48zm208 240h-256l.485-48.485L104.545 328c4.686-4.686 11.799-4.201 16.485.485L160.545 368 264.06 264.485c4.686-4.686 12.284-4.686 16.971 0L320.545 304v112z"
|
||||
fill-rule="nonzero"
|
||||
/>
|
||||
</>,
|
||||
{ width: 576, height: 512, mirror: true },
|
||||
);
|
||||
|
||||
export const exportToFileIcon = createIcon(
|
||||
"M216 0h80c13.3 0 24 10.7 24 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3c-7.5 7.5-19.8 7.5-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24c0-13.3 10.7-24 24-24zm296 376v112c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V376c0-13.3 10.7-24 24-24h146.7l49 49c20.1 20.1 52.5 20.1 72.6 0l49-49H488c13.3 0 24 10.7 24 24zm-124 88c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20zm64 0c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20z",
|
||||
{ width: 512, height: 512 },
|
||||
);
|
||||
|
||||
export const zoomIn = createIcon(
|
||||
"M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z",
|
||||
{ width: 448, height: 512 },
|
||||
@@ -222,14 +249,12 @@ export const SendToBackIcon = React.memo(
|
||||
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h8V3zM22 14a1 1 0 00-1-1h-7a1 1 0 00-1 1v7a1 1 0 001 1h8v-8z"
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -335,7 +360,6 @@ export const DistributeHorizontallyIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path d="M5 5V19Z" fill="black" />
|
||||
<path
|
||||
d="M19 5V19M5 5V19"
|
||||
stroke={iconFillColor(theme)}
|
||||
@@ -353,14 +377,6 @@ export const DistributeHorizontallyIcon = React.memo(
|
||||
),
|
||||
);
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
></svg>;
|
||||
|
||||
export const DistributeVerticallyIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
@@ -479,42 +495,16 @@ export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
<g
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="147.5"
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="147.5"
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
>
|
||||
<rect x="2.5" y="2.5" width="30" height="30" />
|
||||
<rect x="2.5" y="149.5" width="30" height="30" />
|
||||
<rect x="147.5" y="149.5" width="30" height="30" />
|
||||
<rect x="147.5" y="2.5" width="30" height="30" />
|
||||
</g>
|
||||
</>,
|
||||
{ width: 182, height: 182, mirror: true },
|
||||
),
|
||||
@@ -536,60 +526,18 @@ export const UngroupIcon = React.memo(
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
<g
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="78.5"
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="147.5"
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="147.5"
|
||||
y="78.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="105.5"
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="102.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
>
|
||||
<rect x="2.5" y="2.5" width="30" height="30" />
|
||||
<rect x="78.5" y="149.5" width="30" height="30" />
|
||||
<rect x="147.5" y="149.5" width="30" height="30" />
|
||||
<rect x="147.5" y="78.5" width="30" height="30" />
|
||||
<rect x="105.5" y="2.5" width="30" height="30" />
|
||||
<rect x="2.5" y="102.5" width="30" height="30" />
|
||||
</g>
|
||||
</>,
|
||||
{ width: 182, height: 182, mirror: true },
|
||||
),
|
||||
@@ -631,9 +579,10 @@ export const StrokeWidthIcon = React.memo(
|
||||
({ theme, strokeWidth }: { theme: "light" | "dark"; strokeWidth: number }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
d="M6 10H32"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
@@ -648,6 +597,7 @@ export const StrokeStyleSolidIcon = React.memo(
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{
|
||||
width: 40,
|
||||
@@ -665,6 +615,7 @@ export const StrokeStyleDashedIcon = React.memo(
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray={"10, 8"}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
@@ -674,11 +625,12 @@ export const StrokeStyleDottedIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
d="M6 10H36"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray={"4, 4"}
|
||||
strokeDasharray={"2, 4.5"}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
@@ -691,6 +643,7 @@ export const SloppinessArchitectIcon = React.memo(
|
||||
d="M3.00098 16.1691C6.28774 13.9744 19.6399 2.8905 22.7215 3.00082C25.8041 3.11113 19.1158 15.5488 21.4962 16.8309C23.8757 18.1131 34.4155 11.7148 37.0001 10.6919"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
@@ -704,6 +657,7 @@ export const SloppinessArtistIcon = React.memo(
|
||||
d="M3 17C6.68158 14.8752 16.1296 9.09849 22.0648 6.54922C28 3.99995 22.2896 13.3209 25 14C27.7104 14.6791 36.3757 9.6471 36.3757 9.6471M6.40706 15C13 11.1918 20.0468 1.51045 23.0234 3.0052C26 4.49995 20.457 12.8659 22.7285 16.4329C25 20 36.3757 13 36.3757 13"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
@@ -717,6 +671,7 @@ export const SloppinessCartoonistIcon = React.memo(
|
||||
d="M3 15.6468C6.93692 13.5378 22.5544 2.81528 26.6206 3.00242C30.6877 3.18956 25.6708 15.3346 27.4009 16.7705C29.1309 18.2055 35.4001 12.4762 37 11.6177M3.97143 10.4917C6.61158 9.24563 16.3706 2.61886 19.8104 3.01724C23.2522 3.41472 22.0773 12.2013 24.6181 12.8783C27.1598 13.5536 33.3179 8.04068 35.0571 7.07244"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
@@ -730,6 +685,7 @@ export const EdgeSharpIcon = React.memo(
|
||||
d="M10 17L10 5L35 5"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
@@ -743,6 +699,7 @@ export const EdgeRoundIcon = React.memo(
|
||||
d="M10 17V15C10 8 13 5 21 5L33.5 5"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
@@ -902,6 +859,7 @@ export const TextAlignLeftIcon = React.memo(
|
||||
<path
|
||||
d="M12.83 352h262.34A12.82 12.82 0 00288 339.17v-38.34A12.82 12.82 0 00275.17 288H12.83A12.82 12.82 0 000 300.83v38.34A12.82 12.82 0 0012.83 352zm0-256h262.34A12.82 12.82 0 00288 83.17V44.83A12.82 12.82 0 00275.17 32H12.83A12.82 12.82 0 000 44.83v38.34A12.82 12.82 0 0012.83 96zM432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16z"
|
||||
fill={iconFillColor(theme)}
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
@@ -924,6 +882,7 @@ export const TextAlignRightIcon = React.memo(
|
||||
<path
|
||||
d="M16 224h416a16 16 0 0016-16v-32a16 16 0 00-16-16H16a16 16 0 00-16 16v32a16 16 0 0016 16zm416 192H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm3.17-384H172.83A12.82 12.82 0 00160 44.83v38.34A12.82 12.82 0 00172.83 96h262.34A12.82 12.82 0 00448 83.17V44.83A12.82 12.82 0 00435.17 32zm0 256H172.83A12.82 12.82 0 00160 300.83v38.34A12.82 12.82 0 00172.83 352h262.34A12.82 12.82 0 00448 339.17v-38.34A12.82 12.82 0 00435.17 288z"
|
||||
fill={iconFillColor(theme)}
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
|
||||
@@ -91,6 +91,8 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawLibrary: "excalidrawlib",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE = window.location.origin;
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
} as const;
|
||||
@@ -102,7 +104,6 @@ export const TITLE_TIMEOUT = 10000;
|
||||
export const TOAST_TIMEOUT = 5000;
|
||||
export const VERSION_TIMEOUT = 30000;
|
||||
export const SCROLL_TIMEOUT = 100;
|
||||
export const DETECT_POSITION_CHANGE_INTERVAL = 500;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
|
||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||
@@ -141,3 +142,5 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
|
||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap; /* added line */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LoadingMessage {
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// serves 2 purposes:
|
||||
// 1. prevent selecting text outside the component when double-clicking or
|
||||
// dragging inside it (e.g. on canvas)
|
||||
@@ -328,8 +332,8 @@
|
||||
.App-menu_bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-gap: 4px;
|
||||
grid-template-columns: min-content auto min-content;
|
||||
grid-gap: 15px;
|
||||
align-items: flex-start;
|
||||
cursor: default;
|
||||
pointer-events: none !important;
|
||||
@@ -354,10 +358,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.App-menu_bottom > *:first-child {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
@@ -415,13 +415,11 @@
|
||||
}
|
||||
|
||||
&.dropdown-select--floating {
|
||||
position: absolute;
|
||||
margin: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-select__language.dropdown-select--floating {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
@@ -457,13 +455,12 @@
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
fill: $oc-gray-6;
|
||||
bottom: 14px;
|
||||
width: 1.5rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
background: none;
|
||||
color: var(--icon-fill-color);
|
||||
|
||||
@@ -472,11 +469,11 @@
|
||||
}
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 14px;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 14px;
|
||||
margin-left: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,20 +493,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.github-corner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.zen-mode-visibility {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
--focus-highlight-color: #{$oc-blue-2};
|
||||
--icon-fill-color: #{$oc-black};
|
||||
--icon-green-fill-color: #{$oc-green-9};
|
||||
--default-bg-color: #{$oc-white};
|
||||
--input-bg-color: #{$oc-white};
|
||||
--input-border-color: #{$oc-gray-3};
|
||||
--input-hover-bg-color: #{$oc-gray-1};
|
||||
--input-label-color: #{$oc-gray-7};
|
||||
--island-bg-color: rgba(255, 255, 255, 0.9);
|
||||
--island-bg-color: rgba(255, 255, 255, 0.96);
|
||||
--keybinding-color: #{$oc-gray-5};
|
||||
--link-color: #{$oc-blue-7};
|
||||
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
|
||||
@@ -56,11 +57,12 @@
|
||||
--focus-highlight-color: #{$oc-blue-6};
|
||||
--icon-fill-color: #{$oc-gray-4};
|
||||
--icon-green-fill-color: #{$oc-green-4};
|
||||
--default-bg-color: #121212;
|
||||
--input-bg-color: #121212;
|
||||
--input-border-color: #2e2e2e;
|
||||
--input-hover-bg-color: #181818;
|
||||
--input-label-color: #{$oc-gray-2};
|
||||
--island-bg-color: #1e1e1e;
|
||||
--island-bg-color: rgba(30, 30, 30, 0.98);
|
||||
--keybinding-color: #{$oc-gray-6};
|
||||
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
|
||||
--popup-bg-color: #2c2c2c;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { calculateScrollCenter } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { isValidExcalidrawData } from "./json";
|
||||
import { restore } from "./restore";
|
||||
import { LibraryData } from "./types";
|
||||
import { ImportedLibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File) => {
|
||||
let contents: string;
|
||||
@@ -114,7 +114,7 @@ export const loadFromBlob = async (
|
||||
|
||||
export const loadLibraryFromBlob = async (blob: Blob) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
const data: LibraryData = JSON.parse(contents);
|
||||
const data: ImportedLibraryData = JSON.parse(contents);
|
||||
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fileSave } from "browser-fs-access";
|
||||
import {
|
||||
copyCanvasToClipboardAsPng,
|
||||
copyBlobToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
@@ -18,7 +18,6 @@ export const exportCanvas = async (
|
||||
type: ExportType,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
canvas: HTMLCanvasElement,
|
||||
{
|
||||
exportBackground,
|
||||
exportPadding = 10,
|
||||
@@ -76,10 +75,11 @@ export const exportCanvas = async (
|
||||
});
|
||||
tempCanvas.style.display = "none";
|
||||
document.body.appendChild(tempCanvas);
|
||||
let blob = await canvasToBlob(tempCanvas);
|
||||
tempCanvas.remove();
|
||||
|
||||
if (type === "png") {
|
||||
const fileName = `${name}.png`;
|
||||
let blob = await canvasToBlob(tempCanvas);
|
||||
if (appState.exportEmbedScene) {
|
||||
blob = await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
@@ -95,7 +95,7 @@ export const exportCanvas = async (
|
||||
});
|
||||
} else if (type === "clipboard") {
|
||||
try {
|
||||
await copyCanvasToClipboardAsPng(tempCanvas);
|
||||
await copyBlobToClipboardAsPng(blob);
|
||||
} catch (error) {
|
||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw error;
|
||||
@@ -103,9 +103,4 @@ export const exportCanvas = async (
|
||||
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
||||
}
|
||||
}
|
||||
|
||||
// clean up the DOM
|
||||
if (tempCanvas !== canvas) {
|
||||
tempCanvas.remove();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { fileOpen, fileSave } from "browser-fs-access";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { loadFromBlob } from "./blob";
|
||||
import { Library } from "./library";
|
||||
import { ImportedDataState } from "./types";
|
||||
|
||||
import {
|
||||
ExportedDataState,
|
||||
ImportedDataState,
|
||||
ExportedLibraryData,
|
||||
} from "./types";
|
||||
import Library from "./library";
|
||||
|
||||
export const serializeAsJSON = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): string =>
|
||||
JSON.stringify(
|
||||
{
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
version: 2,
|
||||
source: window.location.origin,
|
||||
elements: clearElementsForExport(elements),
|
||||
appState: cleanAppStateForExport(appState),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
): string => {
|
||||
const data: ExportedDataState = {
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
version: 2,
|
||||
source: EXPORT_SOURCE,
|
||||
elements: clearElementsForExport(elements),
|
||||
appState: cleanAppStateForExport(appState),
|
||||
};
|
||||
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
export const saveAsJSON = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -85,17 +89,15 @@ export const isValidLibrary = (json: any) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const saveLibraryAsJSON = async () => {
|
||||
const library = await Library.loadLibrary();
|
||||
const serialized = JSON.stringify(
|
||||
{
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: 1,
|
||||
library,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
export const saveLibraryAsJSON = async (library: Library) => {
|
||||
const libraryItems = await library.loadLibrary();
|
||||
const data: ExportedLibraryData = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: 1,
|
||||
source: EXPORT_SOURCE,
|
||||
library: libraryItems,
|
||||
};
|
||||
const serialized = JSON.stringify(data, null, 2);
|
||||
const fileName = "library.excalidrawlib";
|
||||
const blob = new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
@@ -107,7 +109,7 @@ export const saveLibraryAsJSON = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const importLibraryFromJSON = async () => {
|
||||
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
|
||||
@@ -116,5 +118,5 @@ export const importLibraryFromJSON = async () => {
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
*/
|
||||
});
|
||||
await Library.importLibrary(blob);
|
||||
await library.importLibrary(blob);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { loadLibraryFromBlob } from "./blob";
|
||||
import { LibraryItems, LibraryItem } from "../types";
|
||||
import { restoreElements } from "./restore";
|
||||
import { STORAGE_KEYS } from "../constants";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { NonDeleted, ExcalidrawElement } from "../element/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import App from "../components/App";
|
||||
|
||||
export class Library {
|
||||
private static libraryCache: LibraryItems | null = null;
|
||||
public static csrfToken = nanoid();
|
||||
class Library {
|
||||
private libraryCache: LibraryItems | null = null;
|
||||
private app: App;
|
||||
|
||||
static resetLibrary = () => {
|
||||
Library.libraryCache = null;
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
resetLibrary = async () => {
|
||||
await this.app.props.onLibraryChange?.([]);
|
||||
this.libraryCache = [];
|
||||
};
|
||||
|
||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
||||
const elements = getNonDeletedElements(restoreElements(libraryItem));
|
||||
return elements.length ? elements : null;
|
||||
};
|
||||
|
||||
/** imports library (currently merges, removing duplicates) */
|
||||
static async importLibrary(blob: Blob) {
|
||||
async importLibrary(blob: Blob) {
|
||||
const libraryFile = await loadLibraryFromBlob(blob);
|
||||
if (!libraryFile || !libraryFile.library) {
|
||||
return;
|
||||
@@ -46,37 +53,41 @@ export class Library {
|
||||
});
|
||||
};
|
||||
|
||||
const existingLibraryItems = await Library.loadLibrary();
|
||||
const existingLibraryItems = await this.loadLibrary();
|
||||
|
||||
const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
|
||||
const restored = getNonDeletedElements(restoreElements(libraryItem));
|
||||
if (isUniqueitem(existingLibraryItems, restored)) {
|
||||
acc.push(restored);
|
||||
const restoredItem = this.restoreLibraryItem(libraryItem);
|
||||
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
|
||||
acc.push(restoredItem);
|
||||
}
|
||||
return acc;
|
||||
}, [] as (readonly NonDeleted<ExcalidrawElement>[])[]);
|
||||
}, [] as Mutable<LibraryItems>);
|
||||
|
||||
Library.saveLibrary([...existingLibraryItems, ...filtered]);
|
||||
await this.saveLibrary([...existingLibraryItems, ...filtered]);
|
||||
}
|
||||
|
||||
static loadLibrary = (): Promise<LibraryItems> => {
|
||||
loadLibrary = (): Promise<LibraryItems> => {
|
||||
return new Promise(async (resolve) => {
|
||||
if (Library.libraryCache) {
|
||||
return resolve(JSON.parse(JSON.stringify(Library.libraryCache)));
|
||||
if (this.libraryCache) {
|
||||
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
|
||||
}
|
||||
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
||||
if (!data) {
|
||||
const libraryItems = this.app.libraryItemsFromStorage;
|
||||
if (!libraryItems) {
|
||||
return resolve([]);
|
||||
}
|
||||
|
||||
const items = (JSON.parse(data) as LibraryItems).map((elements) =>
|
||||
restoreElements(elements),
|
||||
) as Mutable<LibraryItems>;
|
||||
const items = libraryItems.reduce((acc, item) => {
|
||||
const restoredItem = this.restoreLibraryItem(item);
|
||||
if (restoredItem) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Mutable<LibraryItems>);
|
||||
|
||||
// clone to ensure we don't mutate the cached library elements in the app
|
||||
Library.libraryCache = JSON.parse(JSON.stringify(items));
|
||||
this.libraryCache = JSON.parse(JSON.stringify(items));
|
||||
|
||||
resolve(items);
|
||||
} catch (error) {
|
||||
@@ -86,17 +97,19 @@ export class Library {
|
||||
});
|
||||
};
|
||||
|
||||
static saveLibrary = (items: LibraryItems) => {
|
||||
const prevLibraryItems = Library.libraryCache;
|
||||
saveLibrary = async (items: LibraryItems) => {
|
||||
const prevLibraryItems = this.libraryCache;
|
||||
try {
|
||||
const serializedItems = JSON.stringify(items);
|
||||
// cache optimistically so that consumers have access to the latest
|
||||
// cache optimistically so that the app has access to the latest
|
||||
// immediately
|
||||
Library.libraryCache = JSON.parse(serializedItems);
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||
this.libraryCache = JSON.parse(serializedItems);
|
||||
await this.app.props.onLibraryChange?.(items);
|
||||
} catch (error) {
|
||||
Library.libraryCache = prevLibraryItems;
|
||||
console.error(error);
|
||||
this.libraryCache = prevLibraryItems;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Library;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ExcalidrawSelectionElement,
|
||||
} from "../element/types";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { DataState, ImportedDataState } from "./types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
|
||||
import { isLinearElementType } from "../element/typeChecks";
|
||||
import { randomId } from "../random";
|
||||
@@ -15,6 +15,31 @@ import {
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
>;
|
||||
|
||||
export const AllowedExcalidrawElementTypes: Record<
|
||||
ExcalidrawElement["type"],
|
||||
true
|
||||
> = {
|
||||
selection: true,
|
||||
text: true,
|
||||
rectangle: true,
|
||||
diamond: true,
|
||||
ellipse: true,
|
||||
line: true,
|
||||
arrow: true,
|
||||
freedraw: true,
|
||||
};
|
||||
|
||||
export type RestoredDataState = {
|
||||
elements: ExcalidrawElement[];
|
||||
appState: RestoredAppState;
|
||||
};
|
||||
|
||||
const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
|
||||
for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
|
||||
@@ -25,12 +50,18 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
|
||||
return DEFAULT_FONT_FAMILY;
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <T extends ExcalidrawElement>(
|
||||
const restoreElementWithProperties = <
|
||||
T extends ExcalidrawElement,
|
||||
K extends keyof Omit<
|
||||
Required<T>,
|
||||
Exclude<keyof ExcalidrawElement, "type" | "x" | "y">
|
||||
>
|
||||
>(
|
||||
element: Required<T>,
|
||||
extra: Omit<Required<T>, keyof ExcalidrawElement>,
|
||||
extra: Pick<T, K>,
|
||||
): T => {
|
||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||
type: element.type,
|
||||
type: (extra as Partial<T>).type || element.type,
|
||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||
// newly added elements
|
||||
version: element.version || 1,
|
||||
@@ -43,8 +74,8 @@ const restoreElementWithProperties = <T extends ExcalidrawElement>(
|
||||
roughness: element.roughness ?? 1,
|
||||
opacity: element.opacity == null ? 100 : element.opacity,
|
||||
angle: element.angle || 0,
|
||||
x: element.x || 0,
|
||||
y: element.y || 0,
|
||||
x: (extra as Partial<T>).x ?? element.x ?? 0,
|
||||
y: (extra as Partial<T>).y ?? element.y ?? 0,
|
||||
strokeColor: element.strokeColor,
|
||||
backgroundColor: element.backgroundColor,
|
||||
width: element.width || 0,
|
||||
@@ -87,28 +118,51 @@ const restoreElement = (
|
||||
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||
});
|
||||
case "draw":
|
||||
case "freedraw": {
|
||||
return restoreElementWithProperties(element, {
|
||||
points: element.points,
|
||||
lastCommittedPoint: null,
|
||||
simulatePressure: element.simulatePressure,
|
||||
pressures: element.pressures,
|
||||
});
|
||||
}
|
||||
case "line":
|
||||
// @ts-ignore LEGACY type
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case "draw":
|
||||
case "arrow": {
|
||||
const {
|
||||
startArrowhead = null,
|
||||
endArrowhead = element.type === "arrow" ? "arrow" : null,
|
||||
} = element;
|
||||
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
? [
|
||||
[0, 0],
|
||||
[element.width, element.height],
|
||||
]
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
return restoreElementWithProperties(element, {
|
||||
type:
|
||||
(element.type as ExcalidrawElement["type"] | "draw") === "draw"
|
||||
? "line"
|
||||
: element.type,
|
||||
startBinding: element.startBinding,
|
||||
endBinding: element.endBinding,
|
||||
points:
|
||||
// migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
? [
|
||||
[0, 0],
|
||||
[element.width, element.height],
|
||||
]
|
||||
: element.points,
|
||||
lastCommittedPoint: null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
points,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
// generic elements
|
||||
@@ -144,7 +198,7 @@ export const restoreElements = (
|
||||
export const restoreAppState = (
|
||||
appState: ImportedDataState["appState"],
|
||||
localAppState: Partial<AppState> | null,
|
||||
): DataState["appState"] => {
|
||||
): RestoredAppState => {
|
||||
appState = appState || {};
|
||||
|
||||
const defaultAppState = getDefaultAppState();
|
||||
@@ -166,6 +220,9 @@ export const restoreAppState = (
|
||||
|
||||
return {
|
||||
...nextAppState,
|
||||
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
|
||||
? nextAppState.elementType
|
||||
: "selection",
|
||||
// Migrates from previous version where appState.zoom was a number
|
||||
zoom:
|
||||
typeof appState.zoom === "number"
|
||||
@@ -186,7 +243,7 @@ export const restore = (
|
||||
* Supply `null` if you can't get access to it.
|
||||
*/
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
): DataState => {
|
||||
): RestoredDataState => {
|
||||
return {
|
||||
elements: restoreElements(data?.elements),
|
||||
appState: restoreAppState(data?.appState, localAppState || null),
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, LibraryItems } from "../types";
|
||||
import type { cleanAppStateForExport } from "../appState";
|
||||
|
||||
export interface DataState {
|
||||
type?: string;
|
||||
version?: string;
|
||||
source?: string;
|
||||
export interface ExportedDataState {
|
||||
type: string;
|
||||
version: number;
|
||||
source: string;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: Omit<AppState, "offsetTop" | "offsetLeft" | "width" | "height">;
|
||||
appState: ReturnType<typeof cleanAppStateForExport>;
|
||||
}
|
||||
|
||||
export interface ImportedDataState {
|
||||
type?: string;
|
||||
version?: string;
|
||||
source?: string;
|
||||
elements?: DataState["elements"] | null;
|
||||
appState?: Partial<DataState["appState"]> | null;
|
||||
scrollToContent?: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryData {
|
||||
type?: string;
|
||||
version?: number;
|
||||
source?: string;
|
||||
library?: LibraryItems;
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: Readonly<Partial<AppState>> | null;
|
||||
scrollToContent?: boolean;
|
||||
libraryItems?: LibraryItems;
|
||||
}
|
||||
|
||||
export interface ExportedLibraryData {
|
||||
type: string;
|
||||
version: number;
|
||||
source: string;
|
||||
library: LibraryItems;
|
||||
}
|
||||
|
||||
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ExcalidrawElement, ExcalidrawLinearElement, Arrowhead } from "./types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
} from "./types";
|
||||
import { distance2d, rotate } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { Drawable, Op } from "roughjs/bin/core";
|
||||
@@ -7,7 +12,7 @@ import {
|
||||
getShapeForElement,
|
||||
generateRoughOptions,
|
||||
} from "../renderer/renderElement";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
@@ -18,7 +23,9 @@ export type Bounds = readonly [number, number, number, number];
|
||||
export const getElementAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
): Bounds => {
|
||||
if (isLinearElement(element)) {
|
||||
if (isFreeDrawElement(element)) {
|
||||
return getFreeDrawElementAbsoluteCoords(element);
|
||||
} else if (isLinearElement(element)) {
|
||||
return getLinearElementAbsoluteCoords(element);
|
||||
}
|
||||
return [
|
||||
@@ -120,9 +127,42 @@ const getMinMaxXYFromCurvePathOps = (
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
const getBoundsFromPoints = (
|
||||
points: ExcalidrawFreeDrawElement["points"],
|
||||
): [number, number, number, number] => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const [x, y] of points) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
const getFreeDrawElementAbsoluteCoords = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
): [number, number, number, number] => {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
};
|
||||
|
||||
const getLinearElementAbsoluteCoords = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): [number, number, number, number] => {
|
||||
let coords: [number, number, number, number];
|
||||
|
||||
if (element.points.length < 2 || !getShapeForElement(element)) {
|
||||
// XXX this is just a poor estimate and not very useful
|
||||
const { minX, minY, maxX, maxY } = element.points.reduce(
|
||||
@@ -137,7 +177,21 @@ const getLinearElementAbsoluteCoords = (
|
||||
},
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
return [
|
||||
coords = [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
} else {
|
||||
const shape = getShapeForElement(element) as Drawable[];
|
||||
|
||||
// first element is always the curve
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
|
||||
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
|
||||
|
||||
coords = [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
@@ -145,19 +199,7 @@ const getLinearElementAbsoluteCoords = (
|
||||
];
|
||||
}
|
||||
|
||||
const shape = getShapeForElement(element) as Drawable[];
|
||||
|
||||
// first element is always the curve
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
|
||||
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
return coords;
|
||||
};
|
||||
|
||||
export const getArrowheadPoints = (
|
||||
@@ -218,20 +260,34 @@ export const getArrowheadPoints = (
|
||||
dot: 15,
|
||||
}[arrowhead]; // pixels (will differ for each arrowhead)
|
||||
|
||||
const length = element.points.reduce((total, [cx, cy], idx, points) => {
|
||||
const [px, py] = idx > 0 ? points[idx - 1] : [0, 0];
|
||||
return total + Math.hypot(cx - px, cy - py);
|
||||
}, 0);
|
||||
let length = 0;
|
||||
|
||||
if (arrowhead === "arrow") {
|
||||
// Length for -> arrows is based on the length of the last section
|
||||
const [cx, cy] = element.points[element.points.length - 1];
|
||||
const [px, py] =
|
||||
element.points.length > 1
|
||||
? element.points[element.points.length - 2]
|
||||
: [0, 0];
|
||||
|
||||
length = Math.hypot(cx - px, cy - py);
|
||||
} else {
|
||||
// Length for other arrowhead types is based on the total length of the line
|
||||
for (let i = 0; i < element.points.length; i++) {
|
||||
const [px, py] = element.points[i - 1] || [0, 0];
|
||||
const [cx, cy] = element.points[i];
|
||||
length += Math.hypot(cx - px, cy - py);
|
||||
}
|
||||
}
|
||||
|
||||
// Scale down the arrowhead until we hit a certain size so that it doesn't look weird.
|
||||
// This value is selected by minimizing a minimum size with the whole length of the
|
||||
// arrowhead instead of last segment of the arrowhead.
|
||||
// This value is selected by minimizing a minimum size with the last segment of the arrowhead
|
||||
const minSize = Math.min(size, length / 2);
|
||||
const xs = x2 - nx * minSize;
|
||||
const ys = y2 - ny * minSize;
|
||||
|
||||
if (arrowhead === "dot") {
|
||||
const r = Math.hypot(ys - y2, xs - x2);
|
||||
const r = Math.hypot(ys - y2, xs - x2) + element.strokeWidth;
|
||||
return [x2, y2, r];
|
||||
}
|
||||
|
||||
@@ -277,16 +333,31 @@ const getLinearElementRotatedBounds = (
|
||||
return getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
};
|
||||
|
||||
// We could cache this stuff
|
||||
export const getElementBounds = (
|
||||
element: ExcalidrawElement,
|
||||
): [number, number, number, number] => {
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
if (isLinearElement(element)) {
|
||||
return getLinearElementRotatedBounds(element, cx, cy);
|
||||
}
|
||||
if (element.type === "diamond") {
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
rotate(x, y, cx - element.x, cy - element.y, element.angle),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
} else if (isLinearElement(element)) {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
||||
@@ -295,26 +366,28 @@ export const getElementBounds = (
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
return [minX, minY, maxX, maxY];
|
||||
}
|
||||
if (element.type === "ellipse") {
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
} else if (element.type === "ellipse") {
|
||||
const w = (x2 - x1) / 2;
|
||||
const h = (y2 - y1) / 2;
|
||||
const cos = Math.cos(element.angle);
|
||||
const sin = Math.sin(element.angle);
|
||||
const ww = Math.hypot(w * cos, h * sin);
|
||||
const hh = Math.hypot(h * cos, w * sin);
|
||||
return [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
}
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
return [minX, minY, maxX, maxY];
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
@@ -345,7 +418,7 @@ export const getResizedElementAbsoluteCoords = (
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
): [number, number, number, number] => {
|
||||
if (!isLinearElement(element)) {
|
||||
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
|
||||
return [
|
||||
element.x,
|
||||
element.y,
|
||||
@@ -360,16 +433,29 @@ export const getResizedElementAbsoluteCoords = (
|
||||
rescalePoints(1, nextHeight, element.points),
|
||||
);
|
||||
|
||||
const gen = rough.generator();
|
||||
const curve =
|
||||
element.strokeSharpness === "sharp"
|
||||
? gen.linearPath(
|
||||
points as [number, number][],
|
||||
generateRoughOptions(element),
|
||||
)
|
||||
: gen.curve(points as [number, number][], generateRoughOptions(element));
|
||||
const ops = getCurvePathOps(curve);
|
||||
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
if (isFreeDrawElement(element)) {
|
||||
// Free Draw
|
||||
bounds = getBoundsFromPoints(points);
|
||||
} else {
|
||||
// Line
|
||||
const gen = rough.generator();
|
||||
const curve =
|
||||
element.strokeSharpness === "sharp"
|
||||
? gen.linearPath(
|
||||
points as [number, number][],
|
||||
generateRoughOptions(element),
|
||||
)
|
||||
: gen.curve(
|
||||
points as [number, number][],
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
const ops = getCurvePathOps(curve);
|
||||
bounds = getMinMaxXYFromCurvePathOps(ops);
|
||||
}
|
||||
|
||||
const [minX, minY, maxX, maxY] = bounds;
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
|
||||
@@ -4,7 +4,13 @@ import * as GADirection from "../gadirections";
|
||||
import * as GALine from "../galines";
|
||||
import * as GATransform from "../gatransforms";
|
||||
|
||||
import { isPathALoop, isPointInPolygon, rotate } from "../math";
|
||||
import {
|
||||
distance2d,
|
||||
rotatePoint,
|
||||
isPathALoop,
|
||||
isPointInPolygon,
|
||||
rotate,
|
||||
} from "../math";
|
||||
import { pointsOnBezierCurves } from "points-on-curve";
|
||||
|
||||
import {
|
||||
@@ -16,6 +22,7 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawEllipseElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFreeDrawElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
@@ -30,10 +37,17 @@ const isElementDraggableFromInside = (
|
||||
if (element.type === "arrow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element.type === "freedraw") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isDraggableFromInside = element.backgroundColor !== "transparent";
|
||||
if (element.type === "line" || element.type === "draw") {
|
||||
|
||||
if (element.type === "line") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
|
||||
return isDraggableFromInside;
|
||||
};
|
||||
|
||||
@@ -81,6 +95,7 @@ const isHittingElementNotConsideringBoundingBox = (
|
||||
: isElementDraggableFromInside(element)
|
||||
? isInsideCheck
|
||||
: isNearCheck;
|
||||
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
};
|
||||
|
||||
@@ -151,9 +166,20 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
case "ellipse":
|
||||
const distance = distanceToBindableElement(args.element, args.point);
|
||||
return args.check(distance, args.threshold);
|
||||
case "freedraw": {
|
||||
if (
|
||||
!args.check(
|
||||
distanceToRectangle(args.element, args.point),
|
||||
args.threshold,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hitTestFreeDrawElement(args.element, args.point, args.threshold);
|
||||
}
|
||||
case "arrow":
|
||||
case "line":
|
||||
case "draw":
|
||||
return hitTestLinear(args);
|
||||
case "selection":
|
||||
console.warn(
|
||||
@@ -195,7 +221,10 @@ const isOutsideCheck = (distance: number, threshold: number): boolean => {
|
||||
};
|
||||
|
||||
const distanceToRectangle = (
|
||||
element: ExcalidrawRectangleElement | ExcalidrawTextElement,
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
@@ -267,6 +296,71 @@ const ellipseParamsForTest = (
|
||||
return [pointRel, tangent];
|
||||
};
|
||||
|
||||
const hitTestFreeDrawElement = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
point: Point,
|
||||
threshold: number,
|
||||
): boolean => {
|
||||
// Check point-distance-to-line-segment for every segment in the
|
||||
// element's points (its input points, not its outline points).
|
||||
// This is... okay? It's plenty fast, but the GA library may
|
||||
// have a faster option.
|
||||
|
||||
let x: number;
|
||||
let y: number;
|
||||
|
||||
if (element.angle === 0) {
|
||||
x = point[0] - element.x;
|
||||
y = point[1] - element.y;
|
||||
} else {
|
||||
// Counter-rotate the point around center before testing
|
||||
const [minX, minY, maxX, maxY] = getElementAbsoluteCoords(element);
|
||||
const rotatedPoint = rotatePoint(
|
||||
point,
|
||||
[minX + (maxX - minX) / 2, minY + (maxY - minY) / 2],
|
||||
-element.angle,
|
||||
);
|
||||
x = rotatedPoint[0] - element.x;
|
||||
y = rotatedPoint[1] - element.y;
|
||||
}
|
||||
|
||||
let [A, B] = element.points;
|
||||
let P: readonly [number, number];
|
||||
|
||||
// For freedraw dots
|
||||
if (element.points.length === 2) {
|
||||
return (
|
||||
distance2d(A[0], A[1], x, y) < threshold ||
|
||||
distance2d(B[0], B[1], x, y) < threshold
|
||||
);
|
||||
}
|
||||
|
||||
// For freedraw lines
|
||||
for (let i = 1; i < element.points.length - 1; i++) {
|
||||
const delta = [B[0] - A[0], B[1] - A[1]];
|
||||
const length = Math.hypot(delta[1], delta[0]);
|
||||
|
||||
const U = [delta[0] / length, delta[1] / length];
|
||||
const C = [x - A[0], y - A[1]];
|
||||
const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]);
|
||||
P = [A[0] + U[0] * d, A[1] + U[1] * d];
|
||||
|
||||
const da = distance2d(P[0], P[1], A[0], A[1]);
|
||||
const db = distance2d(P[0], P[1], B[0], B[1]);
|
||||
|
||||
P = db < da && da > length ? B : da < db && db > length ? A : P;
|
||||
|
||||
if (Math.hypot(y - P[1], x - P[0]) < threshold) {
|
||||
return true;
|
||||
}
|
||||
|
||||
A = B;
|
||||
B = element.points[i + 1];
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const hitTestLinear = (args: HitTestArgs): boolean => {
|
||||
const { element, threshold } = args;
|
||||
if (!getShapeForElement(element)) {
|
||||
|
||||
@@ -58,13 +58,6 @@ export {
|
||||
} from "./sizeHelpers";
|
||||
export { showSelectedShapeActions } from "./showSelectedShapeActions";
|
||||
|
||||
export const getSyncableElements = (
|
||||
elements: readonly ExcalidrawElement[], // There are places in Excalidraw where synthetic invisibly small elements are added and removed.
|
||||
) =>
|
||||
// It's probably best to keep those local otherwise there might be a race condition that
|
||||
// gets the app into an invalid state. I've never seen it happen but I'm worried about it :)
|
||||
elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el));
|
||||
|
||||
export const getElementMap = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.reduce(
|
||||
(acc: { [key: string]: ExcalidrawElement }, element: ExcalidrawElement) => {
|
||||
@@ -77,6 +70,11 @@ export const getElementMap = (elements: readonly ExcalidrawElement[]) =>
|
||||
export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.reduce((acc, el) => acc + el.version, 0);
|
||||
|
||||
export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter(
|
||||
(el) => !el.isDeleted && !isInvisiblySmallElement(el),
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter(
|
||||
(element) => !element.isDeleted,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getElementAbsoluteCoords } from ".";
|
||||
import { getElementPointsCoords } from "./bounds";
|
||||
import { Point, AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { SceneHistory } from "../history";
|
||||
import History from "../history";
|
||||
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
@@ -167,7 +167,7 @@ export class LinearElementEditor {
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
appState: AppState,
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
history: SceneHistory,
|
||||
history: History,
|
||||
scenePointer: { x: number; y: number },
|
||||
): {
|
||||
didAddPoint: boolean;
|
||||
@@ -415,26 +415,31 @@ export class LinearElementEditor {
|
||||
return [rotatedX - element.x, rotatedY - element.y];
|
||||
}
|
||||
|
||||
// element-mutating methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Normalizes line points so that the start point is at [0,0]. This is
|
||||
* expected in various parts of the codebase.
|
||||
* expected in various parts of the codebase. Also returns new x/y to account
|
||||
* for the potential normalization.
|
||||
*/
|
||||
static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
|
||||
static getNormalizedPoints(element: ExcalidrawLinearElement) {
|
||||
const { points } = element;
|
||||
|
||||
const offsetX = points[0][0];
|
||||
const offsetY = points[0][1];
|
||||
|
||||
mutateElement(element, {
|
||||
return {
|
||||
points: points.map((point, _idx) => {
|
||||
return [point[0] - offsetX, point[1] - offsetY] as const;
|
||||
}),
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// element-mutating methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
|
||||
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
static movePointByOffset(
|
||||
|
||||
@@ -114,3 +114,14 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
versionNonce: randomInteger(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutates element and updates `version` & `versionNonce`.
|
||||
*
|
||||
* NOTE: does not trigger re-render.
|
||||
*/
|
||||
export const bumpVersion = (element: Mutable<ExcalidrawElement>) => {
|
||||
element.version = element.version + 1;
|
||||
element.versionNonce = randomInteger();
|
||||
return element;
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GroupId,
|
||||
VerticalAlign,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
} from "../element/types";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
@@ -212,6 +213,22 @@ export const updateTextElement = (
|
||||
});
|
||||
};
|
||||
|
||||
export const newFreeDrawElement = (
|
||||
opts: {
|
||||
type: "freedraw";
|
||||
points?: ExcalidrawFreeDrawElement["points"];
|
||||
simulatePressure: boolean;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
pressures: [],
|
||||
simulatePressure: opts.simulatePressure,
|
||||
lastCommittedPoint: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
getCommonBounds,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { isLinearElement, isTextElement } from "./typeChecks";
|
||||
import {
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
@@ -244,7 +248,7 @@ const rescalePointsInElement = (
|
||||
width: number,
|
||||
height: number,
|
||||
) =>
|
||||
isLinearElement(element)
|
||||
isLinearElement(element) || isFreeDrawElement(element)
|
||||
? {
|
||||
points: rescalePoints(
|
||||
0,
|
||||
@@ -404,7 +408,7 @@ export const resizeSingleElement = (
|
||||
-stateAtResizeStart.angle,
|
||||
);
|
||||
|
||||
//Get bounds corners rendered on screen
|
||||
// Get bounds corners rendered on screen
|
||||
const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
|
||||
element,
|
||||
element.width,
|
||||
@@ -644,11 +648,14 @@ const resizeMultipleElements = (
|
||||
font = { fontSize: nextFont.size, baseline: nextFont.baseline };
|
||||
}
|
||||
const origCoords = getElementAbsoluteCoords(element);
|
||||
|
||||
const rescaledPoints = rescalePointsInElement(element, width, height);
|
||||
|
||||
updateBoundElements(element, {
|
||||
newSize: { width, height },
|
||||
simultaneouslyUpdated: elements,
|
||||
});
|
||||
|
||||
const finalCoords = getResizedElementAbsoluteCoords(
|
||||
{
|
||||
...element,
|
||||
@@ -657,6 +664,7 @@ const resizeMultipleElements = (
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
const { x, y } = getNextXY(element, origCoords, finalCoords);
|
||||
return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ExcalidrawElement } from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
|
||||
export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isLinearElement(element)) {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
return element.points.length < 2;
|
||||
}
|
||||
return element.width === 0 && element.height === 0;
|
||||
@@ -26,7 +26,7 @@ export const getPerfectElementSize = (
|
||||
if (
|
||||
elementType === "line" ||
|
||||
elementType === "arrow" ||
|
||||
elementType === "draw"
|
||||
elementType === "freedraw"
|
||||
) {
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) *
|
||||
|
||||
169
src/element/textWysiwyg.test.tsx
Normal file
169
src/element/textWysiwyg.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { render } from "../tests/test-utils";
|
||||
import { Pointer, UI } from "../tests/helpers/ui";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const tab = " ";
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
let textarea: HTMLTextAreaElement;
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
const element = UI.createElement("text");
|
||||
|
||||
new Pointer("mouse").clickOn(element);
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
});
|
||||
|
||||
it("should add a tab at the start of the first line", () => {
|
||||
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
||||
textarea.value = "Line#1\nLine#2";
|
||||
// cursor: "|Line#1\nLine#2"
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = 0;
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
expect(textarea.value).toEqual(`${tab}Line#1\nLine#2`);
|
||||
// cursor: " |Line#1\nLine#2"
|
||||
expect(textarea.selectionStart).toEqual(4);
|
||||
expect(textarea.selectionEnd).toEqual(4);
|
||||
});
|
||||
|
||||
it("should add a tab at the start of the second line", () => {
|
||||
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
||||
textarea.value = "Line#1\nLine#2";
|
||||
// cursor: "Line#1\nLin|e#2"
|
||||
textarea.selectionStart = 10;
|
||||
textarea.selectionEnd = 10;
|
||||
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
expect(textarea.value).toEqual(`Line#1\n${tab}Line#2`);
|
||||
|
||||
// cursor: "Line#1\n Lin|e#2"
|
||||
expect(textarea.selectionStart).toEqual(14);
|
||||
expect(textarea.selectionEnd).toEqual(14);
|
||||
});
|
||||
|
||||
it("should add a tab at the start of the first and second line", () => {
|
||||
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
||||
textarea.value = "Line#1\nLine#2\nLine#3";
|
||||
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
|
||||
textarea.selectionStart = 2;
|
||||
textarea.selectionEnd = 9;
|
||||
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
expect(textarea.value).toEqual(`${tab}Line#1\n${tab}Line#2\nLine#3`);
|
||||
|
||||
// cursor: " Li|ne#1\n Li|ne#2\nLine#3"
|
||||
expect(textarea.selectionStart).toEqual(6);
|
||||
expect(textarea.selectionEnd).toEqual(17);
|
||||
});
|
||||
|
||||
it("should remove a tab at the start of the first line", () => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: KEYS.TAB,
|
||||
shiftKey: true,
|
||||
});
|
||||
textarea.value = `${tab}Line#1\nLine#2`;
|
||||
// cursor: "| Line#1\nLine#2"
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = 0;
|
||||
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||
|
||||
// cursor: "|Line#1\nLine#2"
|
||||
expect(textarea.selectionStart).toEqual(0);
|
||||
expect(textarea.selectionEnd).toEqual(0);
|
||||
});
|
||||
|
||||
it("should remove a tab at the start of the second line", () => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: KEYS.TAB,
|
||||
shiftKey: true,
|
||||
});
|
||||
// cursor: "Line#1\n Lin|e#2"
|
||||
textarea.value = `Line#1\n${tab}Line#2`;
|
||||
textarea.selectionStart = 15;
|
||||
textarea.selectionEnd = 15;
|
||||
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||
// cursor: "Line#1\nLin|e#2"
|
||||
expect(textarea.selectionStart).toEqual(11);
|
||||
expect(textarea.selectionEnd).toEqual(11);
|
||||
});
|
||||
|
||||
it("should remove a tab at the start of the first and second line", () => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: KEYS.TAB,
|
||||
shiftKey: true,
|
||||
});
|
||||
// cursor: " Li|ne#1\n Li|ne#2\nLine#3"
|
||||
textarea.value = `${tab}Line#1\n${tab}Line#2\nLine#3`;
|
||||
textarea.selectionStart = 6;
|
||||
textarea.selectionEnd = 17;
|
||||
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
expect(textarea.value).toEqual(`Line#1\nLine#2\nLine#3`);
|
||||
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
|
||||
expect(textarea.selectionStart).toEqual(2);
|
||||
expect(textarea.selectionEnd).toEqual(9);
|
||||
});
|
||||
|
||||
it("should remove a tab at the start of the second line and cursor stay on this line", () => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: KEYS.TAB,
|
||||
shiftKey: true,
|
||||
});
|
||||
// cursor: "Line#1\n | Line#2"
|
||||
textarea.value = `Line#1\n${tab}Line#2`;
|
||||
textarea.selectionStart = 9;
|
||||
textarea.selectionEnd = 9;
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
// cursor: "Line#1\n|Line#2"
|
||||
expect(textarea.selectionStart).toEqual(7);
|
||||
// expect(textarea.selectionEnd).toEqual(7);
|
||||
});
|
||||
|
||||
it("should remove partial tabs", () => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: KEYS.TAB,
|
||||
shiftKey: true,
|
||||
});
|
||||
// cursor: "Line#1\n Line#|2"
|
||||
textarea.value = `Line#1\n Line#2`;
|
||||
textarea.selectionStart = 15;
|
||||
textarea.selectionEnd = 15;
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||
});
|
||||
|
||||
it("should remove nothing", () => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: KEYS.TAB,
|
||||
shiftKey: true,
|
||||
});
|
||||
// cursor: "Line#1\n Li|ne#2"
|
||||
textarea.value = `Line#1\nLine#2`;
|
||||
textarea.selectionStart = 9;
|
||||
textarea.selectionEnd = 9;
|
||||
textarea.dispatchEvent(event);
|
||||
|
||||
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { isWritableElement, getFontString } from "../utils";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isTextElement } from "./typeChecks";
|
||||
@@ -43,6 +43,7 @@ export const textWysiwyg = ({
|
||||
getViewportCoords,
|
||||
element,
|
||||
canvas,
|
||||
excalidrawContainer,
|
||||
}: {
|
||||
id: ExcalidrawElement["id"];
|
||||
appState: AppState;
|
||||
@@ -51,6 +52,7 @@ export const textWysiwyg = ({
|
||||
getViewportCoords: (x: number, y: number) => [number, number];
|
||||
element: ExcalidrawElement;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
excalidrawContainer: HTMLDivElement | null;
|
||||
}) => {
|
||||
const updateWysiwygStyle = () => {
|
||||
const updatedElement = Scene.getScene(element)?.getElement(id);
|
||||
@@ -71,7 +73,7 @@ export const textWysiwyg = ({
|
||||
// margin-right of parent if any
|
||||
Number(
|
||||
getComputedStyle(
|
||||
document.querySelector(".excalidraw")!.parentNode as Element,
|
||||
excalidrawContainer?.parentNode as Element,
|
||||
).marginRight.slice(0, -2),
|
||||
);
|
||||
|
||||
@@ -134,6 +136,7 @@ export const textWysiwyg = ({
|
||||
}
|
||||
|
||||
editable.onkeydown = (event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.preventDefault();
|
||||
submittedViaKeyboard = true;
|
||||
@@ -145,11 +148,118 @@ export const textWysiwyg = ({
|
||||
}
|
||||
submittedViaKeyboard = true;
|
||||
handleSubmit();
|
||||
} else if (event.key === KEYS.ENTER && !event.altKey) {
|
||||
event.stopPropagation();
|
||||
} else if (
|
||||
event.key === KEYS.TAB ||
|
||||
(event[KEYS.CTRL_OR_CMD] &&
|
||||
(event.code === CODES.BRACKET_LEFT ||
|
||||
event.code === CODES.BRACKET_RIGHT))
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
|
||||
outdent();
|
||||
} else {
|
||||
indent();
|
||||
}
|
||||
// We must send an input event to resize the element
|
||||
editable.dispatchEvent(new Event("input"));
|
||||
}
|
||||
};
|
||||
|
||||
const TAB_SIZE = 4;
|
||||
const TAB = " ".repeat(TAB_SIZE);
|
||||
const RE_LEADING_TAB = new RegExp(`^ {1,${TAB_SIZE}}`);
|
||||
const indent = () => {
|
||||
const { selectionStart, selectionEnd } = editable;
|
||||
const linesStartIndices = getSelectedLinesStartIndices();
|
||||
|
||||
let value = editable.value;
|
||||
linesStartIndices.forEach((startIndex) => {
|
||||
const startValue = value.slice(0, startIndex);
|
||||
const endValue = value.slice(startIndex);
|
||||
|
||||
value = `${startValue}${TAB}${endValue}`;
|
||||
});
|
||||
|
||||
editable.value = value;
|
||||
|
||||
editable.selectionStart = selectionStart + TAB_SIZE;
|
||||
editable.selectionEnd = selectionEnd + TAB_SIZE * linesStartIndices.length;
|
||||
};
|
||||
|
||||
const outdent = () => {
|
||||
const { selectionStart, selectionEnd } = editable;
|
||||
const linesStartIndices = getSelectedLinesStartIndices();
|
||||
const removedTabs: number[] = [];
|
||||
|
||||
let value = editable.value;
|
||||
linesStartIndices.forEach((startIndex) => {
|
||||
const tabMatch = value
|
||||
.slice(startIndex, startIndex + TAB_SIZE)
|
||||
.match(RE_LEADING_TAB);
|
||||
|
||||
if (tabMatch) {
|
||||
const startValue = value.slice(0, startIndex);
|
||||
const endValue = value.slice(startIndex + tabMatch[0].length);
|
||||
|
||||
// Delete a tab from the line
|
||||
value = `${startValue}${endValue}`;
|
||||
removedTabs.push(startIndex);
|
||||
}
|
||||
});
|
||||
|
||||
editable.value = value;
|
||||
|
||||
if (removedTabs.length) {
|
||||
if (selectionStart > removedTabs[removedTabs.length - 1]) {
|
||||
editable.selectionStart = Math.max(
|
||||
selectionStart - TAB_SIZE,
|
||||
removedTabs[removedTabs.length - 1],
|
||||
);
|
||||
} else {
|
||||
// If the cursor is before the first tab removed, ex:
|
||||
// Line| #1
|
||||
// Line #2
|
||||
// Lin|e #3
|
||||
// we should reset the selectionStart to his initial value.
|
||||
editable.selectionStart = selectionStart;
|
||||
}
|
||||
editable.selectionEnd = Math.max(
|
||||
editable.selectionStart,
|
||||
selectionEnd - TAB_SIZE * removedTabs.length,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns indeces of start positions of selected lines, in reverse order
|
||||
*/
|
||||
const getSelectedLinesStartIndices = () => {
|
||||
let { selectionStart, selectionEnd, value } = editable;
|
||||
|
||||
// chars before selectionStart on the same line
|
||||
const startOffset = value.slice(0, selectionStart).match(/[^\n]*$/)![0]
|
||||
.length;
|
||||
// put caret at the start of the line
|
||||
selectionStart = selectionStart - startOffset;
|
||||
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
|
||||
return selected
|
||||
.split("\n")
|
||||
.reduce(
|
||||
(startIndices, line, idx, lines) =>
|
||||
startIndices.concat(
|
||||
idx
|
||||
? // curr line index is prev line's start + prev line's length + \n
|
||||
startIndices[idx - 1] + lines[idx - 1].length + 1
|
||||
: // first selected line
|
||||
selectionStart,
|
||||
),
|
||||
[] as number[],
|
||||
)
|
||||
.reverse();
|
||||
};
|
||||
|
||||
const stopEvent = (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -159,11 +269,14 @@ export const textWysiwyg = ({
|
||||
// so that we don't need to create separate a callback for event handlers
|
||||
let submittedViaKeyboard = false;
|
||||
const handleSubmit = () => {
|
||||
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
|
||||
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
|
||||
// wysiwyg on update
|
||||
cleanup();
|
||||
onSubmit({
|
||||
text: normalizeText(editable.value),
|
||||
viaKeyboard: submittedViaKeyboard,
|
||||
});
|
||||
cleanup();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -252,7 +365,7 @@ export const textWysiwyg = ({
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
document
|
||||
.querySelector(".excalidraw-textEditorContainer")!
|
||||
excalidrawContainer
|
||||
?.querySelector(".excalidraw-textEditorContainer")!
|
||||
.appendChild(editable);
|
||||
};
|
||||
|
||||
@@ -225,7 +225,7 @@ export const getTransformHandles = (
|
||||
if (
|
||||
element.type === "arrow" ||
|
||||
element.type === "line" ||
|
||||
element.type === "draw"
|
||||
element.type === "freedraw"
|
||||
) {
|
||||
if (element.points.length === 2) {
|
||||
// only check the last point because starting point is always (0,0)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
} from "./types";
|
||||
|
||||
export const isGenericElement = (
|
||||
@@ -24,6 +25,18 @@ export const isTextElement = (
|
||||
return element != null && element.type === "text";
|
||||
};
|
||||
|
||||
export const isFreeDrawElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFreeDrawElement => {
|
||||
return element != null && isFreeDrawElementType(element.type);
|
||||
};
|
||||
|
||||
export const isFreeDrawElementType = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
): boolean => {
|
||||
return elementType === "freedraw";
|
||||
};
|
||||
|
||||
export const isLinearElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
@@ -34,7 +47,7 @@ export const isLinearElementType = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
): boolean => {
|
||||
return (
|
||||
elementType === "arrow" || elementType === "line" || elementType === "draw"
|
||||
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,7 +82,7 @@ export const isExcalidrawElement = (element: any): boolean => {
|
||||
element?.type === "rectangle" ||
|
||||
element?.type === "ellipse" ||
|
||||
element?.type === "arrow" ||
|
||||
element?.type === "draw" ||
|
||||
element?.type === "freedraw" ||
|
||||
element?.type === "line"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,7 +78,8 @@ export type ExcalidrawGenericElement =
|
||||
export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement;
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: false;
|
||||
@@ -113,7 +114,7 @@ export type Arrowhead = "arrow" | "bar" | "dot";
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "line" | "draw" | "arrow";
|
||||
type: "line" | "arrow";
|
||||
points: readonly Point[];
|
||||
lastCommittedPoint: Point | null;
|
||||
startBinding: PointBinding | null;
|
||||
@@ -121,3 +122,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "freedraw";
|
||||
points: readonly Point[];
|
||||
pressures: readonly number[];
|
||||
simulatePressure: boolean;
|
||||
lastCommittedPoint: Point | null;
|
||||
}>;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ExcalidrawElement } from "../../element/types";
|
||||
import {
|
||||
getElementMap,
|
||||
getSceneVersion,
|
||||
getSyncableElements,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../types";
|
||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||
@@ -41,6 +40,7 @@ import { t } from "../../i18n";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { isInvisiblySmallElement } from "../../element";
|
||||
|
||||
interface CollabState {
|
||||
modalIsShown: boolean;
|
||||
@@ -146,7 +146,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||
const syncableElements = getSyncableElements(
|
||||
const syncableElements = this.getSyncableElements(
|
||||
this.getSceneElementsIncludingDeleted(),
|
||||
);
|
||||
|
||||
@@ -177,7 +177,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: ExcalidrawElement[] = getSyncableElements(
|
||||
syncableElements: ExcalidrawElement[] = this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
) => {
|
||||
@@ -565,7 +565,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
) {
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
getSyncableElements(elements),
|
||||
this.getSyncableElements(elements),
|
||||
false,
|
||||
);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
@@ -576,7 +576,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
getSyncableElements(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
true,
|
||||
@@ -591,8 +591,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({ modalIsShown: false });
|
||||
const collabIcon = document.querySelector(".CollabButton") as HTMLElement;
|
||||
collabIcon.focus();
|
||||
};
|
||||
|
||||
onUsernameChange = (username: string) => {
|
||||
@@ -606,6 +604,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
};
|
||||
|
||||
getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el));
|
||||
|
||||
/** PRIVATE. Use `this.getContextValue()` instead. */
|
||||
private contextValue: CollabAPI | null = null;
|
||||
|
||||
@@ -640,6 +641,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
setErrorMessage={(errorMessage) => {
|
||||
this.setState({ errorMessage });
|
||||
}}
|
||||
theme={this.excalidrawAPI.getAppState().theme}
|
||||
/>
|
||||
)}
|
||||
{errorMessage && (
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
|
||||
import CollabWrapper from "./CollabWrapper";
|
||||
|
||||
import { getSyncableElements } from "../../packages/excalidraw/index";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { BROADCAST, SCENE } from "../app_constants";
|
||||
import { UserIdleState } from "../../types";
|
||||
@@ -39,7 +38,9 @@ class Portal {
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
SCENE.INIT,
|
||||
getSyncableElements(this.collab.getSceneElementsIncludingDeleted()),
|
||||
this.collab.getSyncableElements(
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ToolButton } from "../../components/ToolButton";
|
||||
import { t } from "../../i18n";
|
||||
import "./RoomDialog.scss";
|
||||
import Stack from "../../components/Stack";
|
||||
import { AppState } from "../../types";
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
@@ -36,6 +37,7 @@ const RoomDialog = ({
|
||||
onRoomCreate,
|
||||
onRoomDestroy,
|
||||
setErrorMessage,
|
||||
theme,
|
||||
}: {
|
||||
handleClose: () => void;
|
||||
activeRoomLink: string;
|
||||
@@ -44,6 +46,7 @@ const RoomDialog = ({
|
||||
onRoomCreate: () => void;
|
||||
onRoomDestroy: () => void;
|
||||
setErrorMessage: (message: string) => void;
|
||||
theme: AppState["theme"];
|
||||
}) => {
|
||||
const roomLinkInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -168,6 +171,7 @@ const RoomDialog = ({
|
||||
small
|
||||
onCloseRequest={handleClose}
|
||||
title={t("labels.liveCollaboration")}
|
||||
theme={theme}
|
||||
>
|
||||
{renderRoomDialog()}
|
||||
</Dialog>
|
||||
|
||||
@@ -3,13 +3,19 @@ import React from "react";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) => (
|
||||
({ theme, dir }: { theme: "light" | "dark"; dir: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 250 250"
|
||||
className="github-corner rtl-mirror"
|
||||
className="rtl-mirror"
|
||||
style={{
|
||||
marginTop: "calc(var(--space-factor) * -1)",
|
||||
[dir === "rtl"
|
||||
? "marginLeft"
|
||||
: "marginRight"]: "calc(var(--space-factor) * -1)",
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
@@ -19,18 +25,18 @@ export const GitHubCorner = React.memo(
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
fill={theme === "light" ? oc.gray[6] : oc.gray[8]}
|
||||
fill={theme === "light" ? oc.gray[6] : oc.gray[7]}
|
||||
/>
|
||||
<path
|
||||
className="octo-arm"
|
||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||
style={{ transformOrigin: "130px 106px" }}
|
||||
fill={theme === "light" ? oc.white : oc.black}
|
||||
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
<path
|
||||
className="octo-body"
|
||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||
fill={theme === "light" ? oc.white : oc.black}
|
||||
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
||||
@@ -245,10 +245,10 @@ const importFromBackend = async (
|
||||
export const loadScene = async (
|
||||
id: string | null,
|
||||
privateKey: string | null,
|
||||
// Supply initialData even if importing from backend to ensure we restore
|
||||
// Supply local state even if importing from backend to ensure we restore
|
||||
// localStorage user settings which we do not persist on server.
|
||||
// Non-optional so we don't forget to pass it even if `undefined`.
|
||||
initialData: ImportedDataState | undefined | null,
|
||||
localDataState: ImportedDataState | undefined | null,
|
||||
) => {
|
||||
let data;
|
||||
if (id != null) {
|
||||
@@ -256,10 +256,10 @@ export const loadScene = async (
|
||||
// extra care not to leak it
|
||||
data = restore(
|
||||
await importFromBackend(id, privateKey),
|
||||
initialData?.appState,
|
||||
localDataState?.appState,
|
||||
);
|
||||
} else {
|
||||
data = restore(initialData || null, null);
|
||||
data = restore(localDataState || null, null);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
17
src/excalidraw-app/index.scss
Normal file
17
src/excalidraw-app/index.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
.excalidraw {
|
||||
.layer-ui__wrapper__footer-center {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.encrypted-icon {
|
||||
border-radius: var(--space-factor);
|
||||
color: var(--icon-green-fill-color);
|
||||
margin-top: 13px;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,13 @@ import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||
import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
STORAGE_KEYS,
|
||||
TITLE_TIMEOUT,
|
||||
URL_HASH_KEYS,
|
||||
VERSION_TIMEOUT,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data/blob";
|
||||
import { DataState, ImportedDataState } from "../data/types";
|
||||
import { ImportedDataState } from "../data/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
@@ -30,7 +31,7 @@ import Excalidraw, {
|
||||
defaultLang,
|
||||
languages,
|
||||
} from "../packages/excalidraw/index";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, LibraryItems } from "../types";
|
||||
import {
|
||||
debounce,
|
||||
getVersion,
|
||||
@@ -50,6 +51,11 @@ import {
|
||||
saveToLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import { restoreAppState, RestoredDataState } from "../data/restore";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { shield } from "../components/icons";
|
||||
|
||||
import "./index.scss";
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
@@ -81,13 +87,11 @@ const initializeScene = async (opts: {
|
||||
);
|
||||
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
||||
|
||||
const initialData = importFromLocalStorage();
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
||||
let scene: DataState & { scrollToContent?: boolean } = await loadScene(
|
||||
null,
|
||||
null,
|
||||
initialData,
|
||||
);
|
||||
let scene: RestoredDataState & {
|
||||
scrollToContent?: boolean;
|
||||
} = await loadScene(null, null, localDataState);
|
||||
|
||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||
@@ -102,12 +106,12 @@ const initializeScene = async (opts: {
|
||||
) {
|
||||
// Backwards compatibility with legacy url format
|
||||
if (id) {
|
||||
scene = await loadScene(id, null, initialData);
|
||||
scene = await loadScene(id, null, localDataState);
|
||||
} else if (jsonBackendMatch) {
|
||||
scene = await loadScene(
|
||||
jsonBackendMatch[1],
|
||||
jsonBackendMatch[2],
|
||||
initialData,
|
||||
localDataState,
|
||||
);
|
||||
}
|
||||
scene.scrollToContent = true;
|
||||
@@ -161,6 +165,20 @@ const initializeScene = async (opts: {
|
||||
return null;
|
||||
};
|
||||
|
||||
const PlusLinkJSX = (
|
||||
<p style={{ direction: "ltr", unicodeBidi: "embed" }}>
|
||||
Introducing Excalidraw+
|
||||
<br />
|
||||
<a
|
||||
href="https://plus.excalidraw.com/?utm_source=excalidraw&utm_medium=banner&utm_campaign=launch"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Try out now!
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const currentLangCode = languageDetector.detect() || defaultLang.code;
|
||||
@@ -196,6 +214,18 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
|
||||
initializeScene({ collabAPI }).then((scene) => {
|
||||
if (scene) {
|
||||
try {
|
||||
scene.libraryItems =
|
||||
JSON.parse(
|
||||
localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
|
||||
) as string,
|
||||
) || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
initialStatePromiseRef.current.promise.resolve(scene);
|
||||
});
|
||||
|
||||
@@ -213,7 +243,10 @@ const ExcalidrawWrapper = () => {
|
||||
} else {
|
||||
initializeScene({ collabAPI }).then((scene) => {
|
||||
if (scene) {
|
||||
excalidrawAPI.updateScene(scene);
|
||||
excalidrawAPI.updateScene({
|
||||
...scene,
|
||||
appState: restoreAppState(scene.appState, null),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -278,8 +311,41 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderTopRightUI = useCallback(
|
||||
(isMobile: boolean, appState: AppState) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "24ch",
|
||||
fontSize: "0.7em",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{/* <GitHubCorner theme={appState.theme} dir={document.dir} /> */}
|
||||
{/* FIXME remove after 2021-05-20 */}
|
||||
{PlusLinkJSX}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderFooter = useCallback(
|
||||
(isMobile: boolean) => {
|
||||
const renderEncryptedIcon = () => (
|
||||
<a
|
||||
className="encrypted-icon tooltip"
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
|
||||
const renderLanguageList = () => (
|
||||
<LanguageList
|
||||
onChange={(langCode) => {
|
||||
@@ -291,14 +357,43 @@ const ExcalidrawWrapper = () => {
|
||||
/>
|
||||
);
|
||||
if (isMobile) {
|
||||
const isTinyDevice = window.innerWidth < 362;
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.language")}</legend>
|
||||
{renderLanguageList()}
|
||||
</fieldset>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: isTinyDevice ? "column" : "row",
|
||||
}}
|
||||
>
|
||||
<fieldset>
|
||||
<legend>{t("labels.language")}</legend>
|
||||
{renderLanguageList()}
|
||||
</fieldset>
|
||||
{/* FIXME remove after 2021-05-20 */}
|
||||
<div
|
||||
style={{
|
||||
width: "24ch",
|
||||
fontSize: "0.7em",
|
||||
textAlign: "center",
|
||||
marginTop: isTinyDevice ? 16 : undefined,
|
||||
marginLeft: "auto",
|
||||
marginRight: isTinyDevice ? "auto" : undefined,
|
||||
padding: "4px 2px",
|
||||
border: "1px dashed #aaa",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
{PlusLinkJSX}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return renderLanguageList();
|
||||
return (
|
||||
<>
|
||||
{renderEncryptedIcon()}
|
||||
{renderLanguageList()}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[langCode],
|
||||
);
|
||||
@@ -311,6 +406,15 @@ const ExcalidrawWrapper = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const onLibraryChange = async (items: LibraryItems) => {
|
||||
if (!items.length) {
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
||||
return;
|
||||
}
|
||||
const serializedItems = JSON.stringify(items);
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Excalidraw
|
||||
@@ -321,10 +425,13 @@ const ExcalidrawWrapper = () => {
|
||||
isCollaborating={collabAPI?.isCollaborating()}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
onExportToBackend={onExportToBackend}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderFooter={renderFooter}
|
||||
langCode={langCode}
|
||||
renderCustomStats={renderCustomStats}
|
||||
detectScroll={false}
|
||||
handleKeyboardGlobally={true}
|
||||
onLibraryChange={onLibraryChange}
|
||||
/>
|
||||
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
|
||||
@@ -67,10 +67,14 @@ export const selectGroupsForSelectedElements = (
|
||||
appState: AppState,
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
): AppState => {
|
||||
let nextAppState = { ...appState };
|
||||
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return { ...nextAppState, editingGroupId: null };
|
||||
}
|
||||
|
||||
for (const selectedElement of selectedElements) {
|
||||
let groupIds = selectedElement.groupIds;
|
||||
if (appState.editingGroupId) {
|
||||
|
||||
@@ -28,7 +28,7 @@ const clearAppStatePropertiesForHistory = (appState: AppState) => {
|
||||
};
|
||||
};
|
||||
|
||||
export class SceneHistory {
|
||||
class History {
|
||||
private elementCache = new Map<string, Map<number, ExcalidrawElement>>();
|
||||
private recording: boolean = true;
|
||||
private stateHistory: DehydratedHistoryEntry[] = [];
|
||||
@@ -260,7 +260,4 @@ export class SceneHistory {
|
||||
}
|
||||
}
|
||||
|
||||
export const createHistory: () => { history: SceneHistory } = () => {
|
||||
const history = new SceneHistory();
|
||||
return { history };
|
||||
};
|
||||
export default History;
|
||||
|
||||
@@ -47,6 +47,7 @@ const allLanguages: Language[] = [
|
||||
{ code: "uk-UA", label: "Українська" },
|
||||
{ code: "zh-CN", label: "简体中文" },
|
||||
{ code: "zh-TW", label: "繁體中文" },
|
||||
{ code: "lv-LV", label: "Latviešu" },
|
||||
].concat([defaultLang]);
|
||||
|
||||
export const languages: Language[] = allLanguages
|
||||
|
||||
@@ -14,7 +14,9 @@ export const CODES = {
|
||||
NINE: "Digit9",
|
||||
QUOTE: "Quote",
|
||||
ZERO: "Digit0",
|
||||
SLASH: "Slash",
|
||||
C: "KeyC",
|
||||
D: "KeyD",
|
||||
G: "KeyG",
|
||||
F: "KeyF",
|
||||
H: "KeyH",
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
||||
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)",
|
||||
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
||||
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
|
||||
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "تحديد",
|
||||
"draw": "الكتابة الحرة",
|
||||
"freedraw": "الكتابة الحرة",
|
||||
"rectangle": "مستطيل",
|
||||
"diamond": "مضلع",
|
||||
"ellipse": "دائرة",
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
|
||||
"collabStopOverridePrompt": "Прекратяването на сесията ще презапише предишната, локално запазена, рисунка. Сигурни ли сте?\n\n(Ако искате да продължите с локалната рисунка, просто затворете таба на браузъра.)",
|
||||
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
|
||||
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?",
|
||||
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Селекция",
|
||||
"draw": "Рисуване",
|
||||
"freedraw": "Рисуване",
|
||||
"rectangle": "Правоъгълник",
|
||||
"diamond": "Диамант",
|
||||
"ellipse": "Елипс",
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"architect": "Arquitecte",
|
||||
"artist": "Artista",
|
||||
"cartoonist": "Dibuixant",
|
||||
"fileTitle": "",
|
||||
"fileTitle": "Nom del fitxer",
|
||||
"colorPicker": "Selector de colors",
|
||||
"canvasBackground": "Fons del llenç",
|
||||
"drawingCanvas": "Llenç de dibuix",
|
||||
@@ -92,8 +92,8 @@
|
||||
"centerHorizontally": "Centrar horitzontalment",
|
||||
"distributeHorizontally": "Distribuir horitzontalment",
|
||||
"distributeVertically": "Distribuir verticalment",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"flipHorizontal": "Capgira horitzontalment",
|
||||
"flipVertical": "Capgira verticalment",
|
||||
"viewMode": "Mode de visualització",
|
||||
"toggleExportColorScheme": "Canvia l'esquema de colors de l'exportació",
|
||||
"share": "Compartir"
|
||||
@@ -142,6 +142,8 @@
|
||||
"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": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
|
||||
"imageDoesNotContainScene": "En aquest moment no s’admet la importació d’imatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada d’escena. Ho has activat durant l'exportació?",
|
||||
"cannotRestoreFromImage": "L’escena no s’ha pogut restaurar des d’aquest fitxer d’imatge",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selecció",
|
||||
"draw": "Dibuix lliure",
|
||||
"freedraw": "Dibuix lliure",
|
||||
"rectangle": "Rectangle",
|
||||
"diamond": "Rombe",
|
||||
"ellipse": "El·lipse",
|
||||
@@ -231,7 +233,7 @@
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors d’Excalidraw no els veuran mai.",
|
||||
"link": ""
|
||||
"link": "Article del blog sobre encriptació d'extrem a extrem a Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Angle",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"opacity": "Deckkraft",
|
||||
"textAlign": "Textausrichtung",
|
||||
"edges": "Kanten",
|
||||
"sharp": "Eckig",
|
||||
"sharp": "Scharf",
|
||||
"round": "Rund",
|
||||
"arrowheads": "Pfeilspitzen",
|
||||
"arrowhead_none": "Keine",
|
||||
@@ -76,7 +76,7 @@
|
||||
"madeWithExcalidraw": "Made with Excalidraw",
|
||||
"group": "Auswahl gruppieren",
|
||||
"ungroup": "Gruppierung aufheben",
|
||||
"collaborators": "Kollaboratoren",
|
||||
"collaborators": "Mitarbeitende",
|
||||
"showGrid": "Raster anzeigen",
|
||||
"addToLibrary": "Zur Bibliothek hinzufügen",
|
||||
"removeFromLibrary": "Aus Bibliothek entfernen",
|
||||
@@ -95,7 +95,7 @@
|
||||
"flipHorizontal": "Horizontal spiegeln",
|
||||
"flipVertical": "Vertikal spiegeln",
|
||||
"viewMode": "Ansichtsmodus",
|
||||
"toggleExportColorScheme": "Farbschema für Export umschalten",
|
||||
"toggleExportColorScheme": "Exportfarbschema umschalten",
|
||||
"share": "Teilen"
|
||||
},
|
||||
"buttons": {
|
||||
@@ -124,8 +124,8 @@
|
||||
"resetLibrary": "Bibliothek zurücksetzen",
|
||||
"createNewRoom": "Neuen Raum erstellen",
|
||||
"fullScreen": "Vollbildanzeige",
|
||||
"darkMode": "Dunkles Design",
|
||||
"lightMode": "Helles Design",
|
||||
"darkMode": "Dunkler Modus",
|
||||
"lightMode": "Heller Modus",
|
||||
"zenMode": "Zen-Modus",
|
||||
"exitZenMode": "Zen-Modus verlassen"
|
||||
},
|
||||
@@ -139,10 +139,12 @@
|
||||
"couldNotCopyToClipboard": "Konnte nicht in die Zwischenablage kopieren. Versuch es mit dem Chrome Browser.",
|
||||
"decryptFailed": "Daten konnten nicht entschlüsselt werden.",
|
||||
"uploadedSecurly": "Der Upload wurde mit Ende-zu-Ende-Verschlüsselung gespeichert. Weder Excalidraw noch Dritte können den Inhalt einsehen.",
|
||||
"loadSceneOverridePrompt": "Das Laden der externen Zeichnung ersetzt den vorhandenen Inhalt. Möchtest Du fortfahren?",
|
||||
"collabStopOverridePrompt": "Das Stoppen der Sitzung wird Deine vorherige, lokal gespeicherte Zeichnung überschreiben. Bist Du sicher?\n\n(Wenn Du Deine lokale Zeichnung behalten möchtest, schließe einfach stattdessen den Browser-Tab.)",
|
||||
"loadSceneOverridePrompt": "Das Laden einer externen Zeichnung ersetzt den vorhandenen Inhalt. Möchtest du fortfahren?",
|
||||
"collabStopOverridePrompt": "Das Stoppen der Sitzung wird deine vorherige, lokal gespeicherte Zeichnung überschreiben. Bist du dir sicher?\n\n(Wenn du deine lokale Zeichnung behalten möchtest, schließe stattdessen den Browser-Tab.)",
|
||||
"errorLoadingLibrary": "Beim Laden der Drittanbieter-Bibliothek ist ein Fehler aufgetreten.",
|
||||
"confirmAddLibrary": "Dieses fügt {{numShapes}} Form(en) zu deiner Bibliothek hinzu. Bist du sicher?",
|
||||
"errorAddingToLibrary": "Das Element konnte nicht zur Bibliothek hinzugefügt werden",
|
||||
"errorRemovingFromLibrary": "Das Element konnte nicht aus der Bibliothek entfernt werden",
|
||||
"confirmAddLibrary": "Dies fügt {{numShapes}} Form(en) zu deiner Bibliothek hinzu. Bist du dir sicher?",
|
||||
"imageDoesNotContainScene": "Das Importieren von Bildern wird derzeit nicht unterstützt.\n\nMöchtest du eine Szene importieren? Dieses Bild scheint keine Zeichnungsdaten zu enthalten. Hast du dies beim Exportieren aktiviert?",
|
||||
"cannotRestoreFromImage": "Die Zeichnung konnte aus dieser Bilddatei nicht wiederhergestellt werden",
|
||||
"invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Auswahl",
|
||||
"draw": "Freies Zeichnen",
|
||||
"freedraw": "Freies Zeichnen",
|
||||
"rectangle": "Rechteck",
|
||||
"diamond": "Raute",
|
||||
"ellipse": "Ellipse",
|
||||
@@ -168,7 +170,7 @@
|
||||
"hints": {
|
||||
"linearElement": "Klicken für Linie mit mehreren Punkten, Ziehen für einzelne Linie",
|
||||
"freeDraw": "Klicke und ziehe. Lass los, wenn du fertig bist",
|
||||
"text": "Tipp: Du kannst auch Text hinzufügen indem Du mit dem Auswahlwerkzeug auf eine beliebige Stelle doppelklickst",
|
||||
"text": "Tipp: Du kannst auch Text hinzufügen, indem du mit dem Auswahlwerkzeug auf eine beliebige Stelle doppelklickst",
|
||||
"linearElementMulti": "Zum Beenden auf den letzten Punkt klicken oder Escape oder Eingabe drücken",
|
||||
"lockAngle": "Du kannst Winkel einschränken, indem du SHIFT gedrückt hältst",
|
||||
"resize": "Du kannst die Proportionen einschränken, indem du SHIFT während der Größenänderung gedrückt hältst. Halte ALT gedrückt, um die Größe vom Zentrum aus zu ändern",
|
||||
@@ -249,12 +251,12 @@
|
||||
"width": "Breite"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "Formatierung kopiert.",
|
||||
"copyStyles": "Formatierungen kopiert.",
|
||||
"copyToClipboard": "In die Zwischenablage kopiert.",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} als PNG in die Zwischenablage kopiert\n({{exportColorScheme}})",
|
||||
"fileSaved": "Datei gespeichert.",
|
||||
"fileSavedToFilename": "Als {filename} gespeichert",
|
||||
"canvas": "Leinwand",
|
||||
"canvas": "Zeichenfläche",
|
||||
"selection": "Auswahl"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
|
||||
"collabStopOverridePrompt": "Η διακοπή της συνεδρίας θα αντικαταστήσει το προηγούμενο, τοπικά αποθηκευμένο σχέδιο. Είστε σίγουροι?\n\n(Αν θέλετε να διατηρήσετε το τοπικό σας σχέδιο, απλά κλείστε την καρτέλα του προγράμματος περιήγησης.)",
|
||||
"errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;",
|
||||
"imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;",
|
||||
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Επιλογή",
|
||||
"draw": "Ελεύθερο σχέδιο",
|
||||
"freedraw": "Ελεύθερο σχέδιο",
|
||||
"rectangle": "Ορθογώνιο",
|
||||
"diamond": "Ρόμβος",
|
||||
"ellipse": "Έλλειψη",
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
"background": "Background",
|
||||
"fill": "Fill",
|
||||
"strokeWidth": "Stroke width",
|
||||
"strokeShape": "Stroke shape",
|
||||
"strokeShape_gel": "Gel pen",
|
||||
"strokeShape_fountain": "Fountain pen",
|
||||
"strokeShape_brush": "Brush pen",
|
||||
"strokeStyle": "Stroke style",
|
||||
"strokeStyle_solid": "Solid",
|
||||
"strokeStyle_dashed": "Dashed",
|
||||
@@ -38,8 +42,8 @@
|
||||
"fontSize": "Font size",
|
||||
"fontFamily": "Font family",
|
||||
"onlySelected": "Only selected",
|
||||
"withBackground": "With background",
|
||||
"exportEmbedScene": "Embed scene into exported file",
|
||||
"withBackground": "Background",
|
||||
"exportEmbedScene": "Embed scene",
|
||||
"exportEmbedScene_details": "Scene data will be saved into the exported PNG/SVG file so that the scene can be restored from it.\nWill increase exported file size.",
|
||||
"addWatermark": "Add \"Made with Excalidraw\"",
|
||||
"handDrawn": "Hand-drawn",
|
||||
@@ -96,17 +100,20 @@
|
||||
"flipVertical": "Flip vertical",
|
||||
"viewMode": "View mode",
|
||||
"toggleExportColorScheme": "Toggle export color scheme",
|
||||
"share": "Share"
|
||||
"share": "Share",
|
||||
"toggleTheme": "Toggle theme"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Reset the canvas",
|
||||
"exportJSON": "Export to file",
|
||||
"exportImage": "Save as image",
|
||||
"export": "Export",
|
||||
"exportToPng": "Export to PNG",
|
||||
"exportToSvg": "Export to SVG",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copyPngToClipboard": "Copy PNG to clipboard",
|
||||
"scale": "Scale",
|
||||
"save": "Save",
|
||||
"save": "Save to current file",
|
||||
"saveAs": "Save as",
|
||||
"load": "Load",
|
||||
"getShareableLink": "Get shareable link",
|
||||
@@ -142,6 +149,8 @@
|
||||
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
|
||||
"collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
|
||||
"errorLoadingLibrary": "There was an error loading the third party library.",
|
||||
"errorAddingToLibrary": "Couldn't add item to the library",
|
||||
"errorRemovingFromLibrary": "Couldn't remove item from the library",
|
||||
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
|
||||
"imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",
|
||||
"cannotRestoreFromImage": "Scene couldn't be restored from this image file",
|
||||
@@ -150,12 +159,12 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selection",
|
||||
"draw": "Free draw",
|
||||
"rectangle": "Rectangle",
|
||||
"diamond": "Diamond",
|
||||
"ellipse": "Ellipse",
|
||||
"arrow": "Arrow",
|
||||
"line": "Line",
|
||||
"freedraw": "Draw",
|
||||
"text": "Text",
|
||||
"library": "Library",
|
||||
"lock": "Keep selected tool active after drawing"
|
||||
@@ -208,6 +217,14 @@
|
||||
"errorDialog": {
|
||||
"title": "Error"
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Save to disk",
|
||||
"disk_details": "Export the scene data to a file from which you can import later.",
|
||||
"disk_button": "Save to file",
|
||||
"link_title": "Shareable link",
|
||||
"link_details": "Export as a read-only link.",
|
||||
"link_button": "Export to Link"
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "Read our blog",
|
||||
"click": "click",
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"loadSceneOverridePrompt": "Si carga este dibujo externo, reemplazará el que tiene. ¿Desea continuar?",
|
||||
"collabStopOverridePrompt": "Detener la sesión sobrescribirá su dibujo anterior almacenado en local. ¿Estás seguro?\n\n(Si quieres mantener tu dibujo en local, simplemente cierre la pestaña del navegador en su lugar.)",
|
||||
"errorLoadingLibrary": "Se ha producido un error al cargar la biblioteca de terceros.",
|
||||
"errorAddingToLibrary": "No se pudo agregar elemento a la biblioteca",
|
||||
"errorRemovingFromLibrary": "No se pudo quitar el elemento de la biblioteca",
|
||||
"confirmAddLibrary": "Esto añadirá {{numShapes}} forma(s) a tu biblioteca. ¿Estás seguro?",
|
||||
"imageDoesNotContainScene": "La importación de imágenes no está homologada en este momento.\n\n¿Deseas importar una escena? Esta imagen no parece contener ningún dato de escena. ¿Lo has activado durante la exportación?",
|
||||
"cannotRestoreFromImage": "No se pudo restaurar la escena desde este archivo de imagen",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selección",
|
||||
"draw": "Dibujo libre",
|
||||
"freedraw": "Dibujo libre",
|
||||
"rectangle": "Rectángulo",
|
||||
"diamond": "Diamante",
|
||||
"ellipse": "Elipse",
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"loadSceneOverridePrompt": "بارگزاری یک طرح خارجی محتوای فعلی رو از بین میبرد. آیا میخواهید ادامه دهید؟",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorLoadingLibrary": "خطایی در بارگذاری کتابخانه ثالث وجود داشت.",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "{{numShapes}} از اشکال به کتابخانه شما اضافه خواهد شد. مطمئن هستید؟",
|
||||
"imageDoesNotContainScene": "وارد کردن تصویر در این لحظه امکان پذیر نمی باشد.\nآیا مایل به وارد کردن یک صحنه هستید؟ این تصویر به نظر می رسد که فاقد هرگونه اطلاعاتی مربوط به صحنه باشد. آیا این گزینه را در زمان وارد کردن تصویر فعال کرده اید؟",
|
||||
"cannotRestoreFromImage": "صحنه را نمی توان از این فایل تصویری بازیابی کرد",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "گزینش",
|
||||
"draw": "طراحی آزاد",
|
||||
"freedraw": "طراحی آزاد",
|
||||
"rectangle": "مستطیل",
|
||||
"diamond": "لوزی",
|
||||
"ellipse": "بیضی",
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"loadSceneOverridePrompt": "Ulkopuolisen piirroksen lataaminen korvaa nykyisen sisältösi. Jatketaanko?",
|
||||
"collabStopOverridePrompt": "Istunnon lopettaminen korvaa aiemman, paikallisesti tallennetun piirustuksen. Jatketaanko?\n\n(Jos haluat säilyttää paikallisesti tallennetun piirustuksen, sulje selaimen välilehti lopettamisen sijaan.)",
|
||||
"errorLoadingLibrary": "Virhe ladattaessa kolmannen osapuolen kirjastoa.",
|
||||
"errorAddingToLibrary": "Kohdetta ei voitu lisätä kirjastoon",
|
||||
"errorRemovingFromLibrary": "Kohdetta ei voitu poistaa kirjastosta",
|
||||
"confirmAddLibrary": "Tämä lisää {{numShapes}} muotoa kirjastoosi. Jatketaanko?",
|
||||
"imageDoesNotContainScene": "Kuvien lisääminen ei ole tällä hetkellä mahdollista.\n\nHaluatko tuoda piirroksen? Tämä kuva ei näytä sisältävän tarvittavia tietoja. Oletko ottanut piirrostietojen tallennuksen käyttöön viennin aikana?",
|
||||
"cannotRestoreFromImage": "Teosta ei voitu palauttaa tästä kuvatiedostosta",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Valinta",
|
||||
"draw": "Vapaa piirto",
|
||||
"freedraw": "Vapaa piirto",
|
||||
"rectangle": "Suorakulmio",
|
||||
"diamond": "Vinoneliö",
|
||||
"ellipse": "Soikio",
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"loadSceneOverridePrompt": "Le chargement d'un dessin externe remplacera votre contenu actuel. Souhaitez-vous continuer ?",
|
||||
"collabStopOverridePrompt": "Arrêter la session écrasera votre précédent dessin stocké localement. Êtes-vous sûr·e ?\n\n(Si vous voulez garder votre dessin local, fermez simplement l'onglet du navigateur à la place.)",
|
||||
"errorLoadingLibrary": "Une erreur s'est produite lors du chargement de la bibliothèque tierce.",
|
||||
"errorAddingToLibrary": "Impossible d'ajouter l'élément à la bibliothèque",
|
||||
"errorRemovingFromLibrary": "Impossible de retirer l'élément de la bibliothèque",
|
||||
"confirmAddLibrary": "Cela va ajouter {{numShapes}} forme(s) à votre bibliothèque. Êtes-vous sûr·e ?",
|
||||
"imageDoesNotContainScene": "L'importation d'images n'est pas prise en charge pour le moment.\n\nVouliez-vous importer une scène ? Cette image ne semble pas contenir de données de scène. Avez-vous activé cette option lors de l'exportation ?",
|
||||
"cannotRestoreFromImage": "Impossible de restaurer la scène depuis ce fichier image",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Sélection",
|
||||
"draw": "Dessin libre",
|
||||
"freedraw": "Dessin libre",
|
||||
"rectangle": "Rectangle",
|
||||
"diamond": "Losange",
|
||||
"ellipse": "Ellipse",
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"loadSceneOverridePrompt": "טעינה של ציור חיצוני תחליף את התוכן הקיים שלך. האם תרצה להמשיך?",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorLoadingLibrary": "קרתה שגיאה בטעינת הספריה החיצונית.",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "הפעולה תוסיף {{numShapes}} צורה(ות) לספריה שלך. האם אתה בטוח?",
|
||||
"imageDoesNotContainScene": "אין תמיכה בייבוא תמונות כעת.\n\nהאם אתה רוצה לייבא תצוגה? התמונה הזאת אינה מכילה מידע על תצוגה. האם הפעלת את האפשרות הזאת בזמן הוצאת המידע?",
|
||||
"cannotRestoreFromImage": "לא הצלחנו לשחזר את התצוגה מקובץ התמונה",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "בחירה",
|
||||
"draw": "ציור חופשי",
|
||||
"freedraw": "ציור חופשי",
|
||||
"rectangle": "מרובע",
|
||||
"diamond": "מעוין",
|
||||
"ellipse": "אליפסה",
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"loadSceneOverridePrompt": "लोड हो रहा है बाहरी ड्राइंग आपके मौजूदा सामग्री को बदल देगा। क्या आप जारी रखना चाहते हैं?",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorLoadingLibrary": "लाइब्रेरी लोड करने में त्रुटि",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "लाइब्रेरी जोड़ें पुष्टि करें आकार संख्या",
|
||||
"imageDoesNotContainScene": "दृश्य में छवि नहीं है",
|
||||
"cannotRestoreFromImage": "छवि फ़ाइल बहाल दृश्य नहीं है",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "चयन",
|
||||
"draw": "मुफ्त ड्रा",
|
||||
"freedraw": "मुफ्त ड्रा",
|
||||
"rectangle": "आयात",
|
||||
"diamond": "तिर्यग्वर्ग",
|
||||
"ellipse": "दीर्घवृत्त",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user