Compare commits

..
Author SHA1 Message Date
dwelle 0e3a5b2042 feat: cycle through selected elements on cmd/ctrl-click 2021-05-29 22:34:41 +02:00
185 changed files with 4144 additions and 9213 deletions
-2
View File
@@ -5,11 +5,9 @@
.env.test.local
.envrc
.eslintcache
.history
.idea
.vercel
.vscode
.yarn
*.log
*.tgz
build
+1 -1
View File
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
COPY . .
RUN yarn build:app:docker
FROM nginx:1.21-alpine
FROM nginx:1.17-alpine
COPY --from=build /opt/node_app/build /usr/share/nginx/html
+1 -3
View File
@@ -70,8 +70,6 @@ The first set of digits is the room. This is visible from the server thats go
The second set of digits is the encryption key. The Excalidraw server doesnt know about it. This is what all the participants use to encrypt/decrypt the messages.
> Note: Please ensure that the encryption key is 22 characters long.
## Shape libraries
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
@@ -95,7 +93,7 @@ These instructions will get you a copy of the project up and running on your loc
#### Requirements
- [Node.js](https://nodejs.org/en/)
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
- [Yarn](https://yarnpkg.com/getting-started/install)
- [Git](https://git-scm.com/downloads)
#### Clone the repo
-3
View File
@@ -2,8 +2,5 @@
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"storage": {
"rules": "storage.rules"
}
}
-12
View File
@@ -1,12 +0,0 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{migrations} {
match /{scenes}/{scene} {
allow get, write: if true;
// redundant, but let's be explicit'
allow list: if false;
}
}
}
}
+3 -5
View File
@@ -19,17 +19,15 @@
]
},
"dependencies": {
"@dwelle/browser-fs-access": "0.21.1",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "11.2.6",
"@tldraw/vec": "0.0.106",
"@types/jest": "26.0.22",
"@types/react": "17.0.3",
"@types/react-dom": "17.0.3",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.16.4",
"clsx": "1.1.1",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.0",
@@ -37,7 +35,7 @@
"nanoid": "3.1.22",
"open-color": "1.8.0",
"pako": "1.0.11",
"perfect-freehand": "1.0.15",
"perfect-freehand": "0.4.7",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
@@ -78,7 +76,7 @@
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
],
"resetMocks": false
},
Binary file not shown.
-12
View File
@@ -13,18 +13,6 @@
<meta name="theme-color" content="#000" />
<!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
<meta
http-equiv="origin-trial"
content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
/>
<!-- File Handling (https://web.dev/file-handling/) -->
<meta
http-equiv="origin-trial"
content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
/>
<!-- General tags -->
<meta
name="description"
+1 -1
View File
@@ -26,7 +26,7 @@
}
}
],
"capture_links": "new-client",
"capture_links": "new_client",
"share_target": {
"action": "/web-share-target",
"method": "POST",
+1 -4
View File
@@ -38,8 +38,7 @@ const crowdinMap = {
"zh-CN": "en-zhcn",
"zh-TW": "en-zhtw",
"lv-LV": "en-lv",
"cs-CZ": "en-cs",
"kk-KZ": "en-kk",
"cs-CZ": "cs-cz",
};
const flags = {
@@ -79,7 +78,6 @@ const flags = {
"zh-TW": "🇹🇼",
"lv-LV": "🇱🇻",
"cs-CZ": "🇨🇿",
"kk-KZ": "🇰🇿",
};
const languages = {
@@ -119,7 +117,6 @@ const languages = {
"zh-TW": "繁體中文",
"lv-LV": "Latviešu",
"cs-CZ": "Česky",
"kk-KZ": "Қазақ тілі",
};
const percentages = fs.readFileSync(
-39
View File
@@ -1,39 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const updateReadme = require("./updateReadme");
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage);
pkg.version = nextVersion;
const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
};
const release = async (nextVersion) => {
try {
updateReadme();
await updateChangelog(nextVersion);
updatePackageVersion(nextVersion);
await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
/* eslint-disable no-console */
console.log("Done!");
} catch (e) {
console.error(e);
process.exit(1);
}
};
const nextVersion = process.argv.slice(2)[0];
if (!nextVersion) {
console.error("Pass the next version to release!");
process.exit(1);
}
release(nextVersion);
-97
View File
@@ -1,97 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const lastVersion = pkg.version;
const existingChangeLog = fs.readFileSync(
`${excalidrawDir}/CHANGELOG.md`,
"utf8",
);
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
const headerForType = {
feat: "Features",
fix: "Fixes",
style: "Styles",
refactor: " Refactor",
perf: "Performance",
build: "Build",
};
const getCommitHashForLastVersion = async () => {
try {
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
const { stdout } = await exec(
`git log --format=format:"%H" --grep=${commitMessage}`,
);
return stdout;
} catch (e) {
console.error(e);
}
};
const getLibraryCommitsSinceLastRelease = async () => {
const commitHash = await getCommitHashForLastVersion();
const { stdout } = await exec(
`git log --pretty=format:%s ${commitHash}...master`,
);
const commitsSinceLastRelease = stdout.split("\n");
const commitList = {};
supportedTypes.forEach((type) => {
commitList[type] = [];
});
commitsSinceLastRelease.forEach((commit) => {
const indexOfColon = commit.indexOf(":");
const type = commit.slice(0, indexOfColon);
if (!supportedTypes.includes(type)) {
return;
}
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
const messageWithCapitalizeFirst =
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
// return if the changelog already contains the pr number which would happen for package updates
if (existingChangeLog.includes(prNumber)) {
return;
}
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
const messageWithPRLink = messageWithCapitalizeFirst.replace(
/\(#[0-9]*\)/,
prMarkdown,
);
commitList[type].push(messageWithPRLink);
});
return commitList;
};
const updateChangelog = async (nextVersion) => {
const commitList = await getLibraryCommitsSinceLastRelease();
let changelogForLibrary =
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
supportedTypes.forEach((type) => {
if (commitList[type].length) {
changelogForLibrary += `### ${headerForType[type]}\n\n`;
const commits = commitList[type];
commits.forEach((commit) => {
changelogForLibrary += `- ${commit}\n\n`;
});
}
});
changelogForLibrary += "---\n";
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
let updatedContent =
existingChangeLog.slice(0, lastVersionIndex) +
changelogForLibrary +
existingChangeLog.slice(lastVersionIndex);
const currentDate = new Date().toISOString().slice(0, 10);
const newVersion = `## ${nextVersion} (${currentDate})`;
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
};
module.exports = updateChangelog;
-27
View File
@@ -1,27 +0,0 @@
const fs = require("fs");
const updateReadme = () => {
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
// remove note for unstable release
data = data.replace(
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
"",
);
// replace "excalidraw-next" with "excalidraw"
data = data.replace(/excalidraw-next/g, "excalidraw");
data = data.trim();
const demoIndex = data.indexOf("### Demo");
const excalidrawNextNote =
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
// Add excalidraw next note to try out for unreleased changes
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
};
module.exports = updateReadme;
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { alignElements, Alignment } from "../align";
import {
AlignBottomIcon,
+9 -17
View File
@@ -1,9 +1,10 @@
import React from "react";
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker";
import { trash, zoomIn, zoomOut } from "../components/icons";
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants";
import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
@@ -16,7 +17,6 @@ import { getNewZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -34,9 +34,9 @@ export const actionChangeViewBackgroundColor = register({
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={(color) => updateData({ viewBackgroundColor: color })}
isActive={appState.openPopup === "canvasColorPicker"}
isActive={appState.openMenu === "canvasColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "canvasColorPicker" : null })
updateData({ openMenu: active ? "canvasColorPicker" : null })
}
data-testid="canvas-background-picker"
/>
@@ -108,7 +108,6 @@ export const actionZoomIn = register({
onClick={() => {
updateData(null);
}}
size="small"
/>
),
keyTest: (event) =>
@@ -143,7 +142,6 @@ export const actionZoomOut = register({
onClick={() => {
updateData(null);
}}
size="small"
/>
),
keyTest: (event) =>
@@ -170,21 +168,16 @@ export const actionResetZoom = register({
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
<Tooltip label={t("buttons.resetZoom")}>
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
className="reset-zoom-button"
icon={resetZoom}
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
onClick={() => {
updateData(null);
}}
size="small"
>
{(appState.zoom.value * 100).toFixed(0)}%
</ToolButton>
</Tooltip>
/>
),
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
@@ -278,8 +271,7 @@ export const actionToggleTheme = register({
return {
appState: {
...appState,
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
theme: value || (appState.theme === "light" ? "dark" : "light"),
},
commitToHistory: false,
};
+1
View File
@@ -1,6 +1,7 @@
import { isSomeElementSelected } from "../scene";
import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton";
import React from "react";
import { trash } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import {
DistributeHorizontallyIcon,
DistributeVerticallyIcon,
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { KEYS } from "../keys";
import { register } from "./register";
import { ExcalidrawElement } from "../element/types";
+20 -75
View File
@@ -1,25 +1,18 @@
import React from "react";
import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons";
import { load, questionCircle, save, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave";
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 { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
export const actionChangeProjectName = register({
name: "changeProjectName",
@@ -39,54 +32,6 @@ export const actionChangeProjectName = register({
),
});
export const actionChangeExportScale = register({
name: "changeExportScale",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
commitToHistory: false,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
const elements = getNonDeletedElements(allElements);
const exportSelected = isSomeElementSelected(elements, appState);
const exportedElements = exportSelected
? getSelectedElements(elements, appState)
: elements;
return (
<>
{EXPORT_SCALES.map((s) => {
const [width, height] = getExportSize(
exportedElements,
DEFAULT_EXPORT_PADDING,
s,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${s}x (${width}x${height})`;
return (
<ToolButton
key={s}
size="small"
type="radio"
icon={`${s}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={s === appState.exportScale}
onChange={() => updateData(s)}
/>
);
})}
</>
);
},
});
export const actionChangeExportBackground = register({
name: "changeExportBackground",
perform: (_elements, appState, value) => {
@@ -120,7 +65,7 @@ export const actionChangeExportEmbedScene = register({
>
{t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
<div className="Tooltip-icon">{questionCircle}</div>
</Tooltip>
</CheckboxItem>
),
@@ -130,19 +75,15 @@ export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
perform: async (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle;
try {
const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene(elements, appState)
: await saveAsJSON(elements, appState);
const { fileHandle } = await saveAsJSON(elements, appState);
return {
commitToHistory: false,
appState: {
...appState,
fileHandle,
toastMessage: fileHandleExists
? fileHandle?.name
? fileHandle.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
@@ -160,10 +101,14 @@ export const actionSaveToActiveFile = register({
},
keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData, appState }) => (
<ActiveFile
onSave={() => updateData(null)}
fileName={appState.fileHandle?.name}
PanelComponent: ({ updateData }) => (
<ToolButton
type="icon"
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
onClick={() => updateData(null)}
data-testid="save-button"
/>
),
});
@@ -193,7 +138,7 @@ export const actionSaveFileToDisk = register({
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()}
hidden={!nativeFileSystemSupported}
hidden={!fsSupported}
onClick={() => updateData(null)}
data-testid="save-as-button"
/>
@@ -207,7 +152,7 @@ export const actionLoadScene = register({
const {
elements: loadedElements,
appState: loadedAppState,
} = await loadFromJSON(appState, elements);
} = await loadFromJSON(appState);
return {
elements: loadedElements,
appState: loadedAppState,
@@ -256,9 +201,9 @@ export const actionExportWithDarkMode = register({
}}
>
<DarkModeToggle
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
onChange={(theme: Theme) => {
updateData(theme === THEME.DARK);
value={appState.exportWithDarkMode ? "dark" : "light"}
onChange={(theme: Appearence) => {
updateData(theme === "dark");
}}
title={t("labels.toggleExportColorScheme")}
/>
+1
View File
@@ -1,6 +1,7 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { resetCursor } from "../utils";
import React from "react";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { getShortcutKey } from "../utils";
+3 -4
View File
@@ -1,4 +1,5 @@
import { Action, ActionResult } from "./types";
import React from "react";
import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
@@ -68,13 +69,12 @@ export const createUndoAction: ActionCreator = (history) => ({
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => (
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={undo}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
@@ -89,13 +89,12 @@ export const createRedoAction: ActionCreator = (history) => ({
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => (
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={redo}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { menu, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
+3 -2
View File
@@ -1,3 +1,4 @@
import React from "react";
import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
@@ -29,8 +30,8 @@ export const actionGoToCollaborator = register({
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData, data }) => {
const clientId: string | undefined = data?.id;
PanelComponent: ({ appState, updateData, id }) => {
const clientId = id;
if (!clientId) {
return null;
}
+20 -27
View File
@@ -1,3 +1,4 @@
import React from "react";
import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker";
@@ -12,13 +13,6 @@ import {
FillCrossHatchIcon,
FillHachureIcon,
FillSolidIcon,
FontFamilyCodeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontSizeExtraLargeIcon,
FontSizeLargeIcon,
FontSizeMediumIcon,
FontSizeSmallIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
@@ -26,15 +20,18 @@ import {
StrokeStyleDottedIcon,
StrokeStyleSolidIcon,
StrokeWidthIcon,
TextAlignCenterIcon,
FontSizeSmallIcon,
FontSizeMediumIcon,
FontSizeLargeIcon,
FontSizeExtraLargeIcon,
FontFamilyHandDrawnIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
} from "../components/icons";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
} from "../constants";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import {
getNonDeletedElements,
isTextElement,
@@ -47,7 +44,7 @@ import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FontFamilyValues,
FontFamily,
TextAlign,
} from "../element/types";
import { getLanguage, t } from "../i18n";
@@ -129,9 +126,9 @@ export const actionChangeStrokeColor = register({
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "strokeColorPicker"}
isActive={appState.openMenu === "strokeColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
updateData({ openMenu: active ? "strokeColorPicker" : null })
}
/>
</>
@@ -169,9 +166,9 @@ export const actionChangeBackgroundColor = register({
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
isActive={appState.openPopup === "backgroundColorPicker"}
isActive={appState.openMenu === "backgroundColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null })
updateData({ openMenu: active ? "backgroundColorPicker" : null })
}
/>
</>
@@ -502,23 +499,19 @@ export const actionChangeFontFamily = register({
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const options: {
value: FontFamilyValues;
text: string;
icon: JSX.Element;
}[] = [
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
{
value: FONT_FAMILY.Virgil,
value: 1,
text: t("labels.handDrawn"),
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
},
{
value: FONT_FAMILY.Helvetica,
value: 2,
text: t("labels.normal"),
icon: <FontFamilyNormalIcon theme={appState.theme} />,
},
{
value: FONT_FAMILY.Cascadia,
value: 3,
text: t("labels.code"),
icon: <FontFamilyCodeIcon theme={appState.theme} />,
},
@@ -527,7 +520,7 @@ export const actionChangeFontFamily = register({
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false>
<ButtonIconSelect<FontFamily | false>
group="font-family"
options={options}
value={getFormValue(
+1
View File
@@ -10,6 +10,7 @@ export const actionToggleViewMode = register({
appState: {
...appState,
viewModeEnabled: !this.checked!(appState),
selectedElementIds: {},
},
commitToHistory: false,
};
+6 -6
View File
@@ -5,7 +5,6 @@ import {
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppProps, AppState } from "../types";
@@ -108,10 +107,11 @@ export class ActionManager implements ActionsManagerInterface {
);
}
/**
* @param data additional data sent to the PanelComponent
*/
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
// Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components
// like the user list. We can use this key to extract more
// data from app state. This is an alternative to generic prop hell!
renderAction = (name: ActionName, id?: string) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
@@ -139,8 +139,8 @@ export class ActionManager implements ActionsManagerInterface {
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
id={id}
appProps={this.app.props}
data={data}
/>
);
}
+5 -9
View File
@@ -2,7 +2,6 @@ import React from "react";
import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types";
import Library from "../data/library";
import { ToolButtonSize } from "../components/ToolButton";
/** if false, the action should be prevented */
export type ActionResult =
@@ -67,7 +66,6 @@ export type ActionName =
| "changeProjectName"
| "changeExportBackground"
| "changeExportEmbedScene"
| "changeExportScale"
| "saveToActiveFile"
| "saveFileToDisk"
| "loadScene"
@@ -103,17 +101,15 @@ export type ActionName =
| "exportWithDarkMode"
| "toggleTheme";
export type PanelComponentProps = {
export interface Action {
name: ActionName;
PanelComponent?: React.FC<{
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Partial<{ id: string; size: ToolButtonSize }>;
};
export interface Action {
name: ActionName;
PanelComponent?: React.FC<PanelComponentProps>;
id?: string;
}>;
perform: ActionFn;
keyPriority?: number;
keyTest?: (
+1 -11
View File
@@ -3,23 +3,17 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
EXPORT_SCALES,
THEME,
} from "./constants";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
: 1;
export const getDefaultAppState = (): Omit<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> => {
return {
theme: THEME.LIGHT,
theme: "light",
collaborators: new Map(),
currentChartType: "bar",
currentItemBackgroundColor: "transparent",
@@ -45,7 +39,6 @@ export const getDefaultAppState = (): Omit<
elementType: "selection",
errorMessage: null,
exportBackground: true,
exportScale: defaultExportScale,
exportEmbedScene: false,
exportWithDarkMode: false,
fileHandle: null,
@@ -59,7 +52,6 @@ export const getDefaultAppState = (): Omit<
multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
openMenu: null,
openPopup: null,
pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {},
resizingElement: null,
@@ -124,7 +116,6 @@ const APP_STATE_STORAGE_CONF = (<
errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false },
exportEmbedScene: { browser: true, export: false },
exportScale: { browser: true, export: false },
exportWithDarkMode: { browser: true, export: false },
fileHandle: { browser: false, export: false },
gridSize: { browser: true, export: true },
@@ -140,7 +131,6 @@ const APP_STATE_STORAGE_CONF = (<
offsetLeft: { browser: false, export: false },
offsetTop: { browser: false, export: false },
openMenu: { browser: true, export: false },
openPopup: { browser: false, export: false },
pasteDialog: { browser: false, export: false },
previousSelectedElementIds: { browser: true, export: false },
resizingElement: { browser: false, export: false },
+26 -1
View File
@@ -151,14 +151,23 @@ export const SelectedShapeActions = ({
);
};
const LIBRARY_ICON = (
// fa-th-large
<svg viewBox="0 0 512 512">
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
</svg>
);
export const ShapesSwitcher = ({
canvas,
elementType,
setAppState,
isLibraryOpen,
}: {
canvas: HTMLCanvasElement | null;
elementType: ExcalidrawElement["type"];
setAppState: React.Component<any, AppState>["setState"];
isLibraryOpen: boolean;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
@@ -192,6 +201,19 @@ export const ShapesSwitcher = ({
/>
);
})}
<ToolButton
className="Shape ToolIcon_type_button__library"
type="button"
icon={LIBRARY_ICON}
name="editor-library"
keyBindingLabel="9"
aria-keyshortcuts="9"
title={`${capitalizeString(t("toolBar.library"))} — 9`}
aria-label={capitalizeString(t("toolBar.library"))}
onClick={() => {
setAppState({ isLibraryOpen: !isLibraryOpen });
}}
/>
</>
);
@@ -204,9 +226,12 @@ export const ZoomActions = ({
}) => (
<Stack.Col gap={1}>
<Stack.Row gap={1} align="center">
{renderAction("zoomOut")}
{renderAction("zoomIn")}
{renderAction("zoomOut")}
{renderAction("resetZoom")}
<div style={{ marginInlineStart: 4 }}>
{(zoom.value * 100).toFixed(0)}%
</div>
</Stack.Row>
</Stack.Col>
);
-21
View File
@@ -1,21 +0,0 @@
.excalidraw {
.ActiveFile {
.ActiveFile__fileName {
display: flex;
align-items: center;
span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 9.3em;
}
svg {
width: 1.15em;
margin-inline-end: 0.3em;
transform: scaleY(0.9);
}
}
}
}
-28
View File
@@ -1,28 +0,0 @@
import Stack from "../components/Stack";
import { ToolButton } from "../components/ToolButton";
import { save, file } from "../components/icons";
import { t } from "../i18n";
import "./ActiveFile.scss";
type ActiveFileProps = {
fileName?: string;
onSave: () => void;
};
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<Stack.Row className="ActiveFile" gap={1} align="center">
<span className="ActiveFile__fileName">
{file}
<span>{fileName}</span>
</span>
<ToolButton
type="icon"
icon={save}
title={t("buttons.save")}
aria-label={t("buttons.save")}
onClick={onSave}
data-testid="save-button"
/>
</Stack.Row>
);
+140 -110
View File
@@ -2,6 +2,7 @@ import React, { useContext } from "react";
import { RoughCanvas } from "roughjs/bin/canvas";
import rough from "roughjs/bin/rough";
import clsx from "clsx";
import { supported as fsSupported } from "browser-fs-access";
import { nanoid } from "nanoid";
import {
@@ -60,7 +61,6 @@ import {
SCROLL_TIMEOUT,
TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD,
THEME,
TOUCH_CTX_MENU_TIMEOUT,
URL_HASH_KEYS,
URL_QUERY_KEYS,
@@ -111,6 +111,7 @@ import {
import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement } from "../element/mutateElement";
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
import { MaybeTransformHandleType } from "../element/transformHandles";
import {
isBindingElement,
isBindingElementType,
@@ -156,7 +157,6 @@ import {
getElementsWithinSelection,
getNormalizedZoom,
getSelectedElements,
hasBackground,
isOverScrollBars,
isSomeElementSelected,
} from "../scene";
@@ -167,11 +167,9 @@ import { findShapeByKey } from "../shapes";
import {
AppProps,
AppState,
ExcalidrawImperativeAPI,
Gesture,
GestureEvent,
LibraryItems,
PointerDownState,
SceneData,
} from "../types";
import {
@@ -182,6 +180,7 @@ import {
isToolIcon,
isWritableElement,
resetCursor,
ResolvablePromise,
resolvablePromise,
sceneCoordsToViewportCoords,
setCursor,
@@ -195,14 +194,12 @@ import LayerUI from "./LayerUI";
import { Stats } from "./Stats";
import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import { nativeFileSystemSupported } from "../data/filesystem";
const IsMobileContext = React.createContext(false);
export const useIsMobile = () => useContext(IsMobileContext);
const ExcalidrawContainerContext = React.createContext<{
container: HTMLDivElement | null;
id: string | null;
}>({ container: null, id: null });
const ExcalidrawContainerContext = React.createContext<HTMLDivElement | null>(
null,
);
export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext);
@@ -225,6 +222,83 @@ const gesture: Gesture = {
initialScale: null,
};
export type PointerDownState = Readonly<{
// The first position at which pointerDown happened
origin: Readonly<{ x: number; y: number }>;
// Same as "origin" but snapped to the grid, if grid is on
originInGrid: Readonly<{ x: number; y: number }>;
// Scrollbar checks
scrollbars: ReturnType<typeof isOverScrollBars>;
// The previous pointer position
lastCoords: { x: number; y: number };
// map of original elements data
originalElements: Map<string, NonDeleted<ExcalidrawElement>>;
resize: {
// Handle when resizing, might change during the pointer interaction
handleType: MaybeTransformHandleType;
// This is determined on the initial pointer down event
isResizing: boolean;
// This is determined on the initial pointer down event
offset: { x: number; y: number };
// This is determined on the initial pointer down event
arrowDirection: "origin" | "end";
// This is a center point of selected elements determined on the initial pointer down event (for rotation only)
center: { x: number; y: number };
};
hit: {
// The element the pointer is "hitting", is determined on the initial
// pointer down event
element: NonDeleted<ExcalidrawElement> | null;
// The elements the pointer is "hitting", is determined on the initial
// pointer down event
allHitElements: NonDeleted<ExcalidrawElement>[];
// This is determined on the initial pointer down event
wasAddedToSelection: boolean;
// Whether selected element(s) were duplicated, might change during the
// pointer interaction
hasBeenDuplicated: boolean;
hasHitCommonBoundingBoxOfSelectedElements: boolean;
};
withCmdOrCtrl: boolean;
drag: {
// Might change during the pointer interation
hasOccurred: boolean;
// Might change during the pointer interation
offset: { x: number; y: number } | null;
};
// We need to have these in the state so that we can unsubscribe them
eventListeners: {
// It's defined on the initial pointer down event
onMove: null | ((event: PointerEvent) => void);
// It's defined on the initial pointer down event
onUp: null | ((event: PointerEvent) => void);
// It's defined on the initial pointer down event
onKeyDown: null | ((event: KeyboardEvent) => void);
// It's defined on the initial pointer down event
onKeyUp: null | ((event: KeyboardEvent) => void);
};
}>;
export type ExcalidrawImperativeAPI = {
updateScene: InstanceType<typeof App>["updateScene"];
resetScene: InstanceType<typeof App>["resetScene"];
getSceneElementsIncludingDeleted: InstanceType<
typeof App
>["getSceneElementsIncludingDeleted"];
history: {
clear: InstanceType<typeof App>["resetHistory"];
};
scrollToContent: InstanceType<typeof App>["scrollToContent"];
getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"];
refresh: InstanceType<typeof App>["refresh"];
importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
setToastMessage: InstanceType<typeof App>["setToastMessage"];
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true;
id: string;
};
class App extends React.Component<AppProps, AppState> {
canvas: HTMLCanvasElement | null = null;
rc: RoughCanvas | null = null;
@@ -247,10 +321,6 @@ class App extends React.Component<AppProps, AppState> {
public libraryItemsFromStorage: LibraryItems | undefined;
private id: string;
private history: History;
private excalidrawContainerValue: {
container: HTMLDivElement | null;
id: string;
};
constructor(props: AppProps) {
super(props);
@@ -307,12 +377,6 @@ class App extends React.Component<AppProps, AppState> {
}
readyPromise.resolve(api);
}
this.excalidrawContainerValue = {
container: this.excalidrawContainerRef.current,
id: this.id,
};
this.scene = new Scene();
this.library = new Library(this);
this.history = new History();
@@ -340,11 +404,11 @@ class App extends React.Component<AppProps, AppState> {
if (viewModeEnabled) {
return (
<canvas
className="excalidraw__canvas"
id="canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
cursor: CURSOR_TYPE.GRAB,
cursor: "grabbing",
}}
width={canvasWidth}
height={canvasHeight}
@@ -362,7 +426,7 @@ class App extends React.Component<AppProps, AppState> {
}
return (
<canvas
className="excalidraw__canvas"
id="canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
@@ -407,7 +471,7 @@ class App extends React.Component<AppProps, AppState> {
}
>
<ExcalidrawContainerContext.Provider
value={this.excalidrawContainerValue}
value={this.excalidrawContainerRef.current}
>
<IsMobileContext.Provider value={this.isMobile}>
<LayerUI
@@ -470,9 +534,7 @@ class App extends React.Component<AppProps, AppState> {
}
public focusContainer = () => {
if (this.props.autoFocus) {
this.excalidrawContainerRef.current?.focus();
}
};
public getSceneElementsIncludingDeleted = () => {
@@ -514,7 +576,7 @@ class App extends React.Component<AppProps, AppState> {
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
let gridSize = actionResult?.appState?.gridSize || null;
let theme = actionResult?.appState?.theme || THEME.LIGHT;
let theme = actionResult?.appState?.theme || "light";
let name = actionResult?.appState?.name ?? this.state.name;
if (typeof this.props.viewModeEnabled !== "undefined") {
viewModeEnabled = this.props.viewModeEnabled;
@@ -656,11 +718,7 @@ class App extends React.Component<AppProps, AppState> {
const fileHandle = launchParams.files[0];
const blob: Blob = await fileHandle.getFile();
blob.handle = fileHandle;
loadFromBlob(
blob,
this.state,
this.scene.getElementsIncludingDeleted(),
)
loadFromBlob(blob, this.state)
.then(({ elements, appState }) =>
this.syncActionResult({
elements,
@@ -687,7 +745,7 @@ class App extends React.Component<AppProps, AppState> {
if (initialData?.libraryItems) {
this.libraryItemsFromStorage = initialData.libraryItems;
}
} catch (error: any) {
} catch (error) {
console.error(error);
initialData = {
appState: {
@@ -698,7 +756,7 @@ class App extends React.Component<AppProps, AppState> {
};
}
const scene = restore(initialData, null, null);
const scene = restore(initialData, null);
scene.appState = {
...scene.appState,
isLoading: false,
@@ -742,8 +800,6 @@ class App extends React.Component<AppProps, AppState> {
};
public async componentDidMount() {
this.excalidrawContainerValue.container = this.excalidrawContainerRef.current;
if (
process.env.NODE_ENV === ENV.TEST ||
process.env.NODE_ENV === ENV.DEVELOPMENT
@@ -832,7 +888,6 @@ class App extends React.Component<AppProps, AppState> {
});
private removeEventListeners() {
document.removeEventListener(EVENT.POINTER_UP, this.removePointer);
document.removeEventListener(EVENT.COPY, this.onCopy);
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
document.removeEventListener(EVENT.CUT, this.onCut);
@@ -874,7 +929,6 @@ class App extends React.Component<AppProps, AppState> {
private addEventListeners() {
this.removeEventListeners();
document.addEventListener(EVENT.POINTER_UP, this.removePointer); // #3553
document.addEventListener(EVENT.COPY, this.onCopy);
if (this.props.handleKeyboardGlobally) {
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
@@ -930,12 +984,14 @@ class App extends React.Component<AppProps, AppState> {
}
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
this.setState(
{ viewModeEnabled: !!this.props.viewModeEnabled },
this.addEventListeners,
);
}
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
this.addEventListeners();
this.deselectElements();
}
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
@@ -1194,13 +1250,9 @@ class App extends React.Component<AppProps, AppState> {
}
const data = await parseClipboard(event);
if (this.props.onPaste) {
try {
if ((await this.props.onPaste(data, event)) === false) {
if (await this.props.onPaste(data, event)) {
return;
}
} catch (e) {
console.error(e);
}
}
if (data.errorMessage) {
this.setState({ errorMessage: data.errorMessage });
@@ -1213,7 +1265,7 @@ class App extends React.Component<AppProps, AppState> {
});
} else if (data.elements) {
this.addElementsFromPasteOrLibrary({
elements: data.elements,
elements: restoreElements(data.elements),
position: "cursor",
});
} else if (data.text) {
@@ -1228,7 +1280,7 @@ class App extends React.Component<AppProps, AppState> {
elements: readonly ExcalidrawElement[];
position: { clientX: number; clientY: number } | "cursor" | "center";
}) => {
const elements = restoreElements(opts.elements, null);
const elements = restoreElements(opts.elements);
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2;
@@ -1294,7 +1346,6 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElements(),
),
);
this.selectShapeTool("selection");
};
private addTextFromPaste(text: any) {
@@ -1335,7 +1386,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState(obj);
};
removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
removePointer = (event: React.PointerEvent<HTMLElement>) => {
// remove touch handler for context menu on touch devices
if (event.pointerType === "touch" && touchTimeout) {
clearTimeout(touchTimeout);
@@ -1401,7 +1452,7 @@ class App extends React.Component<AppProps, AppState> {
await webShareTargetCache.delete("shared-file");
window.history.replaceState(null, APP_NAME, window.location.pathname);
}
} catch (error: any) {
} catch (error) {
this.setState({ errorMessage: error.message });
}
};
@@ -1598,22 +1649,13 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElements(),
this.state,
);
if (
this.state.elementType === "selection" &&
!selectedElements.length
) {
return;
}
if (
event.key === KEYS.G &&
(hasBackground(this.state.elementType) ||
selectedElements.some((element) => hasBackground(element.type)))
) {
this.setState({ openPopup: "backgroundColorPicker" });
if (selectedElements.length) {
if (event.key === KEYS.G) {
this.setState({ openMenu: "backgroundColorPicker" });
}
if (event.key === KEYS.S) {
this.setState({ openPopup: "strokeColorPicker" });
this.setState({ openMenu: "strokeColorPicker" });
}
}
}
},
@@ -1621,9 +1663,7 @@ class App extends React.Component<AppProps, AppState> {
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) {
if (this.state.viewModeEnabled) {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
} else if (this.state.elementType === "selection") {
if (this.state.elementType === "selection") {
resetCursor(this.canvas);
} else {
setCursorForShape(this.canvas, this.state.elementType);
@@ -1771,8 +1811,7 @@ class App extends React.Component<AppProps, AppState> {
[element.id]: true,
},
}));
}
if (isDeleted) {
} else {
fixBindingsAfterDeletion(this.scene.getElements(), [element]);
}
if (!isDeleted || isExistingElement) {
@@ -1793,19 +1832,15 @@ class App extends React.Component<AppProps, AppState> {
excalidrawContainer: this.excalidrawContainerRef.current,
});
// deselect all other elements when inserting text
this.deselectElements();
// do an initial update to re-initialize element position since we were
// modifying element's x/y for sake of editor (case: syncing to remote)
updateElement(element.text);
}
private deselectElements() {
this.setState({
selectedElementIds: {},
selectedGroupIds: {},
editingGroupId: null,
});
// do an initial update to re-initialize element position since we were
// modifying element's x/y for sake of editor (case: syncing to remote)
updateElement(element.text);
}
private getTextElementAtPosition(
@@ -1827,6 +1862,7 @@ class App extends React.Component<AppProps, AppState> {
/** if true, returns the first selected element (with highest z-index)
of all hit elements */
preferSelected?: boolean;
cycleElementsUnderCursor?: boolean;
},
): NonDeleted<ExcalidrawElement> | null {
const allHitElements = this.getElementsAtPosition(x, y);
@@ -1837,6 +1873,13 @@ class App extends React.Component<AppProps, AppState> {
return allHitElements[index];
}
}
} else if (opts?.cycleElementsUnderCursor) {
const selectedIdx = allHitElements.findIndex(
(element) => this.state.selectedElementIds[element.id],
);
return selectedIdx > 0
? allHitElements[selectedIdx - 1]
: allHitElements[allHitElements.length - 1];
}
const elementWithHighestZIndex =
allHitElements[allHitElements.length - 1];
@@ -2248,8 +2291,6 @@ class App extends React.Component<AppProps, AppState> {
this.canvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
);
} else if (this.state.viewModeEnabled) {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
} else if (isOverScrollBar) {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
} else if (
@@ -2275,6 +2316,8 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLCanvasElement>,
) => {
event.persist();
// remove any active selection when we start to interact with canvas
// (mainly, we care about removing selection outside the component which
// would prevent our copy handling otherwise)
@@ -2487,12 +2530,8 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUp = null;
isPanning = false;
if (!isHoldingSpace) {
if (this.state.viewModeEnabled) {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
} else {
setCursorForShape(this.canvas, this.state.elementType);
}
}
this.setState({
cursorButton: "up",
});
@@ -2715,10 +2754,13 @@ class App extends React.Component<AppProps, AppState> {
// hitElement may already be set above, so check first
pointerDownState.hit.element =
pointerDownState.hit.element ??
(!event[KEYS.CTRL_OR_CMD] ? pointerDownState.hit.element : null) ??
this.getElementAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
{
cycleElementsUnderCursor: event[KEYS.CTRL_OR_CMD],
},
);
// For overlapped elements one position may hit
@@ -3495,7 +3537,6 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(draggingElement, {
points: [...points, [dx, dy]],
pressures,
lastCommittedPoint: [dx, dy],
});
this.actionManager.executeAction(actionFinalize);
@@ -3835,22 +3876,7 @@ class App extends React.Component<AppProps, AppState> {
try {
const file = event.dataTransfer.files[0];
if (file?.type === "image/png" || file?.type === "image/svg+xml") {
if (nativeFileSystemSupported) {
try {
// This will only work as of Chrome 86,
// but can be safely ignored on older releases.
const item = event.dataTransfer.items[0];
(file as any).handle = await (item as any).getAsFileSystemHandle();
} catch (error: any) {
console.warn(error.name, error.message);
}
}
const { elements, appState } = await loadFromBlob(
file,
this.state,
this.scene.getElementsIncludingDeleted(),
);
const { elements, appState } = await loadFromBlob(file, this.state);
this.syncActionResult({
elements,
appState: {
@@ -3861,7 +3887,7 @@ class App extends React.Component<AppProps, AppState> {
});
return;
}
} catch (error: any) {
} catch (error) {
return this.setState({
isLoading: false,
errorMessage: error.message,
@@ -3895,13 +3921,13 @@ class App extends React.Component<AppProps, AppState> {
// default: assume an Excalidraw file regardless of extension/MimeType
} else {
this.setState({ isLoading: true });
if (nativeFileSystemSupported) {
if (fsSupported) {
try {
// This will only work as of Chrome 86,
// but can be safely ignored on older releases.
const item = event.dataTransfer.items[0];
(file as any).handle = await (item as any).getAsFileSystemHandle();
} catch (error: any) {
} catch (error) {
console.warn(error.name, error.message);
}
}
@@ -3910,7 +3936,7 @@ class App extends React.Component<AppProps, AppState> {
};
loadFileToCanvas = (file: Blob) => {
loadFromBlob(file, this.state, this.scene.getElementsIncludingDeleted())
loadFromBlob(file, this.state)
.then(({ elements, appState }) =>
this.syncActionResult({
elements,
@@ -4094,7 +4120,6 @@ class App extends React.Component<AppProps, AppState> {
actionToggleStats,
];
if (this.state.viewModeEnabled) {
ContextMenu.push({
options: viewModeOptions,
top,
@@ -4103,7 +4128,11 @@ class App extends React.Component<AppProps, AppState> {
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
} else {
if (this.state.viewModeEnabled) {
return;
}
ContextMenu.push({
options: [
this.isMobile &&
@@ -4143,8 +4172,9 @@ class App extends React.Component<AppProps, AppState> {
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
return;
}
} else if (type === "element") {
if (this.state.viewModeEnabled) {
ContextMenu.push({
options: [navigator.clipboard && actionCopy, ...options],
@@ -4154,7 +4184,9 @@ class App extends React.Component<AppProps, AppState> {
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
} else {
return;
}
ContextMenu.push({
options: [
this.isMobile && actionCut,
@@ -4198,8 +4230,6 @@ class App extends React.Component<AppProps, AppState> {
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
}
}
};
private handleWheel = withBatchedUpdates((event: WheelEvent) => {
@@ -16,5 +16,10 @@ export const BackgroundPickerAndDarkModeToggle = ({
<div style={{ display: "flex" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
{showThemeBtn && actionManager.renderAction("toggleTheme")}
{appState.fileHandle && (
<div style={{ marginInlineStart: "0.25rem" }}>
{actionManager.renderAction("saveToActiveFile")}
</div>
)}
</div>
);
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx";
export const ButtonSelect = <T extends Object>({
+1 -1
View File
@@ -81,7 +81,7 @@
align-items: center;
}
.excalidraw-tooltip-icon {
.Tooltip-icon {
width: 1em;
height: 1em;
}
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import clsx from "clsx";
import { checkIcon } from "./icons";
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n";
+6 -12
View File
@@ -1,6 +1,5 @@
import React from "react";
import { Popover } from "./Popover";
import { isTransparent } from "../utils";
import "./ColorPicker.scss";
import { isArrowKey, KEYS } from "../keys";
@@ -15,7 +14,7 @@ const isValidColor = (color: string) => {
};
const getColor = (color: string): string | null => {
if (isTransparent(color)) {
if (color === "transparent") {
return color;
}
@@ -138,19 +137,15 @@ const Picker = ({
}}
tabIndex={0}
>
{colors.map((_color, i) => {
const _colorWithoutHash = _color.replace("#", "");
return (
{colors.map((_color, i) => (
<button
className="color-picker-swatch"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(_color);
}}
title={`${t(`colors.${_colorWithoutHash}`)}${
!isTransparent(_color) ? ` (${_color})` : ""
}${keyBindings[i].toUpperCase()}`}
aria-label={t(`colors.${_colorWithoutHash}`)}
title={`${_color}${keyBindings[i].toUpperCase()}`}
aria-label={_color}
aria-keyshortcuts={keyBindings[i]}
style={{ color: _color }}
key={_color}
@@ -166,13 +161,12 @@ const Picker = ({
onChange(_color);
}}
>
{isTransparent(_color) ? (
{_color === "transparent" ? (
<div className="color-picker-transparent"></div>
) : undefined}
<span className="color-picker-keybinding">{keyBindings[i]}</span>
</button>
);
})}
))}
{showInput && (
<ColorInput
color={color}
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx";
import { Popover } from "./Popover";
+7 -8
View File
@@ -1,15 +1,16 @@
import "./ToolIcon.scss";
import React from "react";
import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { THEME } from "../constants";
import { Theme } from "../element/types";
export type Appearence = "light" | "dark";
// We chose to use only explicit toggle and not a third option for system value,
// but this could be added in the future.
export const DarkModeToggle = (props: {
value: Theme;
onChange: (value: Theme) => void;
value: Appearence;
onChange: (value: Appearence) => void;
title?: string;
}) => {
const title =
@@ -19,12 +20,10 @@ export const DarkModeToggle = (props: {
return (
<ToolButton
type="icon"
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
title={title}
aria-label={title}
onClick={() =>
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
}
onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
data-testid="toggle-dark-mode"
/>
);
+2 -3
View File
@@ -2,7 +2,7 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { useExcalidrawContainer, useIsMobile } from "../components/App";
import { useIsMobile } from "../components/App";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, close } from "./icons";
@@ -21,7 +21,6 @@ export const Dialog = (props: {
}) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
useEffect(() => {
if (!islandNode) {
@@ -83,7 +82,7 @@ export const Dialog = (props: {
theme={props.theme}
>
<Island ref={setIslandNode}>
<h2 id={`${id}-dialog-title`} className="Dialog__title">
<h2 id="dialog-title" className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
<button
className="Modal__close"
+1 -1
View File
@@ -12,7 +12,7 @@ export const ErrorDialog = ({
onClose?: () => void;
}) => {
const [modalIsShown, setModalIsShown] = useState(!!message);
const { container: excalidrawContainer } = useExcalidrawContainer();
const excalidrawContainer = useExcalidrawContainer();
const handleClose = React.useCallback(() => {
setModalIsShown(false);
+1 -2
View File
@@ -97,8 +97,7 @@
border-radius: 1rem;
background-color: var(--button-color);
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
0 6px 10px 0 rgba(0, 0, 0, 0.14);
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;
-7
View File
@@ -157,13 +157,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
getShortcutKey("Enter"),
t("helpDialog.doubleClick"),
]}
/>
<Shortcut
label={t("helpDialog.textNewLine")}
shortcuts={[
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { questionCircle } from "../components/icons";
type HelpIconProps = {
+2 -9
View File
@@ -1,10 +1,11 @@
import React from "react";
import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import "./HintViewer.scss";
import { AppState } from "../types";
import { isLinearElement, isTextElement } from "../element/typeChecks";
import { isLinearElement } from "../element/typeChecks";
import { getShortcutKey } from "../utils";
interface Hint {
@@ -56,14 +57,6 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.lineEditor_info");
}
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected");
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
return t("hints.text_editing");
}
return null;
};
+42 -12
View File
@@ -8,17 +8,20 @@ import { CanvasError } from "../errors";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types";
import { Dialog } from "./Dialog";
import { clipboard, exportImage } from "./icons";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import "./ExportDialog.scss";
import { supported as fsSupported } from "browser-fs-access";
import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem";
const scales = [1, 2, 3];
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -79,7 +82,7 @@ const ExportButton: React.FC<{
const ImageExportModal = ({
elements,
appState,
exportPadding = DEFAULT_EXPORT_PADDING,
exportPadding = 10,
actionManager,
onExportToPng,
onExportToSvg,
@@ -95,6 +98,7 @@ const ImageExportModal = ({
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;
@@ -117,6 +121,7 @@ const ImageExportModal = ({
exportBackground,
viewBackgroundColor,
exportPadding,
scale,
});
// if converting to blob fails, there's some problem that will
@@ -139,6 +144,7 @@ const ImageExportModal = ({
exportBackground,
exportPadding,
viewBackgroundColor,
scale,
]);
return (
@@ -169,8 +175,33 @@ const ImageExportModal = ({
</div>
</div>
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
<Stack.Row gap={2}>
{actionManager.renderAction("changeExportScale")}
<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 (
<ToolButton
key={_scale}
size="s"
type="radio"
icon={`${_scale}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={_scale === scale}
onChange={() => setScale(_scale)}
/>
);
})}
</Stack.Row>
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
</div>
@@ -182,15 +213,14 @@ const ImageExportModal = ({
margin: ".6em 0",
}}
>
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
{!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)}
onClick={() => onExportToPng(exportedElements, scale)}
>
PNG
</ExportButton>
@@ -198,14 +228,14 @@ const ImageExportModal = ({
color="red"
title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")}
onClick={() => onExportToSvg(exportedElements)}
onClick={() => onExportToSvg(exportedElements, scale)}
>
SVG
</ExportButton>
{probablySupportsClipboardBlob && (
<ExportButton
title={t("buttons.copyPngToClipboard")}
onClick={() => onExportToClipboard(exportedElements)}
onClick={() => onExportToClipboard(exportedElements, scale)}
color="gray"
shade={7}
>
@@ -220,7 +250,7 @@ const ImageExportModal = ({
export const ImageExportDialog = ({
elements,
appState,
exportPadding = DEFAULT_EXPORT_PADDING,
exportPadding = 10,
actionManager,
onExportToPng,
onExportToSvg,
+21 -16
View File
@@ -1,25 +1,30 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { LoadingMessage } from "./LoadingMessage";
import { defaultLang, Language, languages, setLanguage } from "../i18n";
interface Props {
langCode: Language["code"];
children: React.ReactElement;
}
export const InitializeApp = (props: Props) => {
const [loading, setLoading] = useState(true);
useEffect(() => {
const updateLang = async () => {
await setLanguage(currentLang);
interface State {
isLoading: boolean;
}
export class InitializeApp extends React.Component<Props, State> {
public state: { isLoading: boolean } = {
isLoading: true,
};
const currentLang =
languages.find((lang) => lang.code === props.langCode) || defaultLang;
updateLang();
setLoading(false);
}, [props.langCode]);
return loading ? <LoadingMessage /> : props.children;
};
async componentDidMount() {
const currentLang =
languages.find((lang) => lang.code === this.props.langCode) ||
defaultLang;
await setLanguage(currentLang);
this.setState({
isLoading: false,
});
}
public render() {
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
}
}
+2 -3
View File
@@ -11,7 +11,7 @@ import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card";
import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem";
import { supported as fsSupported } from "browser-fs-access";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -42,8 +42,7 @@ const JSONExportModal = ({
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
{!fsSupported && actionManager.renderAction("changeProjectName")}
</div>
<ToolButton
className="Card-button"
+3 -14
View File
@@ -73,10 +73,10 @@
}
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(-76px, 0);
transform: translate(-92px, 0);
}
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(76px, 0);
transform: translate(92px, 0);
}
&.layer-ui__wrapper__footer-left--transition-bottom {
@@ -116,19 +116,8 @@
}
}
.layer-ui__wrapper__footer-left,
.layer-ui__wrapper__footer-right,
.disable-zen-mode--visible {
pointer-events: all;
}
.layer-ui__wrapper__footer-left {
margin-bottom: 0.2em;
}
.layer-ui__wrapper__footer-right {
margin-top: auto;
margin-bottom: auto;
margin-inline-end: 1em;
pointer-events: all;
}
}
}
+13 -46
View File
@@ -36,7 +36,7 @@ import { Island } from "./Island";
import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton";
import { LockIcon } from "./LockIcon";
import { MobileMenu } from "./MobileMenu";
import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section";
@@ -47,8 +47,6 @@ import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList";
import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob";
interface LayerUIProps {
actionManager: ActionManager;
@@ -109,7 +107,6 @@ const LibraryMenuItems = ({
onAddToLibrary,
onInsertShape,
pendingElements,
theme,
setAppState,
setLibraryItems,
libraryReturnUrl,
@@ -122,7 +119,6 @@ const LibraryMenuItems = ({
onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
theme: AppState["theme"];
setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -196,7 +192,7 @@ const LibraryMenuItems = ({
<a
href={`https://libraries.excalidraw.com?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
}&referrer=${referrer}&useHash=true&token=${id}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
@@ -250,7 +246,6 @@ const LibraryMenu = ({
onInsertShape,
pendingElements,
onAddToLibrary,
theme,
setAppState,
libraryReturnUrl,
focusContainer,
@@ -261,7 +256,6 @@ const LibraryMenu = ({
onClickOutside: (event: MouseEvent) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
@@ -351,7 +345,6 @@ const LibraryMenu = ({
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
theme={theme}
id={id}
/>
)}
@@ -407,25 +400,19 @@ const LayerUI = ({
const createExporter = (type: ExportType): ExportCB => async (
exportedElements,
scale,
) => {
const fileHandle = await exportCanvas(type, exportedElements, appState, {
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 (
appState.exportEmbedScene &&
fileHandle &&
isImageFileHandle(fileHandle)
) {
setAppState({ fileHandle });
}
};
return (
@@ -497,9 +484,6 @@ const LayerUI = ({
setAppState={setAppState}
showThemeBtn={showThemeBtn}
/>
{appState.fileHandle && (
<>{actionManager.renderAction("saveToActiveFile")}</>
)}
</Stack.Col>
</Island>
</Section>
@@ -518,8 +502,7 @@ const LayerUI = ({
style={{
// we want to make sure this doesn't overflow so substracting 200
// which is approximately height of zoom footer and top left menu items with some buffer
// if active file name is displayed, subtracting 248 to account for its height
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
maxHeight: `${appState.height - 200}px`,
}}
>
<SelectedShapeActions
@@ -556,7 +539,6 @@ const LayerUI = ({
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
theme={appState.theme}
id={id}
/>
) : null;
@@ -584,12 +566,6 @@ const LayerUI = ({
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<LockButton
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<Island
padding={1}
className={clsx({ "zen-mode": zenModeEnabled })}
@@ -601,12 +577,15 @@ const LayerUI = ({
canvas={canvas}
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LibraryButton
appState={appState}
setAppState={setAppState}
<LockIcon
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
@@ -632,9 +611,7 @@ const LayerUI = ({
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
{actionManager.renderAction("goToCollaborator", clientId)}
</Tooltip>
))}
</UserList>
@@ -667,16 +644,6 @@ const LayerUI = ({
zoom={appState.zoom}
/>
</Island>
{!viewModeEnabled && (
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("redo", { size: "small" })}
</div>
)}
</Section>
</Stack.Col>
</div>
-46
View File
@@ -1,46 +0,0 @@
import React from "react";
import clsx from "clsx";
import { t } from "../i18n";
import { AppState } from "../types";
import { capitalizeString } from "../utils";
const LIBRARY_ICON = (
<svg viewBox="0 0 576 512">
<path
fill="currentColor"
d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
></path>
</svg>
);
export const LibraryButton: React.FC<{
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
}> = ({ appState, setAppState }) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
`ToolIcon_size_medium`,
{
"zen-mode-visibility--hidden": appState.zenModeEnabled,
},
)}
title={`${capitalizeString(t("toolBar.library"))} — 9`}
style={{ marginInlineStart: "var(--space-factor)" }}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name="editor-library"
onChange={(event) => {
setAppState({ isLibraryOpen: event.target.checked });
}}
checked={appState.isLibraryOpen}
aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="9"
/>
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
</label>
);
};
+5 -11
View File
@@ -1,6 +1,6 @@
import clsx from "clsx";
import oc from "open-color";
import { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { close } from "../components/icons";
import { MIME_TYPES } from "../constants";
import { t } from "../i18n";
@@ -36,11 +36,7 @@ export const LibraryUnit = ({
if (!elementsToRender) {
return;
}
let svg: SVGSVGElement;
const current = ref.current!;
(async () => {
svg = await exportToSvg(elementsToRender, {
const svg = exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: oc.white,
});
@@ -48,15 +44,13 @@ export const LibraryUnit = ({
if (child.tagName !== "svg") {
continue;
}
current!.removeChild(child);
ref.current!.removeChild(child);
}
current!.appendChild(svg);
})();
ref.current!.appendChild(svg);
const current = ref.current!;
return () => {
if (svg) {
current.removeChild(svg);
}
};
}, [elements, pendingElements]);
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import { t } from "../i18n";
export const LoadingMessage = () => {
@@ -2,17 +2,20 @@ import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
type LockIconSize = "s" | "m";
type LockIconProps = {
title?: string;
name?: string;
id?: string;
checked: boolean;
onChange?(): void;
size?: LockIconSize;
zenModeEnabled?: boolean;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
const DEFAULT_SIZE: LockIconSize = "m";
const ICONS = {
CHECKED: (
@@ -38,12 +41,12 @@ const ICONS = {
),
};
export const LockButton = (props: LockIconProps) => {
export const LockIcon = (props: LockIconProps) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
`ToolIcon_size_${DEFAULT_SIZE}`,
`ToolIcon_size_${props.size || DEFAULT_SIZE}`,
{
"zen-mode-visibility--hidden": props.zenModeEnabled,
},
@@ -54,6 +57,7 @@ export const LockButton = (props: LockIconProps) => {
className="ToolIcon_type_checkbox"
type="checkbox"
name={props.name}
id={props.id}
onChange={props.onChange}
checked={props.checked}
aria-label={props.title}
+7 -7
View File
@@ -13,10 +13,9 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import CollabButton from "./CollabButton";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton";
import { LockIcon } from "./LockIcon";
import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { LibraryButton } from "./LibraryButton";
type MobileMenuProps = {
appState: AppState;
@@ -65,15 +64,15 @@ export const MobileMenu = ({
canvas={canvas}
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockButton
<LockIcon
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<LibraryButton appState={appState} setAppState={setAppState} />
</Stack.Row>
{libraryMenu}
</Stack.Col>
@@ -168,9 +167,10 @@ export const MobileMenu = ({
)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
{actionManager.renderAction(
"goToCollaborator",
clientId,
)}
</React.Fragment>
))}
</UserList>
+2 -3
View File
@@ -6,7 +6,6 @@ import clsx from "clsx";
import { KEYS } from "../keys";
import { useExcalidrawContainer, useIsMobile } from "./App";
import { AppState } from "../types";
import { THEME } from "../constants";
export const Modal = (props: {
className?: string;
@@ -16,7 +15,7 @@ export const Modal = (props: {
labelledBy: string;
theme?: AppState["theme"];
}) => {
const { theme = THEME.LIGHT } = props;
const { theme = "light" } = props;
const modalRoot = useBodyRoot(theme);
if (!modalRoot) {
@@ -59,7 +58,7 @@ const useBodyRoot = (theme: AppState["theme"]) => {
const isMobileRef = useRef(isMobile);
isMobileRef.current = isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer();
const excalidrawContainer = useExcalidrawContainer();
useLayoutEffect(() => {
if (div) {
+3 -5
View File
@@ -34,21 +34,19 @@ const ChartPreviewBtn = (props: {
0,
);
setChartElements(elements);
let svg: SVGSVGElement;
const previewNode = previewRef.current!;
(async () => {
svg = await exportToSvg(elements, {
const svg = exportToSvg(elements, {
exportBackground: false,
viewBackgroundColor: oc.white,
});
const previewNode = previewRef.current!;
previewNode.appendChild(svg);
if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
}
})();
return () => {
previewNode.removeChild(svg);
+26 -20
View File
@@ -1,10 +1,9 @@
import "./TextInput.scss";
import React, { useState } from "react";
import React, { Component } from "react";
import { focusNearestParent } from "../utils";
import "./ProjectName.scss";
import { useExcalidrawContainer } from "./App";
type Props = {
value: string;
@@ -13,19 +12,22 @@ type Props = {
isNameEditable: boolean;
};
export const ProjectName = (props: Props) => {
const { id } = useExcalidrawContainer();
const [fileName, setFileName] = useState<string>(props.value);
const handleBlur = (event: any) => {
type State = {
fileName: string;
};
export class ProjectName extends Component<Props, State> {
state = {
fileName: this.props.value,
};
private handleBlur = (event: any) => {
focusNearestParent(event.target);
const value = event.target.value;
if (value !== props.value) {
props.onChange(value);
if (value !== this.props.value) {
this.props.onChange(value);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
event.preventDefault();
if (event.nativeEvent.isComposing || event.keyCode === 229) {
@@ -35,25 +37,29 @@ export const ProjectName = (props: Props) => {
}
};
public render() {
return (
<div className="ProjectName">
<label className="ProjectName-label" htmlFor="filename">
{`${props.label}${props.isNameEditable ? "" : ":"}`}
{`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
</label>
{props.isNameEditable ? (
{this.props.isNameEditable ? (
<input
className="TextInput"
onBlur={handleBlur}
onKeyDown={handleKeyDown}
id={`${id}-filename`}
value={fileName}
onChange={(event) => setFileName(event.target.value)}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
id="filename"
value={this.state.fileName}
onChange={(event) =>
this.setState({ fileName: event.target.value })
}
/>
) : (
<span className="TextInput TextInput--readonly" id={`${id}-filename`}>
{props.value}
<span className="TextInput TextInput--readonly" id="filename">
{this.props.value}
</span>
)}
</div>
);
};
}
}
+2 -4
View File
@@ -1,6 +1,5 @@
import React from "react";
import { t } from "../i18n";
import { useExcalidrawContainer } from "./App";
interface SectionProps extends React.HTMLProps<HTMLElement> {
heading: string;
@@ -8,14 +7,13 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
}
export const Section = ({ heading, children, ...props }: SectionProps) => {
const { id } = useExcalidrawContainer();
const header = (
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
<h2 className="visually-hidden" id={`${heading}-title`}>
{t(`headings.${heading}`)}
</h2>
);
return (
<section {...props} aria-labelledby={`${id}-${heading}-title`}>
<section {...props} aria-labelledby={`${heading}-title`}>
{typeof children === "function" ? (
children(header)
) : (
+1 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { TOAST_TIMEOUT } from "../constants";
import "./Toast.scss";
+6 -7
View File
@@ -2,9 +2,8 @@ import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
export type ToolButtonSize = "small" | "medium";
type ToolIconSize = "s" | "m";
type ToolButtonBaseProps = {
icon?: React.ReactNode;
@@ -15,7 +14,7 @@ type ToolButtonBaseProps = {
title?: string;
name?: string;
id?: string;
size?: ToolButtonSize;
size?: ToolIconSize;
keyBindingLabel?: string;
showAriaLabel?: boolean;
hidden?: boolean;
@@ -41,11 +40,12 @@ type ToolButtonProps =
onChange?(): void;
});
const DEFAULT_SIZE: ToolIconSize = "m";
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${props.size}`;
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
if (props.type === "button" || props.type === "icon") {
return (
@@ -98,7 +98,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
id={props.id}
onChange={props.onChange}
checked={props.checked}
ref={innerRef}
@@ -116,5 +116,4 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
ToolButton.defaultProps = {
visible: true,
className: "",
size: "medium",
};
+14 -41
View File
@@ -8,18 +8,10 @@
position: relative;
font-family: Cascadia;
cursor: pointer;
background-color: var(--button-gray-1);
-webkit-tap-highlight-color: transparent;
border-radius: var(--space-factor);
user-select: none;
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
.ToolIcon--plain {
@@ -60,9 +52,9 @@
text-overflow: ellipsis;
}
.ToolIcon_size_small .ToolIcon__icon {
width: 2rem;
height: 2rem;
.ToolIcon_size_s .ToolIcon__icon {
width: 1.4rem;
height: 1.4rem;
font-size: 0.8em;
}
@@ -74,6 +66,14 @@
margin: 0;
font-size: inherit;
&:hover {
background-color: var(--button-gray-1);
}
&:active {
background-color: var(--button-gray-2);
}
&:focus {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -86,14 +86,6 @@
}
}
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
&--show {
visibility: visible;
}
@@ -111,9 +103,6 @@
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
background-color: var(--button-gray-2);
&:active {
background-color: var(--button-gray-3);
}
}
&:focus + .ToolIcon__icon {
@@ -141,21 +130,12 @@
}
.ToolIcon__icon {
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
width: 2rem;
height: 2em;
}
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}
@@ -186,9 +166,10 @@
// move the lock button out of the way on small viewports
// it begins to collide with the GitHub icon before we switch to mobile mode
@media (max-width: 760px) {
.ToolIcon.ToolIcon_type_floating {
.ToolIcon.ToolIcon__lock {
display: inline-block;
position: absolute;
top: 60px;
right: -8px;
margin-left: 0;
@@ -213,14 +194,6 @@
position: static;
}
}
.ToolIcon.ToolIcon__library {
top: 100px;
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: 0;
top: 60px;
}
}
.unlocked-icon {
+3 -9
View File
@@ -1,6 +1,4 @@
@import "../css/variables.module";
// container in body where the actual tooltip is appended to
.excalidraw-tooltip {
position: absolute;
z-index: 1000;
@@ -26,13 +24,8 @@
}
}
// wraps the element we want to apply the tooltip to
.excalidraw-tooltip-wrapper {
display: flex;
height: 100%;
}
.excalidraw-tooltip-icon {
.excalidraw {
.Tooltip-icon {
width: 0.9em;
height: 0.9em;
margin-left: 5px;
@@ -42,4 +35,5 @@
@include isMobile {
display: none;
}
}
}
-1
View File
@@ -74,7 +74,6 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
return (
<div
className="excalidraw-tooltip-wrapper"
onPointerEnter={(event) =>
updateTooltip(
event.currentTarget as HTMLDivElement,
+75 -57
View File
@@ -10,15 +10,13 @@ import React from "react";
import oc from "open-color";
import clsx from "clsx";
import { Theme } from "../element/types";
import { THEME } from "../constants";
const activeElementColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
const iconFillColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.black : oc.gray[4];
const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
const activeElementColor = (theme: "light" | "dark") =>
theme === "light" ? oc.orange[4] : oc.orange[9];
const iconFillColor = (theme: "light" | "dark") =>
theme === "light" ? oc.black : oc.gray[4];
const handlerColor = (theme: "light" | "dark") =>
theme === "light" ? oc.white : "#1e1e1e";
type Opts = {
width?: number;
@@ -26,10 +24,7 @@ type Opts = {
mirror?: true;
} & React.SVGProps<SVGSVGElement>;
export const createIcon = (
d: string | React.ReactNode,
opts: number | Opts = 512,
) => {
const createIcon = (d: string | React.ReactNode, opts: number | Opts = 512) => {
const { width = 512, height = width, mirror, style } =
typeof opts === "number" ? ({ width: opts } as Opts) : opts;
return (
@@ -177,7 +172,8 @@ export const resetZoom = createIcon(
{ width: 1024 },
);
export const BringForwardIcon = React.memo(({ theme }: { theme: Theme }) =>
export const BringForwardIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -197,7 +193,8 @@ export const BringForwardIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const SendBackwardIcon = React.memo(({ theme }: { theme: Theme }) =>
export const SendBackwardIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -217,7 +214,8 @@ export const SendBackwardIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const BringToFrontIcon = React.memo(({ theme }: { theme: Theme }) =>
export const BringToFrontIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -237,7 +235,8 @@ export const BringToFrontIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const SendToBackIcon = React.memo(({ theme }: { theme: Theme }) =>
export const SendToBackIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -263,7 +262,8 @@ export const SendToBackIcon = React.memo(({ theme }: { theme: Theme }) =>
// first one the user sees. Horizontal align icons should not be flipped since
// that would make them lie about their function.
//
export const AlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
export const AlignTopIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -284,7 +284,8 @@ export const AlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const AlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
export const AlignBottomIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -305,7 +306,8 @@ export const AlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const AlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
export const AlignLeftIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -326,7 +328,8 @@ export const AlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const AlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
export const AlignRightIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -348,7 +351,7 @@ export const AlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
);
export const DistributeHorizontallyIcon = React.memo(
({ theme }: { theme: Theme }) =>
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -369,7 +372,7 @@ export const DistributeHorizontallyIcon = React.memo(
);
export const DistributeVerticallyIcon = React.memo(
({ theme }: { theme: Theme }) =>
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -390,7 +393,8 @@ export const DistributeVerticallyIcon = React.memo(
),
);
export const CenterVerticallyIcon = React.memo(({ theme }: { theme: Theme }) =>
export const CenterVerticallyIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -413,7 +417,7 @@ export const CenterVerticallyIcon = React.memo(({ theme }: { theme: Theme }) =>
);
export const CenterHorizontallyIcon = React.memo(
({ theme }: { theme: Theme }) =>
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -470,12 +474,7 @@ export const shield = createIcon(
{ width: 24 },
);
export const file = createIcon(
"M369.9 97.9L286 14C277 5 264.8-.1 252.1-.1H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V131.9c0-12.7-5.1-25-14.1-34zM332.1 128H256V51.9l76.1 76.1zM48 464V48h160v104c0 13.3 10.7 24 24 24h104v288H48zm32-48h224V288l-23.5-23.5c-4.7-4.7-12.3-4.7-17 0L176 352l-39.5-39.5c-4.7-4.7-12.3-4.7-17 0L80 352v64zm48-240c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z",
{ width: 384, height: 512 },
);
export const GroupIcon = React.memo(({ theme }: { theme: Theme }) =>
export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
@@ -505,7 +504,8 @@ export const GroupIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
export const UngroupIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
@@ -537,7 +537,8 @@ export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const FillHachureIcon = React.memo(({ theme }: { theme: Theme }) =>
export const FillHachureIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fillRule="evenodd"
@@ -549,7 +550,8 @@ export const FillHachureIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const FillCrossHatchIcon = React.memo(({ theme }: { theme: Theme }) =>
export const FillCrossHatchIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<g fill={iconFillColor(theme)} fillRule="evenodd" clipRule="evenodd">
<path d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z" />
@@ -559,7 +561,8 @@ export const FillCrossHatchIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const FillSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
export const FillSolidIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(theme)} />, {
width: 40,
height: 20,
@@ -567,7 +570,7 @@ export const FillSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
);
export const StrokeWidthIcon = React.memo(
({ theme, strokeWidth }: { theme: Theme; strokeWidth: number }) =>
({ theme, strokeWidth }: { theme: "light" | "dark"; strokeWidth: number }) =>
createIcon(
<path
d="M6 10H32"
@@ -580,7 +583,8 @@ export const StrokeWidthIcon = React.memo(
),
);
export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
export const StrokeStyleSolidIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H34"
@@ -596,7 +600,8 @@ export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const StrokeStyleDashedIcon = React.memo(({ theme }: { theme: Theme }) =>
export const StrokeStyleDashedIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H34"
@@ -610,7 +615,8 @@ export const StrokeStyleDashedIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const StrokeStyleDottedIcon = React.memo(({ theme }: { theme: Theme }) =>
export const StrokeStyleDottedIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H36"
@@ -625,7 +631,7 @@ export const StrokeStyleDottedIcon = React.memo(({ theme }: { theme: Theme }) =>
);
export const SloppinessArchitectIcon = React.memo(
({ theme }: { theme: Theme }) =>
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
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"
@@ -638,7 +644,8 @@ export const SloppinessArchitectIcon = React.memo(
),
);
export const SloppinessArtistIcon = React.memo(({ theme }: { theme: Theme }) =>
export const SloppinessArtistIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
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"
@@ -652,7 +659,7 @@ export const SloppinessArtistIcon = React.memo(({ theme }: { theme: Theme }) =>
);
export const SloppinessCartoonistIcon = React.memo(
({ theme }: { theme: Theme }) =>
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
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"
@@ -665,7 +672,8 @@ export const SloppinessCartoonistIcon = React.memo(
),
);
export const EdgeSharpIcon = React.memo(({ theme }: { theme: Theme }) =>
export const EdgeSharpIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M10 17L10 5L35 5"
@@ -678,7 +686,8 @@ export const EdgeSharpIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const EdgeRoundIcon = React.memo(({ theme }: { theme: Theme }) =>
export const EdgeRoundIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M10 17V15C10 8 13 5 21 5L33.5 5"
@@ -691,7 +700,8 @@ export const EdgeRoundIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const ArrowheadNoneIcon = React.memo(({ theme }: { theme: Theme }) =>
export const ArrowheadNoneIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H34"
@@ -707,7 +717,7 @@ export const ArrowheadNoneIcon = React.memo(({ theme }: { theme: Theme }) =>
);
export const ArrowheadArrowIcon = React.memo(
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
@@ -723,7 +733,7 @@ export const ArrowheadArrowIcon = React.memo(
);
export const ArrowheadDotIcon = React.memo(
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
createIcon(
<g
stroke={iconFillColor(theme)}
@@ -738,7 +748,7 @@ export const ArrowheadDotIcon = React.memo(
);
export const ArrowheadBarIcon = React.memo(
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
createIcon(
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
<path
@@ -752,7 +762,8 @@ export const ArrowheadBarIcon = React.memo(
),
);
export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
export const FontSizeSmallIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
@@ -762,7 +773,8 @@ export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const FontSizeMediumIcon = React.memo(({ theme }: { theme: Theme }) =>
export const FontSizeMediumIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
@@ -772,7 +784,8 @@ export const FontSizeMediumIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const FontSizeLargeIcon = React.memo(({ theme }: { theme: Theme }) =>
export const FontSizeLargeIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
@@ -783,7 +796,7 @@ export const FontSizeLargeIcon = React.memo(({ theme }: { theme: Theme }) =>
);
export const FontSizeExtraLargeIcon = React.memo(
({ theme }: { theme: Theme }) =>
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
@@ -794,7 +807,7 @@ export const FontSizeExtraLargeIcon = React.memo(
);
export const FontFamilyHandDrawnIcon = React.memo(
({ theme }: { theme: Theme }) =>
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
@@ -804,7 +817,8 @@ export const FontFamilyHandDrawnIcon = React.memo(
),
);
export const FontFamilyNormalIcon = React.memo(({ theme }: { theme: Theme }) =>
export const FontFamilyNormalIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -820,7 +834,8 @@ export const FontFamilyNormalIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const FontFamilyCodeIcon = React.memo(({ theme }: { theme: Theme }) =>
export const FontFamilyCodeIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
@@ -832,7 +847,8 @@ export const FontFamilyCodeIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const TextAlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignLeftIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<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"
@@ -843,7 +859,8 @@ export const TextAlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const TextAlignCenterIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignCenterIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M432 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-16zM108.1 96h231.81A12.09 12.09 0 00352 83.9V44.09A12.09 12.09 0 00339.91 32H108.1A12.09 12.09 0 0096 44.09V83.9A12.1 12.1 0 00108.1 96zm231.81 256A12.09 12.09 0 00352 339.9v-39.81A12.09 12.09 0 00339.91 288H108.1A12.09 12.09 0 0096 300.09v39.81a12.1 12.1 0 0012.1 12.1z"
@@ -853,7 +870,8 @@ export const TextAlignCenterIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignRightIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<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"
+7 -17
View File
@@ -1,6 +1,6 @@
import { FontFamily } from "./element/types";
import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types";
import { FontFamilyValues } from "./element/types";
export const APP_NAME = "Excalidraw";
@@ -14,7 +14,6 @@ export const CURSOR_TYPE = {
TEXT: "text",
CROSSHAIR: "crosshair",
GRABBING: "grabbing",
GRAB: "grab",
POINTER: "pointer",
MOVE: "move",
AUTO: "",
@@ -35,7 +34,6 @@ export enum EVENT {
MOUSE_MOVE = "mousemove",
RESIZE = "resize",
UNLOAD = "unload",
FOCUS = "focus",
BLUR = "blur",
DRAG_OVER = "dragover",
DROP = "drop",
@@ -65,20 +63,15 @@ export const CLASSES = {
// 1-based in case we ever do `if(element.fontFamily)`
export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
};
export const THEME = {
LIGHT: "light",
DARK: "dark",
};
1: "Virgil",
2: "Helvetica",
3: "Cascadia",
} as const;
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
@@ -90,7 +83,7 @@ export const GRID_SIZE = 20; // TODO make it configurable?
export const MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
} as const;
};
export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw",
@@ -151,6 +144,3 @@ export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
+26 -23
View File
@@ -51,12 +51,11 @@
image-rendering: -moz-crisp-edges; // FF
z-index: var(--zIndex-canvas);
// Remove the main canvas from document flow to avoid resizeObserver
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
}
&__canvas {
#canvas {
// Remove the main canvas from document flow to avoid resizeObserver
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
position: absolute;
}
@@ -414,6 +413,22 @@
&:active {
background-color: var(--button-gray-2);
}
&.dropdown-select--floating {
margin: 0.5em;
}
}
.dropdown-select__language.dropdown-select--floating {
bottom: 10px;
:root[dir="ltr"] & {
right: 44px;
}
:root[dir="rtl"] & {
left: 44px;
}
}
.zIndexButton {
@@ -440,38 +455,26 @@
}
.help-icon {
display: flex;
cursor: pointer;
fill: $oc-gray-6;
width: 1.5rem;
padding: 0;
margin: 0;
margin-top: 5px;
background: none;
color: var(--icon-fill-color);
svg {
width: 1.5rem;
height: 1.5rem;
}
&:hover {
background: none;
}
:root[dir="ltr"] & {
margin-right: 14px;
}
.reset-zoom-button {
padding: 0.2em;
background: transparent;
color: var(--text-primary-color);
font-family: var(--ui-font);
:root[dir="rtl"] & {
margin-left: 14px;
}
.undo-redo-buttons {
display: grid;
grid-auto-flow: column;
gap: 0.4em;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: 0.6em;
}
@include isMobile {
+1 -24
View File
@@ -1,12 +1,10 @@
import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState } from "../types";
import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData } from "./json";
import { restore } from "./restore";
import { ImportedLibraryData } from "./types";
@@ -81,30 +79,10 @@ export const getMimeType = (blob: Blob | string): string => {
return "";
};
export const getFileHandleType = (handle: FileSystemHandle | null) => {
if (!handle) {
return null;
}
return handle.name.match(/\.(json|excalidraw|png|svg)$/)?.[1] || null;
};
export const isImageFileHandleType = (
type: string | null,
): type is "png" | "svg" => {
return type === "png" || type === "svg";
};
export const isImageFileHandle = (handle: FileSystemHandle | null) => {
const type = getFileHandleType(handle);
return type === "png" || type === "svg";
};
export const loadFromBlob = async (
blob: Blob,
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
) => {
const contents = await parseFileContents(blob);
try {
@@ -117,7 +95,7 @@ export const loadFromBlob = async (
elements: clearElementsForExport(data.elements || []),
appState: {
theme: localAppState?.theme,
fileHandle: blob.handle || null,
fileHandle: (!blob.type.startsWith("image/") && blob.handle) || null,
...cleanAppStateForExport(data.appState || {}),
...(localAppState
? calculateScrollCenter(data.elements || [], localAppState, null)
@@ -125,7 +103,6 @@ export const loadFromBlob = async (
},
},
localAppState,
localElements,
);
return result;
-122
View File
@@ -1,122 +0,0 @@
import {
FileWithHandle,
fileOpen as _fileOpen,
fileSave as _fileSave,
FileSystemHandle,
supported as nativeFileSystemSupported,
} from "@dwelle/browser-fs-access";
import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors";
import { debounce } from "../utils";
type FILE_EXTENSION =
| "jpg"
| "png"
| "svg"
| "json"
| "excalidraw"
| "excalidrawlib";
const FILE_TYPE_TO_MIME_TYPE: Record<FILE_EXTENSION, string> = {
jpg: "image/jpeg",
png: "image/png",
svg: "image/svg+xml",
json: "application/json",
excalidraw: MIME_TYPES.excalidraw,
excalidrawlib: MIME_TYPES.excalidrawlib,
};
const INPUT_CHANGE_INTERVAL_MS = 500;
export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[];
description?: string;
multiple?: M;
}): Promise<
M extends false | undefined ? FileWithHandle : FileWithHandle[]
> => {
// an unsafe TS hack, alas not much we can do AFAIK
type RetType = M extends false | undefined
? FileWithHandle
: FileWithHandle[];
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(FILE_TYPE_TO_MIME_TYPE[type]);
return mimeTypes;
}, [] as string[]);
const extensions = opts.extensions?.reduce((acc, ext) => {
if (ext === "jpg") {
return acc.concat(".jpg", ".jpeg");
}
return acc.concat(`.${ext}`);
}, [] as string[]);
return _fileOpen({
description: opts.description,
extensions,
mimeTypes,
multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener(EVENT.FOCUS, focusHandler);
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new AbortError());
}
};
},
}) as Promise<RetType>;
};
export const fileSave = (
blob: Blob,
opts: {
/** supply without the extension */
name: string;
/** file extension */
extension: FILE_EXTENSION;
description?: string;
/** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null;
},
) => {
return _fileSave(
blob,
{
fileName: `${opts.name}.${opts.extension}`,
description: opts.description,
extensions: [`.${opts.extension}`],
},
opts.fileHandle,
);
};
export type { FileSystemHandle };
export { nativeFileSystemSupported };
+25 -21
View File
@@ -1,15 +1,14 @@
import { fileSave } from "browser-fs-access";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { ExportType } from "../scene/types";
import { AppState } from "../types";
import { canvasToBlob } from "./blob";
import { fileSave, FileSystemHandle } from "./filesystem";
import { serializeAsJSON } from "./json";
export { loadFromBlob } from "./blob";
@@ -21,41 +20,45 @@ export const exportCanvas = async (
appState: AppState,
{
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
exportPadding = 10,
viewBackgroundColor,
name,
fileHandle = null,
scale = 1,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
name: string;
fileHandle?: FileSystemHandle | null;
scale?: number;
},
) => {
if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {
const tempSvg = await exportToSvg(elements, {
const tempSvg = exportToSvg(elements, {
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
scale,
metadata:
appState.exportEmbedScene && type === "svg"
? await (
await import(/* webpackChunkName: "image" */ "./image")
).encodeSvgMetadata({
text: serializeAsJSON(elements, appState),
})
: undefined,
});
if (type === "svg") {
return await fileSave(
new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }),
{
name,
extension: "svg",
fileHandle,
},
);
await fileSave(new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }), {
fileName: `${name}.svg`,
extensions: [".svg"],
});
return;
} else if (type === "clipboard-svg") {
await copyTextToSystemClipboard(tempSvg.outerHTML);
copyTextToSystemClipboard(tempSvg.outerHTML);
return;
}
}
@@ -64,6 +67,7 @@ export const exportCanvas = async (
exportBackground,
viewBackgroundColor,
exportPadding,
scale,
});
tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas);
@@ -71,6 +75,7 @@ export const exportCanvas = async (
tempCanvas.remove();
if (type === "png") {
const fileName = `${name}.png`;
if (appState.exportEmbedScene) {
blob = await (
await import(/* webpackChunkName: "image" */ "./image")
@@ -80,10 +85,9 @@ export const exportCanvas = async (
});
}
return await fileSave(blob, {
name,
extension: "png",
fileHandle,
await fileSave(blob, {
fileName,
extensions: [".png"],
});
} else if (type === "clipboard") {
try {
+20 -23
View File
@@ -1,10 +1,10 @@
import { fileOpen, fileSave } from "./filesystem";
import { fileOpen, fileSave } from "browser-fs-access";
import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { isImageFileHandle, loadFromBlob } from "./blob";
import { loadFromBlob } from "./blob";
import {
ExportedDataState,
@@ -15,7 +15,7 @@ import Library from "./library";
export const serializeAsJSON = (
elements: readonly ExcalidrawElement[],
appState: Partial<AppState>,
appState: AppState,
): string => {
const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw,
@@ -37,21 +37,19 @@ export const saveAsJSON = async (
type: MIME_TYPES.excalidraw,
});
const fileHandle = await fileSave(blob, {
name: appState.name,
extension: "excalidraw",
const fileHandle = await fileSave(
blob,
{
fileName: `${appState.name}.excalidraw`,
description: "Excalidraw file",
fileHandle: isImageFileHandle(appState.fileHandle)
? null
: appState.fileHandle,
});
extensions: [".excalidraw"],
},
appState.fileHandle,
);
return { fileHandle };
};
export const loadFromJSON = async (
localAppState: AppState,
localElements: readonly ExcalidrawElement[] | null,
) => {
export const loadFromJSON = async (localAppState: AppState) => {
const blob = await fileOpen({
description: "Excalidraw files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
@@ -66,7 +64,7 @@ export const loadFromJSON = async (
],
*/
});
return loadFromBlob(blob, localAppState, localElements);
return loadFromBlob(blob, localAppState);
};
export const isValidExcalidrawData = (data?: {
@@ -100,16 +98,15 @@ export const saveLibraryAsJSON = async (library: Library) => {
library: libraryItems,
};
const serialized = JSON.stringify(data, null, 2);
await fileSave(
new Blob([serialized], {
const fileName = "library.excalidrawlib";
const blob = new Blob([serialized], {
type: MIME_TYPES.excalidrawlib,
}),
{
name: "library",
extension: "excalidrawlib",
});
await fileSave(blob, {
fileName,
description: "Excalidraw library file",
},
);
extensions: [".excalidrawlib"],
});
};
export const importLibraryFromJSON = async (library: Library) => {
+2 -2
View File
@@ -2,7 +2,7 @@ import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types";
import { restoreElements } from "./restore";
import { getNonDeletedElements } from "../element";
import type App from "../components/App";
import App from "../components/App";
class Library {
private libraryCache: LibraryItems | null = null;
@@ -18,7 +18,7 @@ class Library {
};
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
const elements = getNonDeletedElements(restoreElements(libraryItem, null));
const elements = getNonDeletedElements(restoreElements(libraryItem));
return elements.length ? elements : null;
};
-38
View File
@@ -1,38 +0,0 @@
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { exportCanvas } from ".";
import { getNonDeletedElements } from "../element";
import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
const fileHandleType = getFileHandleType(fileHandle);
if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
throw new Error(
"fileHandle should exist and should be of type svg or png when resaving",
);
}
appState = {
...appState,
exportEmbedScene: true,
};
await exportCanvas(
fileHandleType,
getNonDeletedElements(elements),
appState,
{
exportBackground,
viewBackgroundColor,
name,
fileHandle,
},
);
return { fileHandle };
};
+16 -29
View File
@@ -1,26 +1,21 @@
import {
ExcalidrawElement,
FontFamily,
ExcalidrawSelectionElement,
FontFamilyValues,
} from "../element/types";
import { AppState, NormalizedZoomValue } from "../types";
import { ImportedDataState } from "./types";
import {
getElementMap,
getNormalizedDimensions,
isInvisiblySmallElement,
} from "../element";
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random";
import {
FONT_FAMILY,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
FONT_FAMILY,
} from "../constants";
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
type RestoredAppState = Omit<
AppState,
@@ -46,11 +41,11 @@ export type RestoredDataState = {
appState: RestoredAppState;
};
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
return FONT_FAMILY[
fontFamilyName as keyof typeof FONT_FAMILY
] as FontFamilyValues;
const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
if (fontFamilyString.includes(fontFamilyName)) {
return parseInt(id) as FontFamily;
}
}
return DEFAULT_FONT_FAMILY;
};
@@ -186,20 +181,13 @@ const restoreElement = (
export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
): ExcalidrawElement[] => {
const localElementsMap = localElements ? getElementMap(localElements) : null;
return (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement = restoreElement(element);
const migratedElement = restoreElement(element);
if (migratedElement) {
const localElement = localElementsMap?.[element.id];
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version);
}
elements.push(migratedElement);
}
}
@@ -209,25 +197,25 @@ export const restoreElements = (
export const restoreAppState = (
appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null | undefined,
localAppState: Partial<AppState> | null,
): RestoredAppState => {
appState = appState || {};
const defaultAppState = getDefaultAppState();
const nextAppState = {} as typeof defaultAppState;
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
for (const [key, val] of Object.entries(defaultAppState) as [
keyof typeof defaultAppState,
any,
][]) {
const suppliedValue = appState[key];
const restoredValue = appState[key];
const localValue = localAppState ? localAppState[key] : undefined;
(nextAppState as any)[key] =
suppliedValue !== undefined
? suppliedValue
restoredValue !== undefined
? restoredValue
: localValue !== undefined
? localValue
: defaultValue;
: val;
}
return {
@@ -255,10 +243,9 @@ export const restore = (
* Supply `null` if you can't get access to it.
*/
localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined,
): RestoredDataState => {
return {
elements: restoreElements(data?.elements, localElements),
elements: restoreElements(data?.elements),
appState: restoreAppState(data?.appState, localAppState || null),
};
};
+4 -4
View File
@@ -328,15 +328,15 @@ const hitTestFreeDrawElement = (
let P: readonly [number, number];
// For freedraw dots
if (
if (element.points.length === 2) {
return (
distance2d(A[0], A[1], x, y) < threshold ||
distance2d(B[0], B[1], x, y) < threshold
) {
return true;
);
}
// For freedraw lines
for (let i = 0; i < element.points.length; i++) {
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]);
+1 -1
View File
@@ -5,7 +5,7 @@ import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import Scene from "../scene/Scene";
import { NonDeletedExcalidrawElement } from "./types";
import { PointerDownState } from "../types";
import { PointerDownState } from "../components/App";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
+2 -5
View File
@@ -120,11 +120,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
*
* NOTE: does not trigger re-render.
*/
export const bumpVersion = (
element: Mutable<ExcalidrawElement>,
version?: ExcalidrawElement["version"],
) => {
element.version = (version ?? element.version) + 1;
export const bumpVersion = (element: Mutable<ExcalidrawElement>) => {
element.version = element.version + 1;
element.versionNonce = randomInteger();
return element;
};
+1 -2
View File
@@ -1,7 +1,6 @@
import { duplicateElement } from "./newElement";
import { mutateElement } from "./mutateElement";
import { API } from "../tests/helpers/api";
import { FONT_FAMILY } from "../constants";
const isPrimitive = (val: any) => {
const type = typeof val;
@@ -80,7 +79,7 @@ it("clones text element", () => {
opacity: 100,
text: "hello",
fontSize: 20,
fontFamily: FONT_FAMILY.Virgil,
fontFamily: 1,
textAlign: "left",
verticalAlign: "top",
});
+2 -2
View File
@@ -5,11 +5,11 @@ import {
ExcalidrawGenericElement,
NonDeleted,
TextAlign,
FontFamily,
GroupId,
VerticalAlign,
Arrowhead,
ExcalidrawFreeDrawElement,
FontFamilyValues,
} from "../element/types";
import { measureText, getFontString } from "../utils";
import { randomInteger, randomId } from "../random";
@@ -109,7 +109,7 @@ export const newTextElement = (
opts: {
text: string;
fontSize: number;
fontFamily: FontFamilyValues;
fontFamily: FontFamily;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
} & ElementConstructorOpts,
+2 -1
View File
@@ -32,7 +32,8 @@ import {
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
import { Point, PointerDownState } from "../types";
import { PointerDownState } from "../components/App";
import { Point } from "../types";
export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) {
+3 -5
View File
@@ -1,11 +1,9 @@
import { Point } from "../types";
import { FONT_FAMILY, THEME } from "../constants";
import { FONT_FAMILY } from "../constants";
export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid";
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
export type Theme = typeof THEME[keyof typeof THEME];
export type FontFamily = keyof typeof FONT_FAMILY;
export type FontString = string & { _brand: "fontString" };
export type GroupId = string;
export type PointerType = "mouse" | "pen" | "touch";
@@ -93,7 +91,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
Readonly<{
type: "text";
fontSize: number;
fontFamily: FontFamilyValues;
fontFamily: FontFamily;
text: string;
baseline: number;
textAlign: TextAlign;
-7
View File
@@ -1,5 +1,4 @@
type CANVAS_ERROR_NAMES = "CANVAS_ERROR" | "CANVAS_POSSIBLY_TOO_BIG";
export class CanvasError extends Error {
constructor(
message: string = "Couldn't export canvas.",
@@ -10,9 +9,3 @@ export class CanvasError extends Error {
this.message = message;
}
}
export class AbortError extends DOMException {
constructor(message: string = "Request Aborted") {
super(message, "AbortError");
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { debounce, getVersion, nFormatter } from "../utils";
import {
getElementsStorageSize,
+4 -9
View File
@@ -1,6 +1,6 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import { ExcalidrawImperativeAPI } from "../../types";
import React, { PureComponent } from "react";
import { ExcalidrawImperativeAPI } from "../../components/App";
import { ErrorDialog } from "../../components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
@@ -41,7 +41,6 @@ import { UserIdleState } from "../../types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
import { trackEvent } from "../../analytics";
import { isInvisiblySmallElement } from "../../element";
import { getRandomUsername } from "@excalidraw/random-username";
interface CollabState {
modalIsShown: boolean;
@@ -224,10 +223,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
return null;
}
if (!this.state.username) {
this.updateUsername(getRandomUsername());
}
let roomId;
let roomKey;
@@ -598,7 +593,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.setState({ modalIsShown: false });
};
updateUsername = (username: string) => {
onUsernameChange = (username: string) => {
this.setState({ username });
saveUsernameToLocalStorage(username);
};
@@ -640,7 +635,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
handleClose={this.handleClose}
activeRoomLink={activeRoomLink}
username={username}
onUsernameChange={this.updateUsername}
onUsernameChange={this.onUsernameChange}
onRoomCreate={this.openPortal}
onRoomDestroy={this.closePortal}
setErrorMessage={(errorMessage) => {
+1 -7
View File
@@ -14,7 +14,6 @@ import { t } from "../../i18n";
import "./RoomDialog.scss";
import Stack from "../../components/Stack";
import { AppState } from "../../types";
import { getRandomUsername } from "@excalidraw/random-username";
const getShareIcon = () => {
const navigator = window.navigator as any;
@@ -138,14 +137,9 @@ const RoomDialog = ({
</label>
<input
id="username"
value={username}
value={username || ""}
className="RoomDialog-username TextInput"
onChange={(event) => onUsernameChange(event.target.value)}
onBlur={(event) => {
if (!event.target.value.trim()) {
onUsernameChange(getRandomUsername());
}
}}
onKeyPress={(event) => event.key === "Enter" && handleClose()}
/>
</div>
@@ -1,92 +0,0 @@
import React from "react";
import { Card } from "../../components/Card";
import { ToolButton } from "../../components/ToolButton";
import { serializeAsJSON } from "../../data/json";
import { getImportedKey, createIV, generateEncryptionKey } from "../data";
import { loadFirebaseStorage } from "../data/firebase";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { nanoid } from "nanoid";
import { t } from "../../i18n";
import { excalidrawPlusIcon } from "./icons";
const encryptData = async (
key: string,
json: string,
): Promise<{ blob: Blob; iv: Uint8Array }> => {
const importedKey = await getImportedKey(key, "encrypt");
const iv = createIV();
const encoded = new TextEncoder().encode(json);
const ciphertext = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
encoded,
);
return { blob: new Blob([new Uint8Array(ciphertext)]), iv };
};
const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
const firebase = await loadFirebaseStorage();
const id = `${nanoid(12)}`;
const key = (await generateEncryptionKey())!;
const encryptedData = await encryptData(
key,
serializeAsJSON(elements, appState),
);
const blob = new Blob([encryptedData.iv, encryptedData.blob], {
type: "application/octet-stream",
});
await firebase
.storage()
.ref(`/migrations/scenes/${id}`)
.put(blob, {
customMetadata: {
data: JSON.stringify({ version: 1, name: appState.name }),
created: Date.now().toString(),
},
});
window.open(`https://plus.excalidraw.com/import?excalidraw=${id},${key}`);
};
export const ExportToExcalidrawPlus: React.FC<{
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
onError: (error: Error) => void;
}> = ({ elements, appState, onError }) => {
return (
<Card color="indigo">
<div className="Card-icon">{excalidrawPlusIcon}</div>
<h2>Excalidraw+</h2>
<div className="Card-details">
{t("exportDialog.excalidrawplus_description")}
</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.excalidrawplus_button")}
aria-label={t("exportDialog.excalidrawplus_button")}
showAriaLabel={true}
onClick={async () => {
try {
await exportToExcalidrawPlus(elements, appState);
} catch (error) {
console.error(error);
onError(new Error(t("exportDialog.excalidrawplus_exportError")));
}
}}
/>
</Card>
);
};
@@ -1,11 +1,9 @@
import oc from "open-color";
import React from "react";
import { THEME } from "../../constants";
import { Theme } from "../../element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => (
({ theme, dir }: { theme: "light" | "dark"; dir: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
@@ -27,18 +25,18 @@ export const GitHubCorner = React.memo(
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
fill={theme === "light" ? oc.gray[6] : oc.gray[7]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
/>
</a>
</svg>
@@ -1,18 +1,23 @@
import React from "react";
import clsx from "clsx";
import * as i18n from "../../i18n";
export const LanguageList = ({
onChange,
languages = i18n.languages,
currentLangCode = i18n.getLanguage().code,
floating,
}: {
languages?: { code: string; label: string }[];
onChange: (langCode: i18n.Language["code"]) => void;
currentLangCode?: i18n.Language["code"];
floating?: boolean;
}) => (
<React.Fragment>
<select
className="dropdown-select dropdown-select__language"
className={clsx("dropdown-select dropdown-select__language", {
"dropdown-select--floating": floating,
})}
onChange={({ target }) => onChange(target.value)}
value={currentLangCode}
aria-label={i18n.t("buttons.selectLanguage")}
File diff suppressed because one or more lines are too long
+8 -36
View File
@@ -5,19 +5,15 @@ import { getSceneVersion } from "../../element";
import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore";
// private
// -----------------------------------------------------------------------------
let firebasePromise: Promise<
typeof import("firebase/app").default
> | null = null;
let firestorePromise: Promise<any> | null = null;
let firebseStoragePromise: Promise<any> | null = null;
const _loadFirebase = async () => {
const loadFirebase = async () => {
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
await import(/* webpackChunkName: "firestore" */ "firebase/firestore");
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
firebase.initializeApp(firebaseConfig);
@@ -25,37 +21,13 @@ const _loadFirebase = async () => {
return firebase;
};
const _getFirebase = async (): Promise<
const getFirebase = async (): Promise<
typeof import("firebase/app").default
> => {
if (!firebasePromise) {
firebasePromise = _loadFirebase();
firebasePromise = loadFirebase();
}
return firebasePromise;
};
// -----------------------------------------------------------------------------
const loadFirestore = async () => {
const firebase = await _getFirebase();
if (!firestorePromise) {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
await firestorePromise;
}
return firebase;
};
export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase();
if (!firebseStoragePromise) {
firebseStoragePromise = import(
/* webpackChunkName: "storage" */ "firebase/storage"
);
await firebseStoragePromise;
}
return firebase;
return await firebasePromise!;
};
interface FirebaseStoredScene {
@@ -136,7 +108,7 @@ export const saveToFirebase = async (
return true;
}
const firebase = await loadFirestore();
const firebase = await getFirebase();
const sceneVersion = getSceneVersion(elements);
const { ciphertext, iv } = await encryptElements(roomKey, elements);
@@ -178,7 +150,7 @@ export const loadFromFirebase = async (
roomKey: string,
socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
const firebase = await getFirebase();
const db = firebase.firestore();
const docRef = db.collection("scenes").doc(roomId);
@@ -196,5 +168,5 @@ export const loadFromFirebase = async (
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
}
return restoreElements(elements, null);
return restoreElements(elements);
};
+3 -8
View File
@@ -17,7 +17,7 @@ const generateRandomID = async () => {
return Array.from(arr, byteToHex).join("");
};
export const generateEncryptionKey = async () => {
const generateEncryptionKey = async () => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
@@ -137,10 +137,6 @@ export const decryptAESGEM = async (
export const getCollaborationLinkData = (link: string) => {
const hash = new URL(link).hash;
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
if (match && match[2].length !== 22) {
window.alert(t("alerts.invalidEncryptionKey"));
return null;
}
return match ? { roomId: match[1], roomKey: match[2] } : null;
};
@@ -180,7 +176,7 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
[usage],
);
export const decryptImported = async (
const decryptImported = async (
iv: ArrayBuffer,
encrypted: ArrayBuffer,
privateKey: string,
@@ -261,10 +257,9 @@ export const loadScene = async (
data = restore(
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
);
} else {
data = restore(localDataState || null, null, null);
data = restore(localDataState || null, null);
}
return {
+1 -7
View File
@@ -2,18 +2,12 @@
.layer-ui__wrapper__footer-center {
display: flex;
justify-content: space-between;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: auto;
}
.encrypted-icon {
border-radius: var(--space-factor);
color: var(--icon-green-fill-color);
margin-top: auto;
margin-bottom: auto;
margin-inline-start: auto;
margin-inline-end: 0.6em;
margin-top: 13px;
svg {
width: 1.2rem;
+14 -21
View File
@@ -1,7 +1,14 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { ExcalidrawImperativeAPI } from "../components/App";
import { ErrorDialog } from "../components/ErrorDialog";
import { TopErrorBoundary } from "../components/TopErrorBoundary";
import {
@@ -24,7 +31,7 @@ import Excalidraw, {
defaultLang,
languages,
} from "../packages/excalidraw/index";
import { AppState, LibraryItems, ExcalidrawImperativeAPI } from "../types";
import { AppState, LibraryItems } from "../types";
import {
debounce,
getVersion,
@@ -49,7 +56,6 @@ import { Tooltip } from "../components/Tooltip";
import { shield } from "../components/icons";
import "./index.scss";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
const languageDetector = new LanguageDetector();
languageDetector.init({
@@ -135,7 +141,7 @@ const initializeScene = async (opts: {
const url = externalUrlMatch[1];
try {
const request = await fetch(window.decodeURIComponent(url));
const data = await loadFromBlob(await request.blob(), null, null);
const data = await loadFromBlob(await request.blob(), null);
if (
!scene.elements.length ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
@@ -342,8 +348,11 @@ const ExcalidrawWrapper = () => {
const renderLanguageList = () => (
<LanguageList
onChange={(langCode) => setLangCode(langCode)}
onChange={(langCode) => {
setLangCode(langCode);
}}
languages={languages}
floating={!isMobile}
currentLangCode={langCode}
/>
);
@@ -419,21 +428,6 @@ const ExcalidrawWrapper = () => {
canvasActions: {
export: {
onExportToBackend,
renderCustomUI: (elements, appState) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
errorMessage: error.message,
},
});
}}
/>
);
},
},
},
}}
@@ -444,7 +438,6 @@ const ExcalidrawWrapper = () => {
detectScroll={false}
handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
autoFocus={true}
/>
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && (
-1
View File
@@ -49,7 +49,6 @@ const allLanguages: Language[] = [
{ code: "zh-TW", label: "繁體中文" },
{ code: "lv-LV", label: "Latviešu" },
{ code: "cs-CZ", label: "Česky" },
{ code: "kk-KZ", label: "Қазақ тілі" },
].concat([defaultLang]);
export const languages: Language[] = allLanguages
+1
View File
@@ -69,6 +69,7 @@ const canvas = exportToCanvas(
{
exportBackground: true,
viewBackgroundColor: "#ffffff",
scale: 1,
},
createCanvas,
);
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import ReactDOM from "react-dom";
import ExcalidrawApp from "./excalidraw-app";
+27 -84
View File
@@ -4,7 +4,7 @@
"pasteCharts": "لصق الرسوم البيانية",
"selectAll": "تحديد الكل",
"multiSelect": "إضافة عنصر للتحديد",
"moveCanvas": "نقل لوح الرسم",
"moveCanvas": "نقل لوح رسم",
"cut": "قص",
"copy": "نسخ",
"copyAsPng": "نسخ إلى الحافظة بصيغة PNG",
@@ -14,18 +14,18 @@
"bringToFront": "أحضر للأمام",
"sendBackward": "أرسل للخلف",
"delete": "حذف",
"copyStyles": "نسخ الأنماط",
"pasteStyles": "لصق الأنماط",
"stroke": "الخط",
"copyStyles": "نسخ النمط",
"pasteStyles": "لصق النمط",
"stroke": "الحدود",
"background": "الخلفية",
"fill": "التعبئة",
"strokeWidth": "سُمك الخط",
"strokeShape": "شكل الخط",
"strokeShape_gel": "قلم جل",
"strokeShape_fountain": "قلم رش",
"strokeShape_brush": "فرشاه",
"strokeStyle": "نمط الخط",
"strokeStyle_solid": "كامل",
"strokeWidth": "حجم الحدود",
"strokeShape": "",
"strokeShape_gel": "",
"strokeShape_fountain": "",
"strokeShape_brush": "",
"strokeStyle": "نمط الحدود",
"strokeStyle_solid": "صلبة",
"strokeStyle_dashed": "متقطع",
"strokeStyle_dotted": "منقط",
"sloppiness": "الإمالة",
@@ -43,12 +43,12 @@
"fontFamily": "نوع الخط",
"onlySelected": "المحدد فقط",
"withBackground": "الخلفية",
"exportEmbedScene": "تضمين المشهد",
"exportEmbedScene": "",
"exportEmbedScene_details": "سيتم حفظ بيانات المشهد في ملف PNG/SVG المصدّر بحيث يمكن استعادة المشهد منه.\nسيزيد حجم الملف المصدر.",
"addWatermark": "إضافة \"مصنوعة بواسطة Excalidraw\"",
"handDrawn": "رسم باليد",
"normal": "عادي",
"code": "رمز",
"code": "الرمز",
"small": "صغير",
"medium": "متوسط",
"large": "كبير",
@@ -66,7 +66,7 @@
"artist": "رسام",
"cartoonist": "كرتوني",
"fileTitle": "إسم الملف",
"colorPicker": "منتقي اللون",
"colorPicker": "اختيار الألوان",
"canvasBackground": "خلفية اللوحة",
"drawingCanvas": "لوحة الرسم",
"layers": "الطبقات",
@@ -99,10 +99,8 @@
"flipHorizontal": "قلب عامودي",
"flipVertical": "قلب أفقي",
"viewMode": "نمط العرض",
"toggleExportColorScheme": "تبديل نظام ألوان الصادرات",
"toggleExportColorScheme": "",
"share": "مشاركة",
"showStroke": "إظهار منتقي لون الخط",
"showBackground": "إظهار منتقي لون الخلفية",
"toggleTheme": "غير النمط"
},
"buttons": {
@@ -151,14 +149,13 @@
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)",
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
"errorAddingToLibrary": "تعذر إضافة العنصر للمكتبة",
"errorRemovingFromLibrary": "تعذر إزالة العنصر من المكتبة",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
"invalidSceneUrl": "",
"resetLibrary": ""
},
"toolBar": {
"selection": "تحديد",
@@ -167,7 +164,7 @@
"ellipse": "دائرة",
"arrow": "سهم",
"line": "خط",
"freedraw": "رسم",
"freedraw": "",
"text": "نص",
"library": "مكتبة",
"lock": "الحفاظ على أداة التحديد نشطة بعد الرسم"
@@ -181,8 +178,6 @@
"linearElement": "انقر لبدء نقاط متعددة، اسحب لخط واحد",
"freeDraw": "انقر واسحب، افرج عند الانتهاء",
"text": "نصيحة: يمكنك أيضًا إضافة نص بالنقر المزدوج في أي مكان بأداة الاختيار",
"text_selected": "انقر نقراً مزدوجاً أو اضغط ادخال لتعديل النص",
"text_editing": "اضغط على Esc أو (Ctrl أو Cmd) + Enter لإنهاء التعديل",
"linearElementMulti": "انقر فوق النقطة الأخيرة أو اضغط على Esc أو Enter للإنهاء",
"lockAngle": "يمكنك تقييد الزاوية بالضغط على SHIFT",
"resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز",
@@ -217,21 +212,18 @@
"desc_inProgressIntro": "تجري الآن المشاركة الحية.",
"desc_shareLink": "شارك هذا الرابط مع أي شخص تريده أن يشاركك الجلسة:",
"desc_exitSession": "إيقاف الجلسة سيؤدي إلى قطع الاتصال الخاص بك من الغرفة، ولكن ستتمكن من مواصلة العمل مع المشهد، محليا. لاحظ أن هذا لن يؤثر على الأشخاص الآخرين، و سيظلون قادرين على التعاون في إصدارهم.",
"shareTitle": "الانضمام إلى جلسة تعاون حية على Excalidraw"
"shareTitle": ""
},
"errorDialog": {
"title": "خطأ"
},
"exportDialog": {
"disk_title": "حفظ الملف للجهاز",
"disk_details": "تصدير بيانات المشهد إلى ملف يمكنك الاستيراد منه لاحقاً.",
"disk_details": "",
"disk_button": "إحفظ لملف",
"link_title": "رابط قابل للمشاركة",
"link_details": "صدر الملف للمشاهدة فقط.",
"link_button": "التصدير كرابط",
"excalidrawplus_description": "حفظ المشهد إلى مساحة العمل +Excalidraw الخاصة بك.",
"excalidrawplus_button": "تصدير",
"excalidrawplus_exportError": "تعذر التصدير إلى +Excalidraw في الوقت الحالي..."
"link_button": "التصدير كرابط"
},
"helpDialog": {
"blog": "اقرأ مدونتنا",
@@ -239,18 +231,16 @@
"curvedArrow": "سهم مائل",
"curvedLine": "خط مائل",
"documentation": "دليل الاستخدام",
"doubleClick": "انقر مرتين",
"drag": "اسحب",
"editor": "المحرر",
"editSelectedShape": "تعديل الشكل المحدد (النص/السهم/الخط)",
"github": "عثرت على مشكلة؟ إرسال",
"howto": "اتبع التعليمات",
"or": "أو",
"preventBinding": "منع ارتبط السهم",
"shapes": "أشكال",
"shortcuts": "اختصارات لوحة المفاتيح",
"textFinish": "إنهاء التعديل (محرر النص)",
"textNewLine": "أضف سطر جديد (محرر نص)",
"textFinish": "الانتهاء من التحرير (نص)",
"textNewLine": "اضف سطر جديد (نص)",
"title": "المساعدة",
"view": "عرض",
"zoomToFit": "تكبير للملائمة",
@@ -276,59 +266,12 @@
"width": "العرض"
},
"toast": {
"copyStyles": "نسخت الانماط.",
"copyStyles": "نسخ النمط.",
"copyToClipboard": "نسخ إلى الحافظة.",
"copyToClipboardAsPng": "تم نسخ {{exportSelection}} إلى الحافظة بصيغة PNG\n({{exportColorScheme}})",
"copyToClipboardAsPng": "تم نسخ {{exportSelection}} إلى الحافظة بصيغةPNG\n({{exportColorScheme}})",
"fileSaved": "تم حفظ الملف.",
"fileSavedToFilename": "حفظ باسم {filename}",
"canvas": "لوحة الرسم",
"selection": "العنصر المحدد"
},
"colors": {
"ffffff": "أبيض",
"f8f9fa": "رمادي 0",
"f1f3f5": "رمادي 1",
"fff5f5": "أحمر 0",
"fff0f6": "وردي 0",
"f8f0fc": "عنبي 0",
"f3f0ff": "بنفسجي 0",
"edf2ff": "نيلي 0",
"e7f5ff": "أزرق 0",
"e3fafc": "سماوي 0",
"e6fcf5": "تركواز 0",
"ebfbee": "أخضر 0",
"f4fce3": "ليموني 0",
"fff9db": "أصفر 0",
"fff4e6": "برتقالي 0",
"transparent": "شفاف",
"ced4da": "رمادي 4",
"868e96": "رمادي 6",
"fa5252": "أحمر 6",
"e64980": "وردي 6",
"be4bdb": "عنبي 6",
"7950f2": "بنفسجي 6",
"4c6ef5": "نيلي 6",
"228be6": "أزرق 6",
"15aabf": "سماوي 6",
"12b886": "تركواز 6",
"40c057": "أخضر 6",
"82c91e": "ليموني 6",
"fab005": "أصفر 6",
"fd7e14": "برتقالي 6",
"000000": "أسود",
"343a40": "رمادي 8",
"495057": "رمادي 7",
"c92a2a": "أحمر 9",
"a61e4d": "وردي 9",
"862e9c": "عنبي 9",
"5f3dc4": "بنفسجي 9",
"364fc7": "نيلي 9",
"1864ab": "أزرق 9",
"0b7285": "سماوي 9",
"087f5b": "تركواز 9",
"2b8a3e": "أخضر 9",
"5c940d": "ليموني 9",
"e67700": "أصفر 9",
"d9480f": "برتقالي 9"
}
}

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