Compare commits

..

8 Commits

Author SHA1 Message Date
Aakansha Doshi e9d5b41fbf tweaks
Co-authored-by: David Luzar <luzar.david@gmail.com>
2021-04-11 19:01:38 +05:30
Aakansha Doshi ef5614b697 Update src/packages/excalidraw/CHANGELOG.md 2021-04-11 14:26:50 +05:30
Aakansha Doshi cd5d9111e9 tweak docs 2021-04-11 14:20:24 +05:30
Aakansha Doshi 8467093e83 update 2021-04-11 14:16:00 +05:30
Aakansha Doshi 504ea987b0 update docs 2021-04-11 14:08:50 +05:30
Aakansha Doshi ac08f433e9 update prop name to detectPosition 2021-04-11 13:49:40 +05:30
Aakansha Doshi 4fb906baad disable detectPositionChange by default 2021-04-11 01:40:46 +05:30
Aakansha Doshi bfc8415554 feat: poll to detect if position of excalidraw was updated and allow consumer to disable it 2021-04-10 03:09:56 +05:30
179 changed files with 3884 additions and 7600 deletions
@@ -1,26 +0,0 @@
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
View File
@@ -102,20 +102,6 @@ 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 |
+5 -7
View File
@@ -29,13 +29,12 @@
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.16.4",
"clsx": "1.1.1",
"firebase": "8.3.3",
"firebase": "8.3.2",
"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",
@@ -44,8 +43,8 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-scripts": "4.0.3",
"roughjs": "4.4.1",
"sass": "1.32.10",
"roughjs": "4.3.1",
"sass": "1.32.8",
"socket.io-client": "2.3.1",
"typescript": "4.2.4"
},
@@ -55,7 +54,7 @@
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1",
"@types/resize-observer-browser": "0.1.5",
"eslint-config-prettier": "8.3.0",
"eslint-config-prettier": "8.1.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.9.0",
"husky": "4.3.8",
@@ -104,7 +103,6 @@
"test:other": "yarn prettier --list-different",
"test:typecheck": "tsc",
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
"test": "yarn test:app",
"autorelease": "node scripts/autorelease.js"
"test": "yarn test:app"
}
}
+3 -21
View File
@@ -107,17 +107,15 @@
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style>
body,
html {
body {
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: 100%;
height: 100%;
overflow: hidden;
width: 100vw;
height: 100vh;
}
.visually-hidden {
@@ -127,7 +125,6 @@
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
user-select: none;
}
.LoadingMessage {
@@ -152,21 +149,6 @@
}
#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
View File
@@ -1,51 +0,0 @@
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();
});
-6
View File
@@ -37,8 +37,6 @@ const crowdinMap = {
"uk-UA": "en-uk",
"zh-CN": "en-zhcn",
"zh-TW": "en-zhtw",
"lv-LV": "en-lv",
"cs-CZ": "cs-cz",
};
const flags = {
@@ -76,8 +74,6 @@ const flags = {
"uk-UA": "🇺🇦",
"zh-CN": "🇨🇳",
"zh-TW": "🇹🇼",
"lv-LV": "🇱🇻",
"cs-CZ": "🇨🇿",
};
const languages = {
@@ -115,8 +111,6 @@ const languages = {
"uk-UA": "Українська",
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"lv-LV": "Latviešu",
"cs-CZ": "Česky",
};
const percentages = fs.readFileSync(
+4 -6
View File
@@ -2,20 +2,18 @@ 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, _, app) => {
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
app.library.loadLibrary().then((items) => {
app.library.saveLibrary([
...items,
selectedElements.map(deepCopyElement),
]);
Library.loadLibrary().then((items) => {
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
});
return false;
},
+4 -32
View File
@@ -3,7 +3,6 @@ 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";
@@ -22,8 +21,8 @@ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor,
appState: { ...appState, viewBackgroundColor: value },
commitToHistory: true,
};
},
PanelComponent: ({ appState, updateData }) => {
@@ -33,11 +32,7 @@ export const actionChangeViewBackgroundColor = register({
label={t("labels.canvasBackground")}
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={(color) => updateData({ viewBackgroundColor: color })}
isActive={appState.openMenu === "canvasColorPicker"}
setActive={(active) =>
updateData({ openMenu: active ? "canvasColorPicker" : null })
}
onChange={(color) => updateData(color)}
data-testid="canvas-background-picker"
/>
</div>
@@ -59,6 +54,7 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,
},
@@ -264,27 +260,3 @@ 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,
});
+2
View File
@@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
@@ -88,6 +89,7 @@ export const actionCopyAsPng = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
+48 -20
View File
@@ -11,8 +11,7 @@ import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { KEYS } from "../keys";
import { register } from "./register";
import { supported as fsSupported } from "browser-fs-access";
import { CheckboxItem } from "../components/CheckboxItem";
import { supported } from "browser-fs-access";
export const actionChangeProjectName = register({
name: "changeProjectName",
@@ -41,12 +40,14 @@ export const actionChangeExportBackground = register({
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
checked={appState.exportBackground}
onChange={(checked) => updateData(checked)}
>
<label>
<input
type="checkbox"
checked={appState.exportBackground}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.withBackground")}
</CheckboxItem>
</label>
),
});
@@ -59,20 +60,46 @@ export const actionChangeExportEmbedScene = register({
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
checked={appState.exportEmbedScene}
onChange={(checked) => updateData(checked)}
>
<label style={{ display: "flex" }}>
<input
type="checkbox"
checked={appState.exportEmbedScene}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
<div className="Tooltip-icon">{questionCircle}</div>
<Tooltip
label={t("labels.exportEmbedScene_details")}
position="above"
long={true}
>
<div className="TooltipIcon">{questionCircle}</div>
</Tooltip>
</CheckboxItem>
</label>
),
});
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
export const actionChangeShouldAddWatermark = register({
name: "changeShouldAddWatermark",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, shouldAddWatermark: value },
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<label>
<input
type="checkbox"
checked={appState.shouldAddWatermark}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.addWatermark")}
</label>
),
});
export const actionSaveScene = register({
name: "saveScene",
perform: async (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle;
try {
@@ -103,18 +130,19 @@ export const actionSaveToActiveFile = register({
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData }) => (
<ToolButton
type="icon"
type="button"
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
showAriaLabel={useIsMobile()}
onClick={() => updateData(null)}
data-testid="save-button"
/>
),
});
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
export const actionSaveAsScene = register({
name: "saveAsScene",
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(elements, {
@@ -138,7 +166,7 @@ export const actionSaveFileToDisk = register({
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()}
hidden={!fsSupported}
hidden={!supported}
onClick={() => updateData(null)}
data-testid="save-as-button"
/>
+10 -12
View File
@@ -18,7 +18,7 @@ import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({
name: "finalize",
perform: (elements, appState, _, { canvas, focusContainer }) => {
perform: (elements, appState, _, { canvas }) => {
if (appState.editingLinearElement) {
const {
elementId,
@@ -51,19 +51,19 @@ export const actionFinalize = register({
let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) {
focusContainer();
window.document.activeElement.blur();
}
const multiPointElement = appState.multiElement
? appState.multiElement
: appState.editingElement?.type === "freedraw"
: appState.editingElement?.type === "draw"
? appState.editingElement
: null;
if (multiPointElement) {
// pen and mouse have hover
if (
multiPointElement.type !== "freedraw" &&
multiPointElement.type !== "draw" &&
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 === "freedraw"
multiPointElement.type === "draw"
) {
if (isLoop) {
const linePoints = multiPointElement.points;
@@ -118,24 +118,22 @@ export const actionFinalize = register({
);
}
if (!appState.elementLocked && appState.elementType !== "freedraw") {
if (!appState.elementLocked && appState.elementType !== "draw") {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
(!appState.elementLocked && appState.elementType !== "freedraw") ||
(!appState.elementLocked && appState.elementType !== "draw") ||
!multiPointElement
) {
resetCursor(canvas);
}
return {
elements: newElements,
appState: {
...appState,
elementType:
(appState.elementLocked || appState.elementType === "freedraw") &&
(appState.elementLocked || appState.elementType === "draw") &&
multiPointElement
? appState.elementType
: "selection",
@@ -147,14 +145,14 @@ export const actionFinalize = register({
selectedElementIds:
multiPointElement &&
!appState.elementLocked &&
appState.elementType !== "freedraw"
appState.elementType !== "draw"
? {
...appState.selectedElementIds,
[multiPointElement.id]: true,
}
: appState.selectedElementIds,
},
commitToHistory: appState.elementType === "freedraw",
commitToHistory: appState.elementType === "draw",
};
},
keyTest: (event, appState) =>
+2 -2
View File
@@ -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 { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
import { 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) || isFreeDrawElement(element)) {
if (isLinearElement(element)) {
finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width;
+2 -2
View File
@@ -3,7 +3,7 @@ import React from "react";
import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import History, { HistoryEntry } from "../history";
import { SceneHistory, 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: History) => Action;
type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
+1 -4
View File
@@ -70,10 +70,7 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) {
focusContainer();
}
perform: (_elements, appState) => {
return {
appState: {
...appState,
+16 -34
View File
@@ -99,18 +99,13 @@ export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
}),
),
}),
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeColor: value,
}),
),
appState: { ...appState, currentItemStrokeColor: value },
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -125,11 +120,7 @@ export const actionChangeStrokeColor = register({
(element) => element.strokeColor,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openMenu === "strokeColorPicker"}
setActive={(active) =>
updateData({ openMenu: active ? "strokeColorPicker" : null })
}
onChange={updateData}
/>
</>
),
@@ -139,18 +130,13 @@ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
perform: (elements, appState, value) => {
return {
...(value.currentItemBackgroundColor && {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
}),
),
}),
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemBackgroundColor,
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value,
}),
),
appState: { ...appState, currentItemBackgroundColor: value },
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -165,11 +151,7 @@ export const actionChangeBackgroundColor = register({
(element) => element.backgroundColor,
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
isActive={appState.openMenu === "backgroundColorPicker"}
setActive={(active) =>
updateData({ openMenu: active ? "backgroundColorPicker" : null })
}
onChange={updateData}
/>
</>
),
-3
View File
@@ -1,5 +1,4 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({
name: "stats",
@@ -14,6 +13,4 @@ export const actionToggleStats = register({
},
checked: (appState) => appState.showStats,
contextItemLabel: "stats.title",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
});
+2 -3
View File
@@ -26,7 +26,6 @@ export {
actionZoomOut,
actionResetZoom,
actionZoomToFit,
actionToggleTheme,
} from "./actionCanvas";
export { actionFinalize } from "./actionFinalize";
@@ -34,8 +33,8 @@ export { actionFinalize } from "./actionFinalize";
export {
actionChangeProjectName,
actionChangeExportBackground,
actionSaveToActiveFile,
actionSaveFileToDisk,
actionSaveScene,
actionSaveAsScene,
actionLoadScene,
} from "./actionExport";
+2 -8
View File
@@ -9,16 +9,10 @@ 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;
focusContainer: () => void;
props: AppProps;
library: Library;
};
type App = { canvas: HTMLCanvasElement | null; props: AppProps };
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@@ -57,7 +51,7 @@ export class ActionManager implements ActionsManagerInterface {
actions.forEach((action) => this.registerAction(action));
}
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
handleKeyDown(event: KeyboardEvent) {
const canvasActions = this.app.props.UIOptions.canvasActions;
const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
+1 -1
View File
@@ -57,7 +57,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
stats: [getShortcutKey("Alt+/")],
stats: [],
addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")],
+7 -16
View File
@@ -1,7 +1,6 @@
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 =
@@ -16,17 +15,11 @@ export type ActionResult =
}
| false;
type AppAPI = {
canvas: HTMLCanvasElement | null;
focusContainer(): void;
library: Library;
};
type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: AppAPI,
app: { canvas: HTMLCanvasElement | null },
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
@@ -52,7 +45,6 @@ export type ActionName =
| "changeBackgroundColor"
| "changeFillStyle"
| "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
@@ -66,8 +58,9 @@ export type ActionName =
| "changeProjectName"
| "changeExportBackground"
| "changeExportEmbedScene"
| "saveToActiveFile"
| "saveFileToDisk"
| "changeShouldAddWatermark"
| "saveScene"
| "saveAsScene"
| "loadScene"
| "duplicateSelection"
| "deleteSelectedElements"
@@ -98,8 +91,7 @@ export type ActionName =
| "flipHorizontal"
| "flipVertical"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme";
| "exportWithDarkMode";
export interface Action {
name: ActionName;
@@ -113,7 +105,7 @@ export interface Action {
perform: ActionFn;
keyPriority?: number;
keyTest?: (
event: React.KeyboardEvent | KeyboardEvent,
event: KeyboardEvent,
appState: AppState,
elements: readonly ExcalidrawElement[],
) => boolean;
@@ -128,7 +120,6 @@ export interface Action {
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
handleKeyDown: (event: KeyboardEvent) => boolean;
renderAction: (name: ActionName) => React.ReactElement | null;
executeAction: (action: Action) => void;
}
+2
View File
@@ -61,6 +61,7 @@ export const getDefaultAppState = (): Omit<
selectedElementIds: {},
selectedGroupIds: {},
selectionElement: null,
shouldAddWatermark: false,
shouldCacheIgnoreZoom: false,
showHelpDialog: false,
showStats: false,
@@ -140,6 +141,7 @@ const APP_STATE_STORAGE_CONF = (<
selectedElementIds: { browser: true, export: false },
selectedGroupIds: { browser: true, export: false },
selectionElement: { browser: false, export: false },
shouldAddWatermark: { browser: true, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false },
showHelpDialog: { browser: false, export: false },
showStats: { browser: true, export: false },
+3 -1
View File
@@ -6,6 +6,7 @@ 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 = {
@@ -151,7 +152,8 @@ export const parseClipboard = async (
}
};
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }),
]);
+4 -12
View File
@@ -9,8 +9,7 @@ import {
canHaveArrowheads,
getTargetElements,
hasBackground,
hasStrokeStyle,
hasStrokeWidth,
hasStroke,
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
@@ -54,17 +53,10 @@ export const SelectedShapeActions = ({
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")}
{(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))) && (
{(hasStroke(elementType) ||
targetElements.some((element) => hasStroke(element.type))) && (
<>
{renderAction("changeStrokeWidth")}
{renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")}
</>
+364 -577
View File
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import React from "react";
import { ActionManager } from "../actions/manager";
import { AppState } from "../types";
import { DarkModeToggle } from "./DarkModeToggle";
export const BackgroundPickerAndDarkModeToggle = ({
appState,
@@ -15,10 +16,14 @@ export const BackgroundPickerAndDarkModeToggle = ({
}) => (
<div style={{ display: "flex" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
{showThemeBtn && actionManager.renderAction("toggleTheme")}
{appState.fileHandle && (
{showThemeBtn && (
<div style={{ marginInlineStart: "0.25rem" }}>
{actionManager.renderAction("saveToActiveFile")}
<DarkModeToggle
value={appState.theme}
onChange={(theme) => {
setAppState({ theme });
}}
/>
</div>
)}
</div>
-53
View File
@@ -1,53 +0,0 @@
@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
View File
@@ -1,20 +0,0 @@
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>
);
};
-89
View File
@@ -1,89 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.Checkbox {
margin: 4px 0.3em;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$oc-blue-4};
}
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
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
View File
@@ -1,26 +0,0 @@
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>
);
};
+1 -1
View File
@@ -160,7 +160,7 @@
}
.color-picker-input {
width: 11ch; /* length of `transparent` */
width: 12ch; /* length of `transparent` + 1 */
margin: 0;
font-size: 1rem;
background-color: var(--input-bg-color);
+1 -5
View File
@@ -115,7 +115,6 @@ const Picker = ({
onClose();
}
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
};
return (
@@ -238,16 +237,13 @@ export const ColorPicker = ({
color,
onChange,
label,
isActive,
setActive,
}: {
type: "canvasBackground" | "elementBackground" | "elementStroke";
color: string | null;
onChange: (color: string) => void;
label: string;
isActive: boolean;
setActive: (active: boolean) => void;
}) => {
const [isActive, setActive] = React.useState(false);
const pickerButton = React.useRef<HTMLButtonElement>(null);
return (
+22 -11
View File
@@ -2,7 +2,6 @@ import "./ToolIcon.scss";
import React from "react";
import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
export type Appearence = "light" | "dark";
@@ -13,19 +12,31 @@ export const DarkModeToggle = (props: {
onChange: (value: Appearence) => void;
title?: string;
}) => {
const title =
props.title ||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
const title = props.title
? props.title
: props.value === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode");
return (
<ToolButton
type="icon"
icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
title={title}
aria-label={title}
onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
<label
className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
data-testid="toggle-dark-mode"
/>
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>
);
};
+3 -12
View File
@@ -1,5 +1,5 @@
import clsx from "clsx";
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
@@ -8,7 +8,6 @@ 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;
@@ -17,10 +16,8 @@ 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) {
@@ -68,25 +65,19 @@ 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={onClose}
theme={props.theme}
onCloseRequest={props.onCloseRequest}
>
<Island ref={setIslandNode}>
<h2 id="dialog-title" className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
<button
className="Modal__close"
onClick={onClose}
onClick={props.onCloseRequest}
aria-label={t("buttons.close")}
>
{useIsMobile() ? back : close}
+1 -5
View File
@@ -2,7 +2,6 @@ import React, { useState } from "react";
import { t } from "../i18n";
import { Dialog } from "./Dialog";
import { useExcalidrawContainer } from "./App";
export const ErrorDialog = ({
message,
@@ -12,7 +11,6 @@ export const ErrorDialog = ({
onClose?: () => void;
}) => {
const [modalIsShown, setModalIsShown] = useState(!!message);
const excalidrawContainer = useExcalidrawContainer();
const handleClose = React.useCallback(() => {
setModalIsShown(false);
@@ -20,9 +18,7 @@ export const ErrorDialog = ({
if (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]);
}, [onClose]);
return (
<>
+27 -58
View File
@@ -28,6 +28,33 @@
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;
@@ -57,62 +84,4 @@
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: none;
}
svg {
width: 0.9em;
}
}
}
@@ -6,20 +6,16 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { useIsMobile } from "../components/App";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types";
import { Dialog } from "./Dialog";
import { clipboard, exportImage } from "./icons";
import "./ExportDialog.scss";
import { clipboard, exportFile, link } 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;
@@ -56,30 +52,7 @@ export type ExportCB = (
scale?: number,
) => void;
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 = ({
const ExportModal = ({
elements,
appState,
exportPadding = 10,
@@ -87,6 +60,7 @@ const ImageExportModal = ({
onExportToPng,
onExportToSvg,
onExportToClipboard,
onExportToBackend,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
@@ -95,13 +69,18 @@ const ImageExportModal = ({
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB;
onCloseRequest: () => void;
}) => {
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [scale, setScale] = useState(defaultScale);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const {
exportBackground,
viewBackgroundColor,
shouldAddWatermark,
} = appState;
const exportedElements = exportSelected
? getSelectedElements(elements, appState)
@@ -122,6 +101,7 @@ const ImageExportModal = ({
viewBackgroundColor,
exportPadding,
scale,
shouldAddWatermark,
});
// if converting to blob fails, there's some problem that will
@@ -145,6 +125,7 @@ const ImageExportModal = ({
exportPadding,
viewBackgroundColor,
scale,
shouldAddWatermark,
]);
return (
@@ -152,102 +133,98 @@ const ImageExportModal = ({
<div className="ExportDialog__preview" ref={previewRef} />
{supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")}
<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")}
</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,
_scale,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${_scale}x (${width}x${height})`;
return (
<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
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)}
type="button"
icon={clipboard}
title={t("buttons.copyPngToClipboard")}
aria-label={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements, 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>
)}
{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)
}
/>{" "}
{t("labels.onlySelected")}
</label>
</div>
)}
</Stack.Row>
{actionManager.renderAction("changeExportEmbedScene")}
{actionManager.renderAction("changeShouldAddWatermark")}
</Stack.Col>
</div>
);
};
export const ImageExportDialog = ({
export const ExportDialog = ({
elements,
appState,
exportPadding = 10,
@@ -255,6 +232,7 @@ export const ImageExportDialog = ({
onExportToPng,
onExportToSvg,
onExportToClipboard,
onExportToBackend,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
@@ -263,11 +241,14 @@ export const ImageExportDialog = ({
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 (
@@ -276,16 +257,17 @@ export const ImageExportDialog = ({
onClick={() => {
setModalIsShown(true);
}}
data-testid="image-export-button"
icon={exportImage}
data-testid="export-button"
icon={exportFile}
type="button"
aria-label={t("buttons.exportImage")}
aria-label={t("buttons.export")}
showAriaLabel={useIsMobile()}
title={t("buttons.exportImage")}
title={t("buttons.export")}
ref={triggerButton}
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
<ImageExportModal
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<ExportModal
elements={elements}
appState={appState}
exportPadding={exportPadding}
@@ -293,6 +275,7 @@ export const ImageExportDialog = ({
onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg}
onExportToClipboard={onExportToClipboard}
onExportToBackend={onExportToBackend}
onCloseRequest={handleClose}
/>
</Dialog>
+10 -9
View File
@@ -1,5 +1,6 @@
.excalidraw {
.FixedSideContainer {
--margin: 0.25rem;
position: absolute;
pointer-events: none;
}
@@ -9,9 +10,9 @@
}
.FixedSideContainer_side_top {
left: var(--space-factor);
top: var(--space-factor);
right: var(--space-factor);
left: var(--margin);
top: var(--margin);
right: var(--margin);
z-index: 2;
}
@@ -22,16 +23,16 @@
/* TODO: if these are used, make sure to implement RTL support
.FixedSideContainer_side_left {
left: var(--space-factor);
top: var(--space-factor);
bottom: var(--space-factor);
left: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 1;
}
.FixedSideContainer_side_right {
right: var(--space-factor);
top: var(--space-factor);
bottom: var(--space-factor);
right: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 3;
}
*/
@@ -3,19 +3,13 @@ import React from "react";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: "light" | "dark"; dir: string }) => (
({ theme }: { theme: "light" | "dark" }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl"
? "marginLeft"
: "marginRight"]: "calc(var(--space-factor) * -1)",
}}
className="github-corner rtl-mirror"
>
<a
href="https://github.com/excalidraw/excalidraw"
@@ -25,18 +19,18 @@ export const GitHubCorner = React.memo(
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === "light" ? oc.gray[6] : oc.gray[7]}
fill={theme === "light" ? oc.gray[6] : oc.gray[8]}
/>
<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 : "var(--default-bg-color)"}
fill={theme === "light" ? oc.white : oc.black}
/>
<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 : "var(--default-bg-color)"}
fill={theme === "light" ? oc.white : oc.black}
/>
</a>
</svg>
+1 -17
View File
@@ -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.freedraw")}
label={t("toolBar.draw")}
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
@@ -231,14 +231,6 @@ 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>
@@ -365,14 +357,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.flipVertical")}
shortcuts={[getShortcutKey("Shift+V")]}
/>
<Shortcut
label={t("labels.showStroke")}
shortcuts={[getShortcutKey("S")]}
/>
<Shortcut
label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]}
/>
</ShortcutIsland>
</Column>
</Columns>
+1 -1
View File
@@ -23,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.linearElementMulti");
}
if (elementType === "freedraw") {
if (elementType === "draw") {
return t("hints.freeDraw");
}
-1
View File
@@ -88,7 +88,6 @@ function Picker<T>({
onClose();
}
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
};
return (
+1
View File
@@ -2,6 +2,7 @@
.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));
-127
View File
@@ -1,127 +0,0 @@
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, ExportOpts } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
import { ToolButton } from "./ToolButton";
import { actionSaveFileToDisk } 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,
exportOpts,
canvas,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
const { onExportToBackend } = exportOpts;
return (
<div className="ExportDialog ExportDialog--json">
<div className="ExportDialog-cards">
{exportOpts.saveFileToDisk && (
<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(actionSaveFileToDisk);
}}
/>
</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, appState, canvas)}
/>
</Card>
)}
{exportOpts.renderCustomUI &&
exportOpts.renderCustomUI(elements, appState, canvas)}
</div>
</div>
);
};
export const JSONExportDialog = ({
elements,
appState,
actionManager,
exportOpts,
canvas,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
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}
onCloseRequest={handleClose}
exportOpts={exportOpts}
canvas={canvas}
/>
</Dialog>
)}
</>
);
};
+40 -22
View File
@@ -40,17 +40,50 @@
.layer-ui__wrapper {
z-index: var(--zIndex-layerUI);
&__top-right {
.encrypted-icon {
position: relative;
margin-inline-start: 15px;
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 {
width: 100%;
position: absolute;
z-index: 100;
bottom: 0;
&-right {
z-index: 100;
display: flex;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
width: 190px;
}
.zen-mode-transition {
@@ -72,16 +105,12 @@
transform: translate(-999px, 0);
}
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
:root[dir="ltr"] &.App-menu_bottom--transition-left {
transform: translate(-92px, 0);
}
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
:root[dir="rtl"] &.App-menu_bottom--transition-left {
transform: translate(92px, 0);
}
&.layer-ui__wrapper__footer-left--transition-bottom {
transform: translate(0, 92px);
}
}
.disable-zen-mode {
@@ -108,16 +137,5 @@
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;
}
}
}
+153 -185
View File
@@ -10,6 +10,7 @@ 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";
@@ -28,10 +29,11 @@ import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { ExportCB, ExportDialog } from "./ExportDialog";
import { FixedSideContainer } from "./FixedSideContainer";
import { GitHubCorner } from "./GitHubCorner";
import { HintViewer } from "./HintViewer";
import { exportFile, load, trash } from "./icons";
import { exportFile, load, shield, trash } from "./icons";
import { Island } from "./Island";
import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
@@ -45,8 +47,6 @@ 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;
@@ -63,14 +63,15 @@ interface LayerUIProps {
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
library: Library;
id: string;
}
const useOnClickOutside = (
@@ -102,7 +103,7 @@ const useOnClickOutside = (
};
const LibraryMenuItems = ({
libraryItems,
library,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
@@ -110,11 +111,8 @@ const LibraryMenuItems = ({
setAppState,
setLibraryItems,
libraryReturnUrl,
focusContainer,
library,
id,
}: {
libraryItems: LibraryItems;
library: LibraryItems;
pendingElements: LibraryItem;
onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: LibraryItem) => void;
@@ -122,12 +120,9 @@ 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 = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
const numCells = library.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 = [];
@@ -145,7 +140,7 @@ const LibraryMenuItems = ({
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
importLibraryFromJSON(library)
importLibraryFromJSON()
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
@@ -157,7 +152,7 @@ const LibraryMenuItems = ({
});
}}
/>
{!!libraryItems.length && (
{!!library.length && (
<>
<ToolButton
key="export"
@@ -166,7 +161,7 @@ const LibraryMenuItems = ({
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON(library)
saveLibraryAsJSON()
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
@@ -181,9 +176,8 @@ const LibraryMenuItems = ({
icon={trash}
onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) {
library.resetLibrary();
Library.resetLibrary();
setLibraryItems([]);
focusContainer();
}
}}
/>
@@ -192,7 +186,7 @@ const LibraryMenuItems = ({
<a
href={`https://libraries.excalidraw.com?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}`}
}&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
@@ -207,13 +201,13 @@ const LibraryMenuItems = ({
const shouldAddPendingElements: boolean =
pendingElements.length > 0 &&
!addedPendingElements &&
y + x >= libraryItems.length;
y + x >= library.length;
addedPendingElements = addedPendingElements || shouldAddPendingElements;
children.push(
<Stack.Col key={x}>
<LibraryUnit
elements={libraryItems[y + x]}
elements={library[y + x]}
pendingElements={
shouldAddPendingElements ? pendingElements : undefined
}
@@ -221,7 +215,7 @@ const LibraryMenuItems = ({
onClick={
shouldAddPendingElements
? onAddToLibrary.bind(null, pendingElements)
: onInsertShape.bind(null, libraryItems[y + x])
: onInsertShape.bind(null, library[y + x])
}
/>
</Stack.Col>,
@@ -248,9 +242,6 @@ const LibraryMenu = ({
onAddToLibrary,
setAppState,
libraryReturnUrl,
focusContainer,
library,
id,
}: {
pendingElements: LibraryItem;
onClickOutside: (event: MouseEvent) => void;
@@ -258,9 +249,6 @@ 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) => {
@@ -286,7 +274,7 @@ const LibraryMenu = ({
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
Library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
@@ -298,33 +286,24 @@ 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).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setLibraryItems(nextItems);
},
[library, setAppState],
);
const removeFromLibrary = useCallback(async (indexToRemove) => {
const items = await Library.loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
}, []);
const addToLibrary = useCallback(
async (elements: LibraryItem) => {
const items = await library.loadLibrary();
const items = await Library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
[onAddToLibrary],
);
return loadingState === "preloading" ? null : (
@@ -335,7 +314,7 @@ const LibraryMenu = ({
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
library={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
@@ -343,9 +322,6 @@ const LibraryMenu = ({
setAppState={setAppState}
setLibraryItems={setLibraryItems}
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
id={id}
/>
)}
</Island>
@@ -366,71 +342,75 @@ const LayerUI = ({
showThemeBtn,
toggleZenMode,
isCollaborating,
renderTopRightUI,
onExportToBackend,
renderCustomFooter,
viewModeEnabled,
libraryReturnUrl,
UIOptions,
focusContainer,
library,
id,
}: LayerUIProps) => {
const isMobile = useIsMobile();
const renderJSONExportDialog = () => {
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 = () => {
if (!UIOptions.canvasActions.export) {
return null;
}
return (
<JSONExportDialog
elements={elements}
appState={appState}
actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
/>
);
};
const renderImageExportDialog = () => {
if (!UIOptions.canvasActions.saveAsImage) {
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,
})
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
setAppState({ errorMessage: error.message });
});
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 (
<ImageExportDialog
<ExportDialog
elements={elements}
appState={appState}
actionManager={actionManager}
onExportToPng={createExporter("png")}
onExportToSvg={createExporter("svg")}
onExportToClipboard={createExporter("clipboard")}
onExportToBackend={
onExportToBackend
? (elements) => {
onExportToBackend &&
onExportToBackend(elements, appState, canvas);
}
: undefined
}
/>
);
};
const Separator = () => {
return <div style={{ width: ".625em" }} />;
};
const renderViewModeCanvasActions = () => {
return (
<Section
@@ -444,8 +424,9 @@ const LayerUI = ({
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{renderJSONExportDialog()}
{renderImageExportDialog()}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
</Stack.Row>
</Stack.Col>
</Island>
@@ -464,12 +445,11 @@ const LayerUI = ({
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{actionManager.renderAction("clearCanvas")}
<Separator />
{actionManager.renderAction("loadScene")}
{renderJSONExportDialog()}
{renderImageExportDialog()}
<Separator />
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
@@ -537,9 +517,6 @@ const LayerUI = ({
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
id={id}
/>
) : null;
@@ -593,30 +570,24 @@ const LayerUI = ({
)}
</Section>
)}
<div
className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
<UserList
className={clsx("zen-mode-transition", {
"transition-right": zenModeEnabled,
})}
>
<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>
{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>
</div>
</FixedSideContainer>
);
@@ -624,61 +595,61 @@ const LayerUI = ({
const renderBottomAppMenu = () => {
return (
<footer
role="contentinfo"
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
<div
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
"App-menu_bottom--transition-left": zenModeEnabled,
})}
>
<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>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{renderEncryptedIcon()}
</Section>
</Stack.Col>
</div>
);
};
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 />}
@@ -689,11 +660,7 @@ const LayerUI = ({
/>
)}
{appState.showHelpDialog && (
<HelpDialog
onClose={() => {
setAppState({ showHelpDialog: false });
}}
/>
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
)}
{appState.pasteDialog.shown && (
<PasteChartDialog
@@ -718,8 +685,7 @@ const LayerUI = ({
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
exportButton={renderExportDialog()}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle}
@@ -742,6 +708,8 @@ const LayerUI = ({
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{renderGitHubCorner()}
{renderFooter()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
+1
View File
@@ -39,6 +39,7 @@ export const LibraryUnit = ({
const svg = exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: oc.white,
shouldAddWatermark: false,
});
for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
+11 -11
View File
@@ -20,8 +20,7 @@ import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkMode
type MobileMenuProps = {
appState: AppState;
actionManager: ActionManager;
renderJSONExportDialog: () => React.ReactNode;
renderImageExportDialog: () => React.ReactNode;
exportButton: React.ReactNode;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
libraryMenu: JSX.Element | null;
@@ -29,7 +28,7 @@ type MobileMenuProps = {
onLockToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
showThemeBtn: boolean;
};
@@ -39,8 +38,7 @@ export const MobileMenu = ({
elements,
libraryMenu,
actionManager,
renderJSONExportDialog,
renderImageExportDialog,
exportButton,
setAppState,
onCollabButtonClick,
onLockToggle,
@@ -109,17 +107,19 @@ export const MobileMenu = ({
if (viewModeEnabled) {
return (
<>
{renderJSONExportDialog()}
{renderImageExportDialog()}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
</>
);
}
return (
<>
{actionManager.renderAction("clearCanvas")}
{actionManager.renderAction("loadScene")}
{renderJSONExportDialog()}
{renderImageExportDialog()}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
@@ -155,7 +155,7 @@ export const MobileMenu = ({
<div className="panelColumn">
<Stack.Col gap={4}>
{renderCanvasActions()}
{renderCustomFooter?.(true, appState)}
{renderCustomFooter?.(true)}
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
+3 -5
View File
@@ -26,7 +26,8 @@
right: 0;
bottom: 0;
z-index: 1;
background-color: transparentize($oc-black, 0.3);
background-color: transparentize($oc-black, 0.7);
backdrop-filter: blur(2px);
}
.Modal__content {
@@ -44,16 +45,13 @@
// 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;
+7 -14
View File
@@ -4,8 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { KEYS } from "../keys";
import { useExcalidrawContainer, useIsMobile } from "./App";
import { AppState } from "../types";
import { useIsMobile } from "../components/App";
export const Modal = (props: {
className?: string;
@@ -13,10 +12,8 @@ export const Modal = (props: {
maxWidth?: number;
onCloseRequest(): void;
labelledBy: string;
theme?: AppState["theme"];
}) => {
const { theme = "light" } = props;
const modalRoot = useBodyRoot(theme);
const modalRoot = useBodyRoot();
if (!modalRoot) {
return null;
@@ -25,7 +22,6 @@ export const Modal = (props: {
const handleKeydown = (event: React.KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
props.onCloseRequest();
}
};
@@ -42,7 +38,6 @@ export const Modal = (props: {
<div
className="Modal__content"
style={{ "--max-width": `${props.maxWidth}px` }}
tabIndex={0}
>
{props.children}
</div>
@@ -51,15 +46,13 @@ export const Modal = (props: {
);
};
const useBodyRoot = (theme: AppState["theme"]) => {
const useBodyRoot = () => {
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);
@@ -67,9 +60,9 @@ const useBodyRoot = (theme: AppState["theme"]) => {
}, [div, isMobile]);
useLayoutEffect(() => {
const isDarkTheme =
!!excalidrawContainer?.classList.contains("theme--dark") ||
theme === "dark";
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("theme--dark");
const div = document.createElement("div");
div.classList.add("excalidraw", "excalidraw-modal-container");
@@ -86,7 +79,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
return () => {
document.body.removeChild(div);
};
}, [excalidrawContainer, theme]);
}, []);
return div;
};
+1
View File
@@ -38,6 +38,7 @@ const ChartPreviewBtn = (props: {
const svg = exportToSvg(elements, {
exportBackground: false,
viewBackgroundColor: oc.white,
shouldAddWatermark: false,
});
const previewNode = previewRef.current!;
-25
View File
@@ -1,25 +0,0 @@
.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;
}
}
}
+5 -9
View File
@@ -1,9 +1,6 @@
import "./TextInput.scss";
import React, { Component } from "react";
import { focusNearestParent } from "../utils";
import "./ProjectName.scss";
type Props = {
value: string;
@@ -20,7 +17,6 @@ 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);
@@ -39,8 +35,8 @@ export class ProjectName extends Component<Props, State> {
public render() {
return (
<div className="ProjectName">
<label className="ProjectName-label" htmlFor="filename">
<>
<label htmlFor="file-name">
{`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
</label>
{this.props.isNameEditable ? (
@@ -48,18 +44,18 @@ export class ProjectName extends Component<Props, State> {
className="TextInput"
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
id="filename"
id="file-name"
value={this.state.fileName}
onChange={(event) =>
this.setState({ fileName: event.target.value })
}
/>
) : (
<span className="TextInput TextInput--readonly" id="filename">
<span className="TextInput TextInput--readonly" id="file-name">
{this.props.value}
</span>
)}
</div>
</>
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
top: 64px;
right: 12px;
font-size: 12px;
z-index: 10;
z-index: 999;
h3 {
margin: 0 24px 8px 0;
+10 -17
View File
@@ -29,13 +29,9 @@ type ToolButtonProps =
children?: React.ReactNode;
onClick?(): void;
})
| (ToolButtonBaseProps & {
type: "icon";
children?: React.ReactNode;
onClick?(): void;
})
| (ToolButtonBaseProps & {
type: "radio";
checked: boolean;
onChange?(): void;
});
@@ -47,7 +43,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" || props.type === "icon") {
if (props.type === "button") {
return (
<button
className={clsx(
@@ -60,7 +56,6 @@ 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"]}
@@ -71,16 +66,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
onClick={props.onClick}
ref={innerRef}
>
{(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>
)}
<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 -9
View File
@@ -11,15 +11,6 @@
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 {
@@ -196,6 +187,17 @@
}
}
.TooltipIcon {
width: 0.9em;
height: 0.9em;
margin-left: 5px;
margin-top: 1px;
@include isMobile {
display: none;
}
}
.unlocked-icon {
:root[dir="ltr"] & {
left: 2px;
+52 -33
View File
@@ -1,39 +1,58 @@
@import "../css/variables.module";
.excalidraw-tooltip {
position: absolute;
z-index: 1000;
padding: 8px;
border-radius: 6px;
box-sizing: border-box;
pointer-events: none;
word-wrap: break-word;
background: $oc-black;
line-height: 1.5;
text-align: center;
font-size: 13px;
font-weight: 500;
color: $oc-white;
display: none;
&.excalidraw-tooltip--visible {
display: block;
}
}
.excalidraw {
.Tooltip-icon {
width: 0.9em;
height: 0.9em;
margin-left: 5px;
margin-top: 1px;
display: flex;
.Tooltip {
position: relative;
}
@include isMobile {
display: none;
.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;
&::after {
content: "";
border: var(--arrow-size) solid transparent;
position: absolute;
left: calc(50% - var(--arrow-size));
}
&--above {
bottom: calc(100% + var(--arrow-size) + 3px);
&::after {
border-top-color: $oc-black;
top: 100%;
}
}
&--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;
}
}
+20 -81
View File
@@ -1,92 +1,31 @@
import "./Tooltip.scss";
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`,
});
};
import React from "react";
type TooltipProps = {
children: React.ReactNode;
label: string;
position?: "above" | "below";
long?: boolean;
};
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")
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"
}
style={{ width: long ? "50ch" : "10ch" }}
>
{children}
</div>
);
};
{label}
</span>
{children}
</div>
);
+1 -2
View File
@@ -2,8 +2,7 @@
.UserList {
pointer-events: none;
/*github corner*/
padding: var(--space-factor) var(--space-factor) var(--space-factor)
var(--space-factor);
padding: var(--space-factor) 40px var(--space-factor) var(--space-factor);
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
+98 -51
View File
@@ -41,14 +41,6 @@ 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 },
@@ -88,19 +80,6 @@ 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" />
<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" />
</>,
{ 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 },
@@ -243,12 +222,14 @@ 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"
/>
</>,
@@ -354,6 +335,7 @@ export const DistributeHorizontallyIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path d="M5 5V19Z" fill="black" />
<path
d="M19 5V19M5 5V19"
stroke={iconFillColor(theme)}
@@ -371,6 +353,14 @@ 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(
@@ -489,16 +479,42 @@ export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<g
<rect
x="2.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>
/>
<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"
/>
</>,
{ width: 182, height: 182, mirror: true },
),
@@ -520,18 +536,60 @@ export const UngroupIcon = React.memo(
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<g
<rect
x="2.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="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>
/>
<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"
/>
</>,
{ width: 182, height: 182, mirror: true },
),
@@ -573,10 +631,9 @@ export const StrokeWidthIcon = React.memo(
({ theme, strokeWidth }: { theme: "light" | "dark"; strokeWidth: number }) =>
createIcon(
<path
d="M6 10H32"
d="M6 10H34"
stroke={iconFillColor(theme)}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
/>,
{ width: 40, height: 20 },
@@ -591,7 +648,6 @@ export const StrokeStyleSolidIcon = React.memo(
stroke={iconFillColor(theme)}
strokeWidth={2}
fill="none"
strokeLinecap="round"
/>,
{
width: 40,
@@ -609,7 +665,6 @@ export const StrokeStyleDashedIcon = React.memo(
strokeWidth={2.5}
strokeDasharray={"10, 8"}
fill="none"
strokeLinecap="round"
/>,
{ width: 40, height: 20 },
),
@@ -619,12 +674,11 @@ export const StrokeStyleDottedIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H36"
d="M6 10H34"
stroke={iconFillColor(theme)}
strokeWidth={2.5}
strokeDasharray={"2, 4.5"}
strokeDasharray={"4, 4"}
fill="none"
strokeLinecap="round"
/>,
{ width: 40, height: 20 },
),
@@ -637,7 +691,6 @@ 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 },
@@ -651,7 +704,6 @@ 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 },
@@ -665,7 +717,6 @@ 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 },
@@ -679,7 +730,6 @@ 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 },
@@ -693,7 +743,6 @@ 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 },
@@ -853,7 +902,6 @@ 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 },
),
@@ -876,7 +924,6 @@ 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 },
),
+4 -7
View File
@@ -91,8 +91,6 @@ 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;
@@ -104,6 +102,7 @@ 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
@@ -131,16 +130,14 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: { saveFileToDisk: true },
export: true,
loadScene: true,
saveToActiveFile: true,
saveAsScene: true,
saveScene: true,
theme: true,
saveAsImage: true,
},
};
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;
-1
View File
@@ -5,7 +5,6 @@
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
user-select: none;
}
.LoadingMessage {
+26 -9
View File
@@ -19,10 +19,6 @@
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)
@@ -332,8 +328,8 @@
.App-menu_bottom {
position: absolute;
bottom: 0;
grid-template-columns: min-content auto min-content;
grid-gap: 15px;
grid-template-columns: 1fr auto 1fr;
grid-gap: 4px;
align-items: flex-start;
cursor: default;
pointer-events: none !important;
@@ -358,6 +354,10 @@
}
}
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
pointer-events: all;
}
.App-menu_bottom > *:first-child {
justify-self: flex-start;
}
@@ -415,11 +415,13 @@
}
&.dropdown-select--floating {
position: absolute;
margin: 0.5em;
}
}
.dropdown-select__language.dropdown-select--floating {
position: absolute;
bottom: 10px;
:root[dir="ltr"] & {
@@ -455,12 +457,13 @@
}
.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);
@@ -469,11 +472,11 @@
}
:root[dir="ltr"] & {
margin-right: 14px;
right: 14px;
}
:root[dir="rtl"] & {
margin-left: 14px;
left: 14px;
}
}
@@ -493,6 +496,20 @@
}
}
.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;
+2 -4
View File
@@ -14,12 +14,11 @@
--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.96);
--island-bg-color: rgba(255, 255, 255, 0.9);
--keybinding-color: #{$oc-gray-5};
--link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
@@ -57,12 +56,11 @@
--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: rgba(30, 30, 30, 0.98);
--island-bg-color: #1e1e1e;
--keybinding-color: #{$oc-gray-6};
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
--popup-bg-color: #2c2c2c;
+2 -2
View File
@@ -7,7 +7,7 @@ import { calculateScrollCenter } from "../scene";
import { AppState } from "../types";
import { isValidExcalidrawData } from "./json";
import { restore } from "./restore";
import { ImportedLibraryData } from "./types";
import { LibraryData } 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: ImportedLibraryData = JSON.parse(contents);
const data: LibraryData = JSON.parse(contents);
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
+13 -4
View File
@@ -1,6 +1,6 @@
import { fileSave } from "browser-fs-access";
import {
copyBlobToClipboardAsPng,
copyCanvasToClipboardAsPng,
copyTextToSystemClipboard,
} from "../clipboard";
import { NonDeletedExcalidrawElement } from "../element/types";
@@ -18,18 +18,21 @@ export const exportCanvas = async (
type: ExportType,
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement,
{
exportBackground,
exportPadding = 10,
viewBackgroundColor,
name,
scale = 1,
shouldAddWatermark,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
name: string;
scale?: number;
shouldAddWatermark: boolean;
},
) => {
if (elements.length === 0) {
@@ -42,6 +45,7 @@ export const exportCanvas = async (
viewBackgroundColor,
exportPadding,
scale,
shouldAddWatermark,
metadata:
appState.exportEmbedScene && type === "svg"
? await (
@@ -68,14 +72,14 @@ export const exportCanvas = async (
viewBackgroundColor,
exportPadding,
scale,
shouldAddWatermark,
});
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")
@@ -91,7 +95,7 @@ export const exportCanvas = async (
});
} else if (type === "clipboard") {
try {
await copyBlobToClipboardAsPng(blob);
await copyCanvasToClipboardAsPng(tempCanvas);
} catch (error) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error;
@@ -99,4 +103,9 @@ export const exportCanvas = async (
throw new Error(t("alerts.couldNotCopyToClipboard"));
}
}
// clean up the DOM
if (tempCanvas !== canvas) {
tempCanvas.remove();
}
};
+28 -30
View File
@@ -1,32 +1,28 @@
import { fileOpen, fileSave } from "browser-fs-access";
import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { loadFromBlob } from "./blob";
import {
ExportedDataState,
ImportedDataState,
ExportedLibraryData,
} from "./types";
import Library from "./library";
import { Library } from "./library";
import { ImportedDataState } from "./types";
export const serializeAsJSON = (
elements: readonly ExcalidrawElement[],
appState: AppState,
): 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);
};
): string =>
JSON.stringify(
{
type: EXPORT_DATA_TYPES.excalidraw,
version: 2,
source: window.location.origin,
elements: clearElementsForExport(elements),
appState: cleanAppStateForExport(appState),
},
null,
2,
);
export const saveAsJSON = async (
elements: readonly ExcalidrawElement[],
@@ -89,15 +85,17 @@ export const isValidLibrary = (json: any) => {
);
};
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);
export const saveLibraryAsJSON = async () => {
const library = await Library.loadLibrary();
const serialized = JSON.stringify(
{
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: 1,
library,
},
null,
2,
);
const fileName = "library.excalidrawlib";
const blob = new Blob([serialized], {
type: MIME_TYPES.excalidrawlib,
@@ -109,7 +107,7 @@ export const saveLibraryAsJSON = async (library: Library) => {
});
};
export const importLibraryFromJSON = async (library: Library) => {
export const importLibraryFromJSON = async () => {
const blob = await fileOpen({
description: "Excalidraw library files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
@@ -118,5 +116,5 @@ export const importLibraryFromJSON = async (library: Library) => {
extensions: [".json", ".excalidrawlib"],
*/
});
await library.importLibrary(blob);
await Library.importLibrary(blob);
};
+32 -45
View File
@@ -1,29 +1,22 @@
import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types";
import { restoreElements } from "./restore";
import { STORAGE_KEYS } from "../constants";
import { getNonDeletedElements } from "../element";
import App from "../components/App";
import { NonDeleted, ExcalidrawElement } from "../element/types";
import { nanoid } from "nanoid";
class Library {
private libraryCache: LibraryItems | null = null;
private app: App;
export class Library {
private static libraryCache: LibraryItems | null = null;
public static csrfToken = nanoid();
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;
static resetLibrary = () => {
Library.libraryCache = null;
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
};
/** imports library (currently merges, removing duplicates) */
async importLibrary(blob: Blob) {
static async importLibrary(blob: Blob) {
const libraryFile = await loadLibraryFromBlob(blob);
if (!libraryFile || !libraryFile.library) {
return;
@@ -53,41 +46,37 @@ class Library {
});
};
const existingLibraryItems = await this.loadLibrary();
const existingLibraryItems = await Library.loadLibrary();
const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
const restoredItem = this.restoreLibraryItem(libraryItem);
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
acc.push(restoredItem);
const restored = getNonDeletedElements(restoreElements(libraryItem));
if (isUniqueitem(existingLibraryItems, restored)) {
acc.push(restored);
}
return acc;
}, [] as Mutable<LibraryItems>);
}, [] as (readonly NonDeleted<ExcalidrawElement>[])[]);
await this.saveLibrary([...existingLibraryItems, ...filtered]);
Library.saveLibrary([...existingLibraryItems, ...filtered]);
}
loadLibrary = (): Promise<LibraryItems> => {
static loadLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => {
if (this.libraryCache) {
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
if (Library.libraryCache) {
return resolve(JSON.parse(JSON.stringify(Library.libraryCache)));
}
try {
const libraryItems = this.app.libraryItemsFromStorage;
if (!libraryItems) {
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
if (!data) {
return resolve([]);
}
const items = libraryItems.reduce((acc, item) => {
const restoredItem = this.restoreLibraryItem(item);
if (restoredItem) {
acc.push(item);
}
return acc;
}, [] as Mutable<LibraryItems>);
const items = (JSON.parse(data) as LibraryItems).map((elements) =>
restoreElements(elements),
) as Mutable<LibraryItems>;
// clone to ensure we don't mutate the cached library elements in the app
this.libraryCache = JSON.parse(JSON.stringify(items));
Library.libraryCache = JSON.parse(JSON.stringify(items));
resolve(items);
} catch (error) {
@@ -97,19 +86,17 @@ class Library {
});
};
saveLibrary = async (items: LibraryItems) => {
const prevLibraryItems = this.libraryCache;
static saveLibrary = (items: LibraryItems) => {
const prevLibraryItems = Library.libraryCache;
try {
const serializedItems = JSON.stringify(items);
// cache optimistically so that the app has access to the latest
// cache optimistically so that consumers have access to the latest
// immediately
this.libraryCache = JSON.parse(serializedItems);
await this.app.props.onLibraryChange?.(items);
Library.libraryCache = JSON.parse(serializedItems);
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
} catch (error) {
this.libraryCache = prevLibraryItems;
throw error;
Library.libraryCache = prevLibraryItems;
console.error(error);
}
};
}
export default Library;
+17 -74
View File
@@ -4,7 +4,7 @@ import {
ExcalidrawSelectionElement,
} from "../element/types";
import { AppState, NormalizedZoomValue } from "../types";
import { ImportedDataState } from "./types";
import { DataState, ImportedDataState } from "./types";
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random";
@@ -15,31 +15,6 @@ 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)) {
@@ -50,18 +25,12 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
return DEFAULT_FONT_FAMILY;
};
const restoreElementWithProperties = <
T extends ExcalidrawElement,
K extends keyof Omit<
Required<T>,
Exclude<keyof ExcalidrawElement, "type" | "x" | "y">
>
>(
const restoreElementWithProperties = <T extends ExcalidrawElement>(
element: Required<T>,
extra: Pick<T, K>,
extra: Omit<Required<T>, keyof ExcalidrawElement>,
): T => {
const base: Pick<T, keyof ExcalidrawElement> = {
type: (extra as Partial<T>).type || element.type,
type: element.type,
// all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements
version: element.version || 1,
@@ -74,8 +43,8 @@ const restoreElementWithProperties = <
roughness: element.roughness ?? 1,
opacity: element.opacity == null ? 100 : element.opacity,
angle: element.angle || 0,
x: (extra as Partial<T>).x ?? element.x ?? 0,
y: (extra as Partial<T>).y ?? element.y ?? 0,
x: element.x || 0,
y: element.y || 0,
strokeColor: element.strokeColor,
backgroundColor: element.backgroundColor,
width: element.width || 0,
@@ -118,51 +87,28 @@ const restoreElement = (
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
});
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 "line":
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
@@ -198,7 +144,7 @@ export const restoreElements = (
export const restoreAppState = (
appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null,
): RestoredAppState => {
): DataState["appState"] => {
appState = appState || {};
const defaultAppState = getDefaultAppState();
@@ -220,9 +166,6 @@ 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"
@@ -243,7 +186,7 @@ export const restore = (
* Supply `null` if you can't get access to it.
*/
localAppState: Partial<AppState> | null | undefined,
): RestoredDataState => {
): DataState => {
return {
elements: restoreElements(data?.elements),
appState: restoreAppState(data?.appState, localAppState || null),
+15 -19
View File
@@ -1,30 +1,26 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, LibraryItems } from "../types";
import type { cleanAppStateForExport } from "../appState";
export interface ExportedDataState {
type: string;
version: number;
source: string;
export interface DataState {
type?: string;
version?: string;
source?: string;
elements: readonly ExcalidrawElement[];
appState: ReturnType<typeof cleanAppStateForExport>;
appState: Omit<AppState, "offsetTop" | "offsetLeft" | "width" | "height">;
}
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;
elements?: readonly ExcalidrawElement[] | null;
appState?: Readonly<Partial<AppState>> | null;
scrollToContent?: boolean;
libraryItems?: LibraryItems;
library?: LibraryItems;
}
export interface ExportedLibraryData {
type: string;
version: number;
source: string;
library: LibraryItems;
}
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {}
+52 -138
View File
@@ -1,9 +1,4 @@
import {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
} from "./types";
import { ExcalidrawElement, ExcalidrawLinearElement, Arrowhead } from "./types";
import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core";
@@ -12,7 +7,7 @@ import {
getShapeForElement,
generateRoughOptions,
} from "../renderer/renderElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { isLinearElement } from "./typeChecks";
import { rescalePoints } from "../points";
// x and y position of top left corner, x and y position of bottom right corner
@@ -23,9 +18,7 @@ export type Bounds = readonly [number, number, number, number];
export const getElementAbsoluteCoords = (
element: ExcalidrawElement,
): Bounds => {
if (isFreeDrawElement(element)) {
return getFreeDrawElementAbsoluteCoords(element);
} else if (isLinearElement(element)) {
if (isLinearElement(element)) {
return getLinearElementAbsoluteCoords(element);
}
return [
@@ -127,42 +120,9 @@ 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(
@@ -177,21 +137,7 @@ const getLinearElementAbsoluteCoords = (
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
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 = [
return [
minX + element.x,
minY + element.y,
maxX + element.x,
@@ -199,7 +145,19 @@ const getLinearElementAbsoluteCoords = (
];
}
return coords;
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,
];
};
export const getArrowheadPoints = (
@@ -260,34 +218,20 @@ export const getArrowheadPoints = (
dot: 15,
}[arrowhead]; // pixels (will differ for each arrowhead)
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);
}
}
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);
// 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 last segment of the arrowhead
// This value is selected by minimizing a minimum size with the whole length of the
// arrowhead instead of 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) + element.strokeWidth;
const r = Math.hypot(ys - y2, xs - x2);
return [x2, y2, r];
}
@@ -333,31 +277,16 @@ 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 (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") {
if (isLinearElement(element)) {
return getLinearElementRotatedBounds(element, cx, cy);
}
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);
@@ -366,28 +295,26 @@ 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);
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
return [minX, minY, maxX, maxY];
}
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);
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];
return [cx - ww, cy - hh, cx + ww, cy + hh];
}
return bounds;
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];
};
export const getCommonBounds = (
@@ -418,7 +345,7 @@ export const getResizedElementAbsoluteCoords = (
nextWidth: number,
nextHeight: number,
): [number, number, number, number] => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
if (!isLinearElement(element)) {
return [
element.x,
element.y,
@@ -433,29 +360,16 @@ export const getResizedElementAbsoluteCoords = (
rescalePoints(1, nextHeight, element.points),
);
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;
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);
return [
minX + element.x,
minY + element.y,
+4 -98
View File
@@ -4,13 +4,7 @@ import * as GADirection from "../gadirections";
import * as GALine from "../galines";
import * as GATransform from "../gatransforms";
import {
distance2d,
rotatePoint,
isPathALoop,
isPointInPolygon,
rotate,
} from "../math";
import { isPathALoop, isPointInPolygon, rotate } from "../math";
import { pointsOnBezierCurves } from "points-on-curve";
import {
@@ -22,7 +16,6 @@ import {
ExcalidrawTextElement,
ExcalidrawEllipseElement,
NonDeleted,
ExcalidrawFreeDrawElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -37,17 +30,10 @@ const isElementDraggableFromInside = (
if (element.type === "arrow") {
return false;
}
if (element.type === "freedraw") {
return true;
}
const isDraggableFromInside = element.backgroundColor !== "transparent";
if (element.type === "line") {
if (element.type === "line" || element.type === "draw") {
return isDraggableFromInside && isPathALoop(element.points);
}
return isDraggableFromInside;
};
@@ -95,7 +81,6 @@ const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element)
? isInsideCheck
: isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check });
};
@@ -166,20 +151,9 @@ 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(
@@ -221,10 +195,7 @@ const isOutsideCheck = (distance: number, threshold: number): boolean => {
};
const distanceToRectangle = (
element:
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement,
element: ExcalidrawRectangleElement | ExcalidrawTextElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -296,71 +267,6 @@ 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)) {
+7 -5
View File
@@ -58,6 +58,13 @@ 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) => {
@@ -70,11 +77,6 @@ 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,
+9 -14
View File
@@ -10,7 +10,7 @@ import { getElementAbsoluteCoords } from ".";
import { getElementPointsCoords } from "./bounds";
import { Point, AppState } from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
import { SceneHistory } 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: History,
history: SceneHistory,
scenePointer: { x: number; y: number },
): {
didAddPoint: boolean;
@@ -415,31 +415,26 @@ 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. Also returns new x/y to account
* for the potential normalization.
* expected in various parts of the codebase.
*/
static getNormalizedPoints(element: ExcalidrawLinearElement) {
static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
return {
mutateElement(element, {
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(
-11
View File
@@ -114,14 +114,3 @@ 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;
};
+1 -30
View File
@@ -9,7 +9,6 @@ import {
GroupId,
VerticalAlign,
Arrowhead,
ExcalidrawFreeDrawElement,
} from "../element/types";
import { measureText, getFontString } from "../utils";
import { randomInteger, randomId } from "../random";
@@ -213,22 +212,6 @@ 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"];
@@ -307,19 +290,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
overrides?: Partial<TElement>,
): TElement => {
let copy: TElement = deepCopyElement(element);
if (process.env.NODE_ENV === "test") {
copy.id = `${copy.id}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el) => el.id === copy.id)
) {
copy.id += "_copy";
}
} else {
copy.id = randomId();
}
copy.id = process.env.NODE_ENV === "test" ? `${copy.id}_copy` : randomId();
copy.seed = randomInteger();
copy.groupIds = getNewGroupIdsForDuplication(
copy.groupIds,
+3 -11
View File
@@ -18,11 +18,7 @@ import {
getCommonBounds,
getResizedElementAbsoluteCoords,
} from "./bounds";
import {
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { isLinearElement, isTextElement } from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { measureText, getFontString } from "../utils";
@@ -248,7 +244,7 @@ const rescalePointsInElement = (
width: number,
height: number,
) =>
isLinearElement(element) || isFreeDrawElement(element)
isLinearElement(element)
? {
points: rescalePoints(
0,
@@ -408,7 +404,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,
@@ -648,14 +644,11 @@ 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,
@@ -664,7 +657,6 @@ const resizeMultipleElements = (
width,
height,
);
const { x, y } = getNextXY(element, origCoords, finalCoords);
return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
},
+3 -3
View File
@@ -1,12 +1,12 @@
import { ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants";
export const isInvisiblySmallElement = (
element: ExcalidrawElement,
): boolean => {
if (isLinearElement(element) || isFreeDrawElement(element)) {
if (isLinearElement(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 === "freedraw"
elementType === "draw"
) {
const lockedAngle =
Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) *
-169
View File
@@ -1,169 +0,0 @@
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`);
});
});
+7 -120
View File
@@ -1,4 +1,4 @@
import { CODES, KEYS } from "../keys";
import { KEYS } from "../keys";
import { isWritableElement, getFontString } from "../utils";
import Scene from "../scene/Scene";
import { isTextElement } from "./typeChecks";
@@ -43,7 +43,6 @@ export const textWysiwyg = ({
getViewportCoords,
element,
canvas,
excalidrawContainer,
}: {
id: ExcalidrawElement["id"];
appState: AppState;
@@ -52,7 +51,6 @@ 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);
@@ -73,7 +71,7 @@ export const textWysiwyg = ({
// margin-right of parent if any
Number(
getComputedStyle(
excalidrawContainer?.parentNode as Element,
document.querySelector(".excalidraw")!.parentNode as Element,
).marginRight.slice(0, -2),
);
@@ -136,7 +134,6 @@ export const textWysiwyg = ({
}
editable.onkeydown = (event) => {
event.stopPropagation();
if (event.key === KEYS.ESCAPE) {
event.preventDefault();
submittedViaKeyboard = true;
@@ -148,118 +145,11 @@ export const textWysiwyg = ({
}
submittedViaKeyboard = true;
handleSubmit();
} 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"));
} else if (event.key === KEYS.ENTER && !event.altKey) {
event.stopPropagation();
}
};
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();
@@ -269,14 +159,11 @@ 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 = () => {
@@ -365,7 +252,7 @@ export const textWysiwyg = ({
passive: false,
capture: true,
});
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
document
.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
};
+1 -1
View File
@@ -225,7 +225,7 @@ export const getTransformHandles = (
if (
element.type === "arrow" ||
element.type === "line" ||
element.type === "freedraw"
element.type === "draw"
) {
if (element.points.length === 2) {
// only check the last point because starting point is always (0,0)
+2 -15
View File
@@ -4,7 +4,6 @@ import {
ExcalidrawLinearElement,
ExcalidrawBindableElement,
ExcalidrawGenericElement,
ExcalidrawFreeDrawElement,
} from "./types";
export const isGenericElement = (
@@ -25,18 +24,6 @@ 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 => {
@@ -47,7 +34,7 @@ export const isLinearElementType = (
elementType: ExcalidrawElement["type"],
): boolean => {
return (
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
elementType === "arrow" || elementType === "line" || elementType === "draw"
);
};
@@ -82,7 +69,7 @@ export const isExcalidrawElement = (element: any): boolean => {
element?.type === "rectangle" ||
element?.type === "ellipse" ||
element?.type === "arrow" ||
element?.type === "freedraw" ||
element?.type === "draw" ||
element?.type === "line"
);
};
+2 -12
View File
@@ -78,8 +78,7 @@ export type ExcalidrawGenericElement =
export type ExcalidrawElement =
| ExcalidrawGenericElement
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawFreeDrawElement;
| ExcalidrawLinearElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: false;
@@ -114,7 +113,7 @@ export type Arrowhead = "arrow" | "bar" | "dot";
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "line" | "arrow";
type: "line" | "draw" | "arrow";
points: readonly Point[];
lastCommittedPoint: Point | null;
startBinding: PointBinding | null;
@@ -122,12 +121,3 @@ 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;
}>;
+7 -9
View File
@@ -8,6 +8,7 @@ import { ExcalidrawElement } from "../../element/types";
import {
getElementMap,
getSceneVersion,
getSyncableElements,
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
@@ -40,7 +41,6 @@ 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 = this.getSyncableElements(
const syncableElements = getSyncableElements(
this.getSceneElementsIncludingDeleted(),
);
@@ -177,7 +177,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
});
saveCollabRoomToFirebase = async (
syncableElements: ExcalidrawElement[] = this.getSyncableElements(
syncableElements: ExcalidrawElement[] = getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
) => {
@@ -565,7 +565,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
) {
this.portal.broadcastScene(
SCENE.UPDATE,
this.getSyncableElements(elements),
getSyncableElements(elements),
false,
);
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
@@ -576,7 +576,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene(
SCENE.UPDATE,
this.getSyncableElements(
getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
true,
@@ -591,6 +591,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
handleClose = () => {
this.setState({ modalIsShown: false });
const collabIcon = document.querySelector(".CollabButton") as HTMLElement;
collabIcon.focus();
};
onUsernameChange = (username: string) => {
@@ -604,9 +606,6 @@ 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;
@@ -641,7 +640,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
setErrorMessage={(errorMessage) => {
this.setState({ errorMessage });
}}
theme={this.excalidrawAPI.getAppState().theme}
/>
)}
{errorMessage && (
+2 -3
View File
@@ -6,6 +6,7 @@ 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";
@@ -38,9 +39,7 @@ class Portal {
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
SCENE.INIT,
this.collab.getSyncableElements(
this.collab.getSceneElementsIncludingDeleted(),
),
getSyncableElements(this.collab.getSceneElementsIncludingDeleted()),
/* syncAll */ true,
);
});
-4
View File
@@ -13,7 +13,6 @@ 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;
@@ -37,7 +36,6 @@ const RoomDialog = ({
onRoomCreate,
onRoomDestroy,
setErrorMessage,
theme,
}: {
handleClose: () => void;
activeRoomLink: string;
@@ -46,7 +44,6 @@ const RoomDialog = ({
onRoomCreate: () => void;
onRoomDestroy: () => void;
setErrorMessage: (message: string) => void;
theme: AppState["theme"];
}) => {
const roomLinkInput = useRef<HTMLInputElement>(null);
@@ -171,7 +168,6 @@ const RoomDialog = ({
small
onCloseRequest={handleClose}
title={t("labels.liveCollaboration")}
theme={theme}
>
{renderRoomDialog()}
</Dialog>
+4 -4
View File
@@ -245,10 +245,10 @@ const importFromBackend = async (
export const loadScene = async (
id: string | null,
privateKey: string | null,
// Supply local state even if importing from backend to ensure we restore
// Supply initialData 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`.
localDataState: ImportedDataState | undefined | null,
initialData: 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),
localDataState?.appState,
initialData?.appState,
);
} else {
data = restore(localDataState || null, null);
data = restore(initialData || null, null);
}
return {
-17
View File
@@ -1,17 +0,0 @@
.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;
}
}
}
+17 -130
View File
@@ -14,13 +14,12 @@ 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 { ImportedDataState } from "../data/types";
import { DataState, ImportedDataState } from "../data/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
@@ -31,7 +30,7 @@ import Excalidraw, {
defaultLang,
languages,
} from "../packages/excalidraw/index";
import { AppState, LibraryItems } from "../types";
import { AppState } from "../types";
import {
debounce,
getVersion,
@@ -51,11 +50,6 @@ 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({
@@ -87,11 +81,13 @@ const initializeScene = async (opts: {
);
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
const localDataState = importFromLocalStorage();
const initialData = importFromLocalStorage();
let scene: RestoredDataState & {
scrollToContent?: boolean;
} = await loadScene(null, null, localDataState);
let scene: DataState & { scrollToContent?: boolean } = await loadScene(
null,
null,
initialData,
);
let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
@@ -106,12 +102,12 @@ const initializeScene = async (opts: {
) {
// Backwards compatibility with legacy url format
if (id) {
scene = await loadScene(id, null, localDataState);
scene = await loadScene(id, null, initialData);
} else if (jsonBackendMatch) {
scene = await loadScene(
jsonBackendMatch[1],
jsonBackendMatch[2],
localDataState,
initialData,
);
}
scene.scrollToContent = true;
@@ -165,20 +161,6 @@ 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;
@@ -214,18 +196,6 @@ 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);
});
@@ -243,10 +213,7 @@ const ExcalidrawWrapper = () => {
} else {
initializeScene({ collabAPI }).then((scene) => {
if (scene) {
excalidrawAPI.updateScene({
...scene,
appState: restoreAppState(scene.appState, null),
});
excalidrawAPI.updateScene(scene);
}
});
}
@@ -311,41 +278,8 @@ 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) => {
@@ -357,43 +291,14 @@ const ExcalidrawWrapper = () => {
/>
);
if (isMobile) {
const isTinyDevice = window.innerWidth < 362;
return (
<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>
<fieldset>
<legend>{t("labels.language")}</legend>
{renderLanguageList()}
</fieldset>
);
}
return (
<>
{renderEncryptedIcon()}
{renderLanguageList()}
</>
);
return renderLanguageList();
},
[langCode],
);
@@ -406,15 +311,6 @@ 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
@@ -424,20 +320,11 @@ const ExcalidrawWrapper = () => {
onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating()}
onPointerUpdate={collabAPI?.onPointerUpdate}
UIOptions={{
canvasActions: {
export: {
onExportToBackend,
},
},
}}
renderTopRightUI={renderTopRightUI}
onExportToBackend={onExportToBackend}
renderFooter={renderFooter}
langCode={langCode}
renderCustomStats={renderCustomStats}
detectScroll={false}
handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
/>
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && (
+1 -5
View File
@@ -67,14 +67,10 @@ export const selectGroupsForSelectedElements = (
appState: AppState,
elements: readonly NonDeleted<ExcalidrawElement>[],
): AppState => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
let nextAppState = { ...appState };
const selectedElements = getSelectedElements(elements, appState);
if (!selectedElements.length) {
return { ...nextAppState, editingGroupId: null };
}
for (const selectedElement of selectedElements) {
let groupIds = selectedElement.groupIds;
if (appState.editingGroupId) {
+6 -4
View File
@@ -21,7 +21,6 @@ interface DehydratedHistoryEntry {
const clearAppStatePropertiesForHistory = (appState: AppState) => {
return {
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
viewBackgroundColor: appState.viewBackgroundColor,
editingLinearElement: appState.editingLinearElement,
editingGroupId: appState.editingGroupId,
@@ -29,7 +28,7 @@ const clearAppStatePropertiesForHistory = (appState: AppState) => {
};
};
class History {
export class SceneHistory {
private elementCache = new Map<string, Map<number, ExcalidrawElement>>();
private recording: boolean = true;
private stateHistory: DehydratedHistoryEntry[] = [];
@@ -170,7 +169,7 @@ class History {
continue;
}
}
if (key === "selectedElementIds" || key === "selectedGroupIds") {
if (key === "selectedElementIds") {
continue;
}
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
@@ -261,4 +260,7 @@ class History {
}
}
export default History;
export const createHistory: () => { history: SceneHistory } = () => {
const history = new SceneHistory();
return { history };
};
-2
View File
@@ -47,8 +47,6 @@ const allLanguages: Language[] = [
{ code: "uk-UA", label: "Українська" },
{ code: "zh-CN", label: "简体中文" },
{ code: "zh-TW", label: "繁體中文" },
{ code: "lv-LV", label: "Latviešu" },
{ code: "cs-CZ", label: "Česky" },
].concat([defaultLang]);
export const languages: Language[] = allLanguages
+1
View File
@@ -69,6 +69,7 @@ const canvas = exportToCanvas(
{
exportBackground: true,
viewBackgroundColor: "#ffffff",
shouldAddWatermark: false,
scale: 1,
},
createCanvas,
-3
View File
@@ -14,9 +14,7 @@ export const CODES = {
NINE: "Digit9",
QUOTE: "Quote",
ZERO: "Digit0",
SLASH: "Slash",
C: "KeyC",
D: "KeyD",
G: "KeyG",
F: "KeyF",
H: "KeyH",
@@ -44,7 +42,6 @@ export const KEYS = {
A: "a",
D: "d",
E: "e",
G: "g",
L: "l",
O: "o",
P: "p",
+35 -52
View File
@@ -20,10 +20,6 @@
"background": "الخلفية",
"fill": "التعبئة",
"strokeWidth": "حجم الحدود",
"strokeShape": "",
"strokeShape_gel": "",
"strokeShape_fountain": "",
"strokeShape_brush": "",
"strokeStyle": "نمط الحدود",
"strokeStyle_solid": "صلبة",
"strokeStyle_dashed": "متقطع",
@@ -42,8 +38,8 @@
"fontSize": "حجم الخط",
"fontFamily": "نوع الخط",
"onlySelected": "المحدد فقط",
"withBackground": "الخلفية",
"exportEmbedScene": "",
"withBackground": "مع الخلفية",
"exportEmbedScene": "تضمين المشهد في ملف التصدير",
"exportEmbedScene_details": "سيتم حفظ بيانات المشهد في ملف PNG/SVG المصدّر بحيث يمكن استعادة المشهد منه.\nسيزيد حجم الملف المصدر.",
"addWatermark": "إضافة \"مصنوعة بواسطة Excalidraw\"",
"handDrawn": "رسم باليد",
@@ -65,7 +61,7 @@
"architect": "معماري",
"artist": "رسام",
"cartoonist": "كرتوني",
"fileTitle": "إسم الملف",
"fileTitle": "",
"colorPicker": "اختيار الألوان",
"canvasBackground": "خلفية اللوحة",
"drawingCanvas": "لوحة الرسم",
@@ -96,24 +92,21 @@
"centerHorizontally": "توسيط أفقي",
"distributeHorizontally": "التوزيع الأفقي",
"distributeVertically": "التوزيع عمودياً",
"flipHorizontal": "قلب عامودي",
"flipVertical": "قلب أفقي",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "نمط العرض",
"toggleExportColorScheme": "",
"share": "مشاركة",
"toggleTheme": "غير النمط"
"share": "مشاركة"
},
"buttons": {
"clearReset": "إعادة تعيين اللوحة",
"exportJSON": "صدر الملف",
"exportImage": "إحفظ كصورة",
"export": "تصدير",
"exportToPng": "تصدير بصيغة PNG",
"exportToSvg": "تصدير بصيغة SVG",
"copyToClipboard": "نسخ إلى الحافظة",
"copyPngToClipboard": "نسخ الـ PNG إلى الحافظة",
"scale": "مقاس",
"save": "احفظ للملف الحالي",
"save": "حفظ",
"saveAs": "حفظ كـ",
"load": "تحميل",
"getShareableLink": "احصل على رابط المشاركة",
@@ -149,8 +142,6 @@
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)",
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
@@ -159,12 +150,12 @@
},
"toolBar": {
"selection": "تحديد",
"draw": "الكتابة الحرة",
"rectangle": "مستطيل",
"diamond": "مضلع",
"ellipse": "دائرة",
"arrow": "سهم",
"line": "خط",
"freedraw": "",
"text": "نص",
"library": "مكتبة",
"lock": "الحفاظ على أداة التحديد نشطة بعد الرسم"
@@ -217,38 +208,30 @@
"errorDialog": {
"title": "خطأ"
},
"exportDialog": {
"disk_title": "حفظ الملف للجهاز",
"disk_details": "",
"disk_button": "إحفظ لملف",
"link_title": "رابط قابل للمشاركة",
"link_details": "صدر الملف للمشاهدة فقط.",
"link_button": "التصدير كرابط"
},
"helpDialog": {
"blog": "اقرأ مدونتنا",
"click": "انقر",
"curvedArrow": "سهم مائل",
"curvedLine": "خط مائل",
"documentation": "دليل الاستخدام",
"blog": "",
"click": "",
"curvedArrow": "",
"curvedLine": "",
"documentation": "",
"drag": "اسحب",
"editor": "المحرر",
"github": "عثرت على مشكلة؟ إرسال",
"howto": "اتبع التعليمات",
"or": "أو",
"preventBinding": "منع ارتبط السهم",
"shapes": "أشكال",
"shortcuts": "اختصارات لوحة المفاتيح",
"textFinish": "الانتهاء من التحرير (نص)",
"textNewLine": "اضف سطر جديد (نص)",
"title": "المساعدة",
"view": "عرض",
"zoomToFit": "تكبير للملائمة",
"zoomToSelection": "تكبير للعنصر المحدد"
"howto": "",
"or": "",
"preventBinding": "",
"shapes": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
},
"encrypted": {
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا.",
"link": "مشاركة المدونة في التشفير من النهاية إلى النهاية في Excalidraw"
"link": ""
},
"stats": {
"angle": "الزاوية",
@@ -260,18 +243,18 @@
"storage": "التخزين",
"title": "إحصائيات للمهووسين",
"total": "المجموع",
"version": "الإصدار",
"versionCopy": "انقر للنسخ",
"versionNotAvailable": "الإصدار غير متوفر",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "العرض"
},
"toast": {
"copyStyles": "نسخ النمط.",
"copyToClipboard": "نسخ إلى الحافظة.",
"copyToClipboardAsPng": "تم نسخ {{exportSelection}} إلى الحافظة بصيغةPNG\n({{exportColorScheme}})",
"fileSaved": "تم حفظ الملف.",
"fileSavedToFilename": "حفظ باسم {filename}",
"canvas": "لوحة الرسم",
"selection": "العنصر المحدد"
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": "",
"canvas": "",
"selection": ""
}
}
+5 -22
View File
@@ -20,10 +20,6 @@
"background": "Фон",
"fill": "Наситеност",
"strokeWidth": "Ширина на щриха",
"strokeShape": "",
"strokeShape_gel": "",
"strokeShape_fountain": "",
"strokeShape_brush": "",
"strokeStyle": "Стил на линия",
"strokeStyle_solid": "Плътен",
"strokeStyle_dashed": "Пунктир",
@@ -42,8 +38,8 @@
"fontSize": "Размер на шрифта",
"fontFamily": "Семейство шрифтове",
"onlySelected": "Само избраното",
"withBackground": "",
"exportEmbedScene": "",
"withBackground": "С фон",
"exportEmbedScene": "Вгради сцената във файл",
"exportEmbedScene_details": "Данните от сцената ще бъдат екпортирани в PNG/SVG файл, за да може сцената да бъде възстановена от него.\nТова ще увеличи размера на файла.",
"addWatermark": "Добави \"Направено с Excalidraw\"",
"handDrawn": "Нарисувано на ръка",
@@ -100,20 +96,17 @@
"flipVertical": "",
"viewMode": "Изглед",
"toggleExportColorScheme": "",
"share": "",
"toggleTheme": ""
"share": ""
},
"buttons": {
"clearReset": "Нулиране на платно",
"exportJSON": "",
"exportImage": "",
"export": "Експортиране",
"exportToPng": "Изнасяне в PNG",
"exportToSvg": "Изнасяне в SVG",
"copyToClipboard": "Копиране в клипборда",
"copyPngToClipboard": "Копирай PNG в клипборда",
"scale": "Мащаб",
"save": "",
"save": "Запази",
"saveAs": "Запиши като",
"load": "Зареждане",
"getShareableLink": "Получаване на връзка за споделяне",
@@ -149,8 +142,6 @@
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
"collabStopOverridePrompt": "Прекратяването на сесията ще презапише предишната, локално запазена, рисунка. Сигурни ли сте?\n\n(Ако искате да продължите с локалната рисунка, просто затворете таба на браузъра.)",
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?",
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл",
@@ -159,12 +150,12 @@
},
"toolBar": {
"selection": "Селекция",
"draw": "Рисуване",
"rectangle": "Правоъгълник",
"diamond": "Диамант",
"ellipse": "Елипс",
"arrow": "Стрелка",
"line": "Линия",
"freedraw": "",
"text": "Текст",
"library": "Библиотека",
"lock": "Поддържайте избрания инструмент активен след рисуване"
@@ -217,14 +208,6 @@
"errorDialog": {
"title": "Грешка"
},
"exportDialog": {
"disk_title": "",
"disk_details": "",
"disk_button": "",
"link_title": "",
"link_details": "",
"link_button": ""
},
"helpDialog": {
"blog": "Прочетете нашия блог",
"click": "клик",
+9 -26
View File
@@ -20,10 +20,6 @@
"background": "Color del fons",
"fill": "Estil del fons",
"strokeWidth": "Amplada del traç",
"strokeShape": "",
"strokeShape_gel": "",
"strokeShape_fountain": "",
"strokeShape_brush": "",
"strokeStyle": "Estil del traç",
"strokeStyle_solid": "Sòlid",
"strokeStyle_dashed": "Guions",
@@ -42,8 +38,8 @@
"fontSize": "Mida de lletra",
"fontFamily": "Tipus de lletra",
"onlySelected": "Només seleccionats",
"withBackground": "",
"exportEmbedScene": "",
"withBackground": "Amb fons",
"exportEmbedScene": "Incrustar escena al fitxer exportat",
"exportEmbedScene_details": "Les dades de lescena es desaran al fitxer PNG/SVG de manera que es pugui restaurar lescena.\nAugmentarà la mida del fitxer exportat.",
"addWatermark": "Afegir \"Fet amb Excalidraw\"",
"handDrawn": "Dibuixat a mà",
@@ -65,7 +61,7 @@
"architect": "Arquitecte",
"artist": "Artista",
"cartoonist": "Dibuixant",
"fileTitle": "Nom del fitxer",
"fileTitle": "",
"colorPicker": "Selector de colors",
"canvasBackground": "Fons del llenç",
"drawingCanvas": "Llenç de dibuix",
@@ -96,24 +92,21 @@
"centerHorizontally": "Centrar horitzontalment",
"distributeHorizontally": "Distribuir horitzontalment",
"distributeVertically": "Distribuir verticalment",
"flipHorizontal": "Capgira horitzontalment",
"flipVertical": "Capgira verticalment",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Mode de visualització",
"toggleExportColorScheme": "Canvia l'esquema de colors de l'exportació",
"share": "Compartir",
"toggleTheme": ""
"share": "Compartir"
},
"buttons": {
"clearReset": "Netejar el llenç",
"exportJSON": "",
"exportImage": "",
"export": "Exportar",
"exportToPng": "Exportar a PNG",
"exportToSvg": "Exportar a SNG",
"copyToClipboard": "Copiar al porta-retalls",
"copyPngToClipboard": "Copiar PNG al porta-retalls",
"scale": "Escala",
"save": "",
"save": "Desar",
"saveAs": "Desar com",
"load": "Carregar",
"getShareableLink": "Obtenir enllaç per compartir",
@@ -149,8 +142,6 @@
"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 sadmet la importació dimatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada descena. Ho has activat durant l'exportació?",
"cannotRestoreFromImage": "Lescena no sha pogut restaurar des daquest fitxer dimatge",
@@ -159,12 +150,12 @@
},
"toolBar": {
"selection": "Selecció",
"draw": "Dibuix lliure",
"rectangle": "Rectangle",
"diamond": "Rombe",
"ellipse": "El·lipse",
"arrow": "Fletxa",
"line": "Línia",
"freedraw": "Dibuix",
"text": "Text",
"library": "Biblioteca",
"lock": "Mantenir activa l'eina seleccionada desprès de dibuixar"
@@ -217,14 +208,6 @@
"errorDialog": {
"title": "Error"
},
"exportDialog": {
"disk_title": "",
"disk_details": "",
"disk_button": "",
"link_title": "",
"link_details": "",
"link_button": ""
},
"helpDialog": {
"blog": "Llegiu el nostre blog",
"click": "clic",
@@ -248,7 +231,7 @@
},
"encrypted": {
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors dExcalidraw no els veuran mai.",
"link": "Article del blog sobre encriptació d'extrem a extrem a Excalidraw"
"link": ""
},
"stats": {
"angle": "Angle",
-277
View File
@@ -1,277 +0,0 @@
{
"labels": {
"paste": "Vložit",
"pasteCharts": "Vložit grafy",
"selectAll": "Vybrat vše",
"multiSelect": "Přidat prvek do výběru",
"moveCanvas": "Posunout plátno",
"cut": "Vyjmout",
"copy": "Kopírovat",
"copyAsPng": "Zkopírovat do schránky jako PNG",
"copyAsSvg": "Zkopírovat do schránky jako SVG",
"bringForward": "Přenést blíž",
"sendToBack": "Přenést do pozadí",
"bringToFront": "Přenést do popředí",
"sendBackward": "Přenést dál",
"delete": "Smazat",
"copyStyles": "Kopírovat styly",
"pasteStyles": "Vložit styly",
"stroke": "Obrys",
"background": "Pozadí",
"fill": "Výplň",
"strokeWidth": "Šířka obrysu",
"strokeShape": "Tvar tahu",
"strokeShape_gel": "Gelové pero",
"strokeShape_fountain": "Plnicí pero",
"strokeShape_brush": "Fixa",
"strokeStyle": "Styl tahu",
"strokeStyle_solid": "Plný",
"strokeStyle_dashed": "Čárkovaný",
"strokeStyle_dotted": "Tečkovaný",
"sloppiness": "Stylizace",
"opacity": "Průhlednost",
"textAlign": "Zarovnání textu",
"edges": "Hrany",
"sharp": "Ostré",
"round": "Zaoblené",
"arrowheads": "Styl šipky",
"arrowhead_none": "Žádný",
"arrowhead_arrow": "Šipka",
"arrowhead_bar": "Kóta",
"arrowhead_dot": "Tečka",
"fontSize": "Velikost písma",
"fontFamily": "Písmo",
"onlySelected": "Pouze vybrané",
"withBackground": "",
"exportEmbedScene": "",
"exportEmbedScene_details": "",
"addWatermark": "",
"handDrawn": "Od ruky",
"normal": "Normální",
"code": "Kód",
"small": "Malé",
"medium": "Střední",
"large": "Velké",
"veryLarge": "Velmi velké",
"solid": "",
"hachure": "",
"crossHatch": "",
"thin": "",
"bold": "",
"left": "",
"center": "",
"right": "",
"extraBold": "",
"architect": "",
"artist": "",
"cartoonist": "",
"fileTitle": "",
"colorPicker": "",
"canvasBackground": "Pozadí plátna",
"drawingCanvas": "",
"layers": "",
"actions": "",
"language": "",
"liveCollaboration": "",
"duplicateSelection": "",
"untitled": "",
"name": "",
"yourName": "",
"madeWithExcalidraw": "",
"group": "",
"ungroup": "",
"collaborators": "",
"showGrid": "",
"addToLibrary": "",
"removeFromLibrary": "",
"libraryLoadingMessage": "",
"libraries": "",
"loadingScene": "",
"align": "",
"alignTop": "",
"alignBottom": "",
"alignLeft": "",
"alignRight": "",
"centerVertically": "",
"centerHorizontally": "",
"distributeHorizontally": "",
"distributeVertically": "",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Náhled",
"toggleExportColorScheme": "",
"share": "Sdílet",
"toggleTheme": "Přepnout tmavý řežim"
},
"buttons": {
"clearReset": "",
"exportJSON": "",
"exportImage": "",
"export": "Exportovat",
"exportToPng": "Exportovat do PNG",
"exportToSvg": "Exportovat do SVG",
"copyToClipboard": "Kopírovat do schránky",
"copyPngToClipboard": "Kopírovat PNG do schránky",
"scale": "Měřítko",
"save": "",
"saveAs": "Uložit jako",
"load": "Nahrát",
"getShareableLink": "Získat odkaz pro sdílení",
"close": "Zavřít",
"selectLanguage": "Zvolit jazyk",
"scrollBackToContent": "",
"zoomIn": "Přiblížit",
"zoomOut": "Oddálit",
"resetZoom": "Resetovat přiblížení",
"menu": "Menu",
"done": "Hotovo",
"edit": "Upravit",
"undo": "Zpět",
"redo": "Znovu",
"resetLibrary": "",
"createNewRoom": "Vytvořit novou místnost",
"fullScreen": "Celá obrazovka",
"darkMode": "Tmavý režim",
"lightMode": "Světlý režim",
"zenMode": "Zen mód",
"exitZenMode": "Opustit zen mód"
},
"alerts": {
"clearReset": "",
"couldNotCreateShareableLink": "",
"couldNotCreateShareableLinkTooBig": "",
"couldNotLoadInvalidFile": "",
"importBackendFailed": "",
"cannotExportEmptyCanvas": "",
"couldNotCopyToClipboard": "",
"decryptFailed": "",
"uploadedSecurly": "",
"loadSceneOverridePrompt": "",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "",
"imageDoesNotContainScene": "",
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": ""
},
"toolBar": {
"selection": "Výběr",
"rectangle": "Obdélník",
"diamond": "Diamant",
"ellipse": "Elipsa",
"arrow": "Šipka",
"line": "Čára",
"freedraw": "Kreslení",
"text": "Text",
"library": "",
"lock": ""
},
"headings": {
"canvasActions": "",
"selectedShapeActions": "",
"shapes": "Tvary"
},
"hints": {
"linearElement": "",
"freeDraw": "",
"text": "",
"linearElementMulti": "",
"lockAngle": "",
"resize": "",
"rotate": "",
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": ""
},
"canvasError": {
"cannotShowPreview": "",
"canvasTooBig": "",
"canvasTooBigTip": ""
},
"errorSplash": {
"headingMain_pre": "",
"headingMain_button": "",
"clearCanvasMessage": "",
"clearCanvasMessage_button": "",
"clearCanvasCaveat": "",
"trackedToSentry_pre": "",
"trackedToSentry_post": "",
"openIssueMessage_pre": "",
"openIssueMessage_button": "",
"openIssueMessage_post": "",
"sceneContent": ""
},
"roomDialog": {
"desc_intro": "",
"desc_privacy": "",
"button_startSession": "",
"button_stopSession": "",
"desc_inProgressIntro": "",
"desc_shareLink": "",
"desc_exitSession": "",
"shareTitle": ""
},
"errorDialog": {
"title": ""
},
"exportDialog": {
"disk_title": "",
"disk_details": "",
"disk_button": "",
"link_title": "",
"link_details": "",
"link_button": ""
},
"helpDialog": {
"blog": "",
"click": "kliknutí",
"curvedArrow": "",
"curvedLine": "",
"documentation": "",
"drag": "tažení",
"editor": "",
"github": "",
"howto": "",
"or": "nebo",
"preventBinding": "",
"shapes": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
},
"encrypted": {
"tooltip": "",
"link": ""
},
"stats": {
"angle": "",
"element": "",
"elements": "",
"height": "",
"scene": "",
"selected": "",
"storage": "",
"title": "",
"total": "",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": ""
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": "",
"canvas": "plátno",
"selection": "výběr"
}
}
+16 -33
View File
@@ -20,10 +20,6 @@
"background": "Hintergrund",
"fill": "Füllung",
"strokeWidth": "Strichstärke",
"strokeShape": "Strichform",
"strokeShape_gel": "Gelschreiber",
"strokeShape_fountain": "Füllfederhalter",
"strokeShape_brush": "Pinselstift",
"strokeStyle": "Konturstil",
"strokeStyle_solid": "Durchgezogen",
"strokeStyle_dashed": "Gestrichelt",
@@ -32,7 +28,7 @@
"opacity": "Deckkraft",
"textAlign": "Textausrichtung",
"edges": "Kanten",
"sharp": "Scharf",
"sharp": "Eckig",
"round": "Rund",
"arrowheads": "Pfeilspitzen",
"arrowhead_none": "Keine",
@@ -42,8 +38,8 @@
"fontSize": "Schriftgröße",
"fontFamily": "Schriftfamilie",
"onlySelected": "Nur ausgewählte",
"withBackground": "Hintergrund",
"exportEmbedScene": "Szene einbetten",
"withBackground": "Mit Hintergrund",
"exportEmbedScene": "Zeichnung in exportierte Datei einbetten",
"exportEmbedScene_details": "Die Zeichnungsdaten werden in der exportierten PNG/SVG-Datei gespeichert, sodass das Dokument später weiter bearbeitet werden kann. \nDieses wird die exportierte Datei vergrößern.",
"addWatermark": "\"Made with Excalidraw\" hinzufügen",
"handDrawn": "Handgezeichnet",
@@ -80,7 +76,7 @@
"madeWithExcalidraw": "Made with Excalidraw",
"group": "Auswahl gruppieren",
"ungroup": "Gruppierung aufheben",
"collaborators": "Mitarbeitende",
"collaborators": "Kollaboratoren",
"showGrid": "Raster anzeigen",
"addToLibrary": "Zur Bibliothek hinzufügen",
"removeFromLibrary": "Aus Bibliothek entfernen",
@@ -99,21 +95,18 @@
"flipHorizontal": "Horizontal spiegeln",
"flipVertical": "Vertikal spiegeln",
"viewMode": "Ansichtsmodus",
"toggleExportColorScheme": "Exportfarbschema umschalten",
"share": "Teilen",
"toggleTheme": "Design umschalten"
"toggleExportColorScheme": "Farbschema für Export umschalten",
"share": "Teilen"
},
"buttons": {
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
"exportJSON": "In Datei exportieren",
"exportImage": "Als Bild speichern",
"export": "Exportieren",
"exportToPng": "Als PNG exportieren",
"exportToSvg": "Als SVG exportieren",
"copyToClipboard": "In Zwischenablage kopieren",
"copyPngToClipboard": "PNG in die Zwischenablage kopieren",
"scale": "Skalierung",
"save": "In aktueller Datei speichern",
"save": "Speichern",
"saveAs": "Speichern unter",
"load": "Laden",
"getShareableLink": "Teilbaren Link erhalten",
@@ -131,8 +124,8 @@
"resetLibrary": "Bibliothek zurücksetzen",
"createNewRoom": "Neuen Raum erstellen",
"fullScreen": "Vollbildanzeige",
"darkMode": "Dunkler Modus",
"lightMode": "Heller Modus",
"darkMode": "Dunkles Design",
"lightMode": "Helles Design",
"zenMode": "Zen-Modus",
"exitZenMode": "Zen-Modus verlassen"
},
@@ -146,12 +139,10 @@
"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 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.)",
"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.)",
"errorLoadingLibrary": "Beim Laden der Drittanbieter-Bibliothek ist ein Fehler aufgetreten.",
"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?",
"confirmAddLibrary": "Dieses fügt {{numShapes}} Form(en) zu deiner Bibliothek hinzu. Bist du 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.",
@@ -159,12 +150,12 @@
},
"toolBar": {
"selection": "Auswahl",
"draw": "Freies Zeichnen",
"rectangle": "Rechteck",
"diamond": "Raute",
"ellipse": "Ellipse",
"arrow": "Pfeil",
"line": "Linie",
"freedraw": "Zeichnen",
"text": "Text",
"library": "Bibliothek",
"lock": "Ausgewähltes Werkzeug nach Zeichnen aktiv lassen"
@@ -177,7 +168,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",
@@ -217,14 +208,6 @@
"errorDialog": {
"title": "Fehler"
},
"exportDialog": {
"disk_title": "Auf Festplatte speichern",
"disk_details": "Exportiere die Zeichnungsdaten in eine Datei, die Du später importieren kannst.",
"disk_button": "In Datei speichern",
"link_title": "Teilbarer Link",
"link_details": "Als schreibgeschützten Link exportieren.",
"link_button": "Als Link exportieren"
},
"helpDialog": {
"blog": "Lies unseren Blog",
"click": "klicken",
@@ -266,12 +249,12 @@
"width": "Breite"
},
"toast": {
"copyStyles": "Formatierungen kopiert.",
"copyStyles": "Formatierung kopiert.",
"copyToClipboard": "In die Zwischenablage kopiert.",
"copyToClipboardAsPng": "{{exportSelection}} als PNG in die Zwischenablage kopiert\n({{exportColorScheme}})",
"fileSaved": "Datei gespeichert.",
"fileSavedToFilename": "Als {filename} gespeichert",
"canvas": "Zeichenfläche",
"canvas": "Leinwand",
"selection": "Auswahl"
}
}
+5 -22
View File
@@ -20,10 +20,6 @@
"background": "Φόντο",
"fill": "Γέμισμα",
"strokeWidth": "Πάχος μολυβιάς",
"strokeShape": "",
"strokeShape_gel": "",
"strokeShape_fountain": "",
"strokeShape_brush": "",
"strokeStyle": "Στυλ περιγράμματος",
"strokeStyle_solid": "Συμπαγής",
"strokeStyle_dashed": "Διακεκομμένη με παύλες",
@@ -42,8 +38,8 @@
"fontSize": "Μέγεθος γραμματοσειράς",
"fontFamily": "Γραμματοσειρά",
"onlySelected": "Μόνο τα Επιλεγμένα",
"withBackground": "",
"exportEmbedScene": "",
"withBackground": "Με φόντο",
"exportEmbedScene": "Ενσωμάτωση της σκηνής στο αρχείο προς εξαγωγή",
"exportEmbedScene_details": "Τα δεδομένα σκηνής θα αποθηκευτούν στο αρχείο PNG/SVG προς εξαγωγή ώστε η σκηνή να είναι δυνατό να αποκατασταθεί από αυτό.\nΘα αυξήσει το μέγεθος του αρχείου προς εξαγωγή.",
"addWatermark": "Προσθήκη \"Φτιαγμένο με Excalidraw\"",
"handDrawn": "Σχεδιασμένο στο χέρι",
@@ -100,20 +96,17 @@
"flipVertical": "Κατακόρυφη αναστροφή",
"viewMode": "Λειτουργία προβολής",
"toggleExportColorScheme": "Εναλλαγή εξαγωγής θέματος χρωμάτων",
"share": "Κοινοποίηση",
"toggleTheme": ""
"share": "Κοινοποίηση"
},
"buttons": {
"clearReset": "Επαναφορά του καμβά",
"exportJSON": "",
"exportImage": "",
"export": "Εξαγωγή",
"exportToPng": "Εξαγωγή σε PNG",
"exportToSvg": "Εξαγωγή σε SVG",
"copyToClipboard": "Αντιγραφή στο πρόχειρο",
"copyPngToClipboard": "Αντιγραφή PNG στο πρόχειρο",
"scale": "Κλίμακα",
"save": "",
"save": "Αποθήκευση",
"saveAs": "Αποθήκευση ως",
"load": "Άνοιγμα",
"getShareableLink": "Δημόσιος σύνδεσμος",
@@ -149,8 +142,6 @@
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
"collabStopOverridePrompt": "Η διακοπή της συνεδρίας θα αντικαταστήσει το προηγούμενο, τοπικά αποθηκευμένο σχέδιο. Είστε σίγουροι?\n\n(Αν θέλετε να διατηρήσετε το τοπικό σας σχέδιο, απλά κλείστε την καρτέλα του προγράμματος περιήγησης.)",
"errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;",
"imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;",
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
@@ -159,12 +150,12 @@
},
"toolBar": {
"selection": "Επιλογή",
"draw": "Ελεύθερο σχέδιο",
"rectangle": "Ορθογώνιο",
"diamond": "Ρόμβος",
"ellipse": "Έλλειψη",
"arrow": "Βέλος",
"line": "Γραμμή",
"freedraw": "",
"text": "Κείμενο",
"library": "Βιβλιοθήκη",
"lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο"
@@ -217,14 +208,6 @@
"errorDialog": {
"title": "Σφάλμα"
},
"exportDialog": {
"disk_title": "",
"disk_details": "",
"disk_button": "",
"link_title": "",
"link_details": "",
"link_button": ""
},
"helpDialog": {
"blog": "Διαβάστε το Blog μας",
"click": "κλικ",
+5 -24
View File
@@ -20,10 +20,6 @@
"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",
@@ -42,8 +38,8 @@
"fontSize": "Font size",
"fontFamily": "Font family",
"onlySelected": "Only selected",
"withBackground": "Background",
"exportEmbedScene": "Embed scene",
"withBackground": "With background",
"exportEmbedScene": "Embed scene into exported file",
"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",
@@ -100,22 +96,17 @@
"flipVertical": "Flip vertical",
"viewMode": "View mode",
"toggleExportColorScheme": "Toggle export color scheme",
"share": "Share",
"showStroke": "Show stroke color picker",
"showBackground": "Show background color picker",
"toggleTheme": "Toggle theme"
"share": "Share"
},
"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 to current file",
"save": "Save",
"saveAs": "Save as",
"load": "Load",
"getShareableLink": "Get shareable link",
@@ -151,8 +142,6 @@
"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",
@@ -161,12 +150,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"
@@ -219,14 +208,6 @@
"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",

Some files were not shown because too many files have changed in this diff Show More