Compare commits

...

18 Commits

Author SHA1 Message Date
dwelle 8946b2637f update changelog & readme 2021-10-23 13:53:10 +02:00
dwelle a834a4fda0 feat: expose app instance on excalidrawAPI 2021-10-23 13:41:14 +02:00
David Luzar 7dbd0c5e0a fix: clear LibraryUnit DOM on unmount (#4084) 2021-10-22 22:07:20 +02:00
David Luzar ba35eb8f8c fix: pasting images on firefox (#4085) 2021-10-22 21:04:04 +02:00
David Luzar 163ad1f4c4 feat: image support (#4011)
Co-authored-by: Emil Atanasov <heitara@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2021-10-21 22:05:48 +02:00
Aakansha Doshi 0f0244224d feat: Use dialog component for clear canvas instead of window confirm (#4075)
* feat: Use dialog component for clear canvas instead of window confirm

* reduce font weight

* fix specs

* update button name and use action

* export clearCanvas from actions
2021-10-21 17:35:28 +05:30
Aakansha Doshi 6eecadce60 feat: export isLinearElement and getNonDeletedElements (#4072)
* feat: export isLinearElement and getNonDeletedElements

* fix
2021-10-19 14:40:48 +05:30
Aakansha Doshi bc88cf5002 fix: Don't show save file to disk when UIOptions.canvasActions.export.saveFileToDisk is false (#4073) 2021-10-19 14:39:47 +05:30
dependabot[bot] 571be9c0fe chore(deps-dev): bump @babel/plugin-transform-runtime (#4053)
Bumps [@babel/plugin-transform-runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-runtime) from 7.14.5 to 7.15.8.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.8/packages/babel-plugin-transform-runtime)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-runtime"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-18 15:59:56 +05:30
Aakansha Doshi 5d925c7d3f build: Allow package.json changes when autoreleasing next (#4068) 2021-10-18 15:58:58 +05:30
David Luzar 45c520341f chore: bump @dwelle/browser-fs-access to 0.21.2 (#4067) 2021-10-18 11:08:12 +02:00
Aakansha Doshi c6ffc06541 feat: support renderTopRightUI in mobile (#4065) 2021-10-17 21:44:46 +05:30
Aakansha Doshi ff29780760 Refactor: convert initializeApp to func component and use JSX transform in the codebase (#4056) 2021-10-14 22:56:51 +05:30
Aakansha Doshi 463857ad9a feat: Export THEME from the package (#4055)
* Use Theme type everywhere
* Rename Appearance type to Theme for consistency
* Reorder headers in readme
The host don't need to pass hardcoded strings any more and instead can use the exported constant
2021-10-14 14:15:57 +05:30
dependabot[bot] be2da9539e chore(deps): bump path-parse in /src/packages/excalidraw (#3912)
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-13 20:18:29 +05:30
dependabot[bot] bb7829ef90 chore(deps): bump url-parse from 1.5.1 to 1.5.3 (#3927)
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.1 to 1.5.3.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.1...1.5.3)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-13 20:18:09 +05:30
dependabot[bot] 1104f6891e chore(deps): bump semver-regex from 3.1.2 to 3.1.3 (#3988)
Bumps [semver-regex](https://github.com/sindresorhus/semver-regex) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/sindresorhus/semver-regex/releases)
- [Commits](https://github.com/sindresorhus/semver-regex/commits)

---
updated-dependencies:
- dependency-name: semver-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-13 20:17:43 +05:30
dependabot[bot] a97e172070 chore(deps): bump tmpl from 1.0.4 to 1.0.5 (#3992)
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-13 20:17:24 +05:30
125 changed files with 4420 additions and 1305 deletions
+1
View File
@@ -5,3 +5,4 @@ package-lock.json
firebase/
dist/
public/workbox
src/packages/excalidraw/types
+5 -1
View File
@@ -19,19 +19,23 @@
]
},
"dependencies": {
"@dwelle/browser-fs-access": "0.21.1",
"@dwelle/browser-fs-access": "0.21.3",
"@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/pica": "5.1.3",
"@types/react": "17.0.3",
"@types/react-dom": "17.0.3",
"@types/socket.io-client": "1.4.36",
"clsx": "1.1.1",
"fake-indexeddb": "3.1.3",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.0",
"idb-keyval": "5.1.3",
"image-blob-reduce": "3.0.1",
"lodash.throttle": "4.1.1",
"nanoid": "3.1.22",
"open-color": "1.8.0",
+4 -3
View File
@@ -31,9 +31,11 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
const excalidrawPackageFiles = changedFiles.filter((file) => {
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
return (
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
!filesToIgnoreRegex.test(file)
);
});
if (!excalidrawPackageFiles.length) {
process.exit(0);
}
@@ -46,6 +48,5 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
// update readme
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
publish();
});
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { alignElements, Alignment } from "../align";
import {
AlignBottomIcon,
+12 -23
View File
@@ -1,15 +1,11 @@
import React from "react";
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker";
import { trash, zoomIn, zoomOut } from "../components/icons";
import { zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { ZOOM_STEP } from "../constants";
import { THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
@@ -18,6 +14,9 @@ import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState } from "../appState";
import ClearCanvas from "../components/ClearCanvas";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -48,13 +47,15 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
perform: (elements, appState: AppState) => {
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return {
elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }),
),
appState: {
...getDefaultAppState(),
files: {},
theme: appState.theme,
elementLocked: appState.elementLocked,
exportBackground: appState.exportBackground,
@@ -66,21 +67,8 @@ export const actionClearCanvas = register({
commitToHistory: true,
};
},
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useIsMobile()}
onClick={() => {
if (window.confirm(t("alerts.clearReset"))) {
updateData(null);
}
}}
data-testid="clear-canvas-button"
/>
),
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
});
export const actionZoomIn = register({
@@ -279,7 +267,8 @@ export const actionToggleTheme = register({
return {
appState: {
...appState,
theme: value || (appState.theme === "light" ? "dark" : "light"),
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
commitToHistory: false,
};
+4 -2
View File
@@ -9,8 +9,8 @@ import { t } from "../i18n";
export const actionCopy = register({
name: "copy",
perform: (elements, appState) => {
copyToClipboard(getNonDeletedElements(elements), appState);
perform: (elements, appState, _, app) => {
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
return {
commitToHistory: false,
@@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
appState,
);
return {
@@ -88,6 +89,7 @@ export const actionCopyAsPng = register({
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
appState,
);
return {
-1
View File
@@ -1,7 +1,6 @@
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,4 +1,3 @@
import React from "react";
import {
DistributeHorizontallyIcon,
DistributeVerticallyIcon,
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { KEYS } from "../keys";
import { register } from "./register";
import { ExcalidrawElement } from "../element/types";
+22 -15
View File
@@ -1,11 +1,10 @@
import React from "react";
import { trackEvent } from "../analytics";
import { load, questionCircle, 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, Appearence } from "../components/DarkModeToggle";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
@@ -14,12 +13,13 @@ import { KEYS } from "../keys";
import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem";
import { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES } from "../constants";
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",
@@ -128,13 +128,13 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
perform: async (elements, appState, value) => {
perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle;
try {
const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene(elements, appState)
: await saveAsJSON(elements, appState);
? await resaveAsImageWithScene(elements, appState, app.files)
: await saveAsJSON(elements, appState, app.files);
return {
commitToHistory: false,
@@ -170,12 +170,16 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
perform: async (elements, appState, value) => {
perform: async (elements, appState, value, app) => {
try {
const { fileHandle } = await saveAsJSON(elements, {
...appState,
fileHandle: null,
});
const { fileHandle } = await saveAsJSON(
elements,
{
...appState,
fileHandle: null,
},
app.files,
);
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) {
if (error?.name !== "AbortError") {
@@ -202,15 +206,17 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
perform: async (elements, appState) => {
perform: async (elements, appState, _, app) => {
try {
const {
elements: loadedElements,
appState: loadedAppState,
files,
} = await loadFromJSON(appState, elements);
return {
elements: loadedElements,
appState: loadedAppState,
files,
commitToHistory: true,
};
} catch (error) {
@@ -220,6 +226,7 @@ export const actionLoadScene = register({
return {
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
commitToHistory: false,
};
}
@@ -256,9 +263,9 @@ export const actionExportWithDarkMode = register({
}}
>
<DarkModeToggle
value={appState.exportWithDarkMode ? "dark" : "light"}
onChange={(theme: Appearence) => {
updateData(theme === "dark");
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
onChange={(theme: Theme) => {
updateData(theme === THEME.DARK);
}}
title={t("labels.toggleExportColorScheme")}
/>
+6 -1
View File
@@ -1,7 +1,6 @@
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";
@@ -50,6 +49,11 @@ export const actionFinalize = register({
}
let newElements = elements;
if (appState.pendingImageElement) {
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
}
if (window.document.activeElement instanceof HTMLElement) {
focusContainer();
}
@@ -153,6 +157,7 @@ export const actionFinalize = register({
[multiPointElement.id]: true,
}
: appState.selectedElementIds,
pendingImageElement: null,
},
commitToHistory: appState.elementType === "freedraw",
};
+4 -4
View File
@@ -93,13 +93,13 @@ const flipElements = (
appState: AppState,
flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => {
for (let i = 0; i < elements.length; i++) {
flipElement(elements[i], appState);
elements.forEach((element) => {
flipElement(element, appState);
// If vertical flip, rotate an extra 180
if (flipDirection === "vertical") {
rotateElement(elements[i], Math.PI);
rotateElement(element, Math.PI);
}
}
});
return elements;
};
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { getShortcutKey } from "../utils";
-1
View File
@@ -1,5 +1,4 @@
import { Action, ActionResult } from "./types";
import React from "react";
import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { menu, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
+8 -6
View File
@@ -1,4 +1,3 @@
import React from "react";
import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker";
@@ -60,6 +59,7 @@ import {
getTargetElements,
isSomeElementSelected,
} from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import { register } from "./register";
const changeProperty = (
@@ -104,11 +104,13 @@ export const actionChangeStrokeColor = register({
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
}),
),
elements: changeProperty(elements, appState, (el) => {
return hasStrokeColor(el.type)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
: el;
}),
}),
appState: {
...appState,
+3 -13
View File
@@ -8,18 +8,8 @@ import {
PanelComponentProps,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppProps, AppState } from "../types";
import { AppClassProperties, AppState } from "../types";
import { MODES } from "../constants";
import Library from "../data/library";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = {
canvas: HTMLCanvasElement | null;
focusContainer: () => void;
props: AppProps;
library: Library;
};
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@@ -28,13 +18,13 @@ export class ActionManager implements ActionsManagerInterface {
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: App;
app: AppClassProperties;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: App,
app: AppClassProperties,
) {
this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) {
+9 -9
View File
@@ -1,7 +1,11 @@
import React from "react";
import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types";
import Library from "../data/library";
import {
AppClassProperties,
AppState,
ExcalidrawProps,
BinaryFiles,
} from "../types";
import { ToolButtonSize } from "../components/ToolButton";
/** if false, the action should be prevented */
@@ -12,22 +16,18 @@ export type ActionResult =
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
commitToHistory: boolean;
syncHistory?: boolean;
replaceFiles?: boolean;
}
| false;
type AppAPI = {
canvas: HTMLCanvasElement | null;
focusContainer(): void;
library: Library;
};
type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: AppAPI,
app: AppClassProperties,
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
+85 -68
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
EXPORT_SCALES,
THEME,
} from "./constants";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
@@ -18,7 +19,7 @@ export const getDefaultAppState = (): Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
> => {
return {
theme: "light",
theme: THEME.LIGHT,
collaborators: new Map(),
currentChartType: "bar",
currentItemBackgroundColor: "transparent",
@@ -78,6 +79,7 @@ export const getDefaultAppState = (): Omit<
zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
pendingImageElement: null,
};
};
@@ -91,78 +93,87 @@ const APP_STATE_STORAGE_CONF = (<
browser: boolean;
/** whether to keep when exporting to file/database */
export: boolean;
/** server (shareLink/collab/...) */
server: boolean;
},
T extends Record<keyof AppState, Values>
>(
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
) => config)({
theme: { browser: true, export: false },
collaborators: { browser: false, export: false },
currentChartType: { browser: true, export: false },
currentItemBackgroundColor: { browser: true, export: false },
currentItemEndArrowhead: { browser: true, export: false },
currentItemFillStyle: { browser: true, export: false },
currentItemFontFamily: { browser: true, export: false },
currentItemFontSize: { browser: true, export: false },
currentItemLinearStrokeSharpness: { browser: true, export: false },
currentItemOpacity: { browser: true, export: false },
currentItemRoughness: { browser: true, export: false },
currentItemStartArrowhead: { browser: true, export: false },
currentItemStrokeColor: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false },
currentItemStrokeStyle: { browser: true, export: false },
currentItemStrokeWidth: { browser: true, export: false },
currentItemTextAlign: { browser: true, export: false },
cursorButton: { browser: true, export: false },
draggingElement: { browser: false, export: false },
editingElement: { browser: false, export: false },
editingGroupId: { browser: true, export: false },
editingLinearElement: { browser: false, export: false },
elementLocked: { browser: true, export: false },
elementType: { browser: true, export: false },
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 },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
isLibraryOpen: { browser: false, export: false },
isLoading: { browser: false, export: false },
isResizing: { browser: false, export: false },
isRotating: { browser: false, export: false },
lastPointerDownWith: { browser: true, export: false },
multiElement: { browser: false, export: false },
name: { browser: true, export: false },
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 },
scrolledOutside: { browser: true, export: false },
scrollX: { browser: true, export: false },
scrollY: { browser: true, export: false },
selectedElementIds: { browser: true, export: false },
selectedGroupIds: { browser: true, export: false },
selectionElement: { browser: false, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false },
showHelpDialog: { browser: false, export: false },
showStats: { browser: true, export: false },
startBoundElement: { browser: false, export: false },
suggestedBindings: { browser: false, export: false },
toastMessage: { browser: false, export: false },
viewBackgroundColor: { browser: true, export: true },
width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false },
zoom: { browser: true, export: false },
viewModeEnabled: { browser: false, export: false },
theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false },
currentItemBackgroundColor: { browser: true, export: false, server: false },
currentItemEndArrowhead: { browser: true, export: false, server: false },
currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false },
currentItemLinearStrokeSharpness: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
elementLocked: { browser: true, export: false, server: false },
elementType: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false },
exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false },
exportScale: { browser: true, export: false, server: false },
exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
isLibraryOpen: { browser: false, export: false, server: false },
isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false },
lastPointerDownWith: { browser: true, export: false, server: false },
multiElement: { browser: false, export: false, server: false },
name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false },
pasteDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false },
scrolledOutside: { browser: true, export: false, server: false },
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showHelpDialog: { browser: false, export: false, server: false },
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
toastMessage: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false },
zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElement: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
const _clearAppStateForStorage = <
ExportType extends "export" | "browser" | "server"
>(
appState: Partial<AppState>,
exportType: ExportType,
) => {
@@ -175,8 +186,10 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
const propConfig = APP_STATE_STORAGE_CONF[key];
if (propConfig?.[exportType]) {
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
stateForExport[key] = appState[key];
const nextValue = appState[key];
// https://github.com/microsoft/TypeScript/issues/31445
(stateForExport as any)[key] = nextValue;
}
}
return stateForExport;
@@ -189,3 +202,7 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "export");
};
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "server");
};
+20 -6
View File
@@ -3,19 +3,22 @@ import {
NonDeletedExcalidrawElement,
} from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState } from "./types";
import { AppState, BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES } from "./constants";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: ExcalidrawElement[];
files: BinaryFiles | undefined;
};
export interface ClipboardData {
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
files?: BinaryFiles;
text?: string;
errorMessage?: string;
}
@@ -37,7 +40,7 @@ export const probablySupportsClipboardBlob =
const clipboardContainsElements = (
contents: any,
): contents is { elements: ExcalidrawElement[] } => {
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
if (
[
EXPORT_DATA_TYPES.excalidraw,
@@ -53,10 +56,18 @@ const clipboardContainsElements = (
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const selectedElements = getSelectedElements(elements, appState);
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: getSelectedElements(elements, appState),
elements: selectedElements,
files: selectedElements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles),
};
const json = JSON.stringify(contents);
CLIPBOARD = json;
@@ -138,7 +149,10 @@ export const parseClipboard = async (
try {
const systemClipboardData = JSON.parse(systemClipboard);
if (clipboardContainsElements(systemClipboardData)) {
return { elements: systemClipboardData.elements };
return {
elements: systemClipboardData.elements,
files: systemClipboardData.files,
};
}
return appClipboardData;
} catch {
@@ -153,7 +167,7 @@ export const parseClipboard = async (
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }),
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
]);
};
+26 -8
View File
@@ -1,7 +1,7 @@
import React from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import {
@@ -18,6 +18,7 @@ import { AppState, Zoom } from "../types";
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
export const SelectedShapeActions = ({
appState,
@@ -48,9 +49,22 @@ export const SelectedShapeActions = ({
hasBackground(elementType) ||
targetElements.some((element) => hasBackground(element.type));
let commonSelectedType: string | null = targetElements[0]?.type || null;
for (const element of targetElements) {
if (element.type !== commonSelectedType) {
commonSelectedType = null;
break;
}
}
return (
<div className="panelColumn">
{renderAction("changeStrokeColor")}
{((hasStrokeColor(elementType) &&
elementType !== "image" &&
commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")}
@@ -155,18 +169,20 @@ export const ShapesSwitcher = ({
canvas,
elementType,
setAppState,
onImageAction,
}: {
canvas: HTMLCanvasElement | null;
elementType: ExcalidrawElement["type"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
const label = t(`toolBar.${value}`);
const letter = typeof key === "string" ? key : key[0];
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
index + 1
}`;
const letter = key && (typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
: `${index + 1}`;
return (
<ToolButton
className="Shape"
@@ -180,14 +196,16 @@ export const ShapesSwitcher = ({
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={value}
onChange={() => {
onChange={({ pointerType }) => {
setAppState({
elementType: value,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, value);
setAppState({});
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import Stack from "../components/Stack";
import { ToolButton } from "../components/ToolButton";
import { save, file } from "../components/icons";
+673 -58
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,4 +1,3 @@
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,4 +1,3 @@
import React from "react";
import clsx from "clsx";
export const ButtonSelect = <T extends Object>({
+4
View File
@@ -48,6 +48,10 @@
.ToolIcon__label {
color: $oc-white;
}
.Spinner {
--spinner-color: #fff;
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
@import "../css/variables.module";
.excalidraw {
.clear-canvas {
&-buttons {
display: flex;
padding: 0.2rem 0;
justify-content: flex-end;
.ToolIcon__icon {
min-width: 2.5rem;
width: auto;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 1.5rem;
padding: 0 0.5rem;
}
}
&__content {
font-size: 1rem;
}
&--confirm.ToolIcon_type_button {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-8;
}
.ToolIcon__icon {
color: $oc-white;
}
}
&--cancel.ToolIcon_type_button {
background-color: $oc-gray-2;
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from "react";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { Dialog } from "./Dialog";
import { trash } from "./icons";
import { ToolButton } from "./ToolButton";
import "./ClearCanvas.scss";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false);
const toggleDialog = () => {
setShowDialog(!showDialog);
};
return (
<>
<ToolButton
type="button"
icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useIsMobile()}
onClick={toggleDialog}
data-testid="clear-canvas-button"
/>
{showDialog && (
<Dialog
onCloseRequest={toggleDialog}
title={t("clearCanvasDialog.title")}
className="clear-canvas"
small={true}
>
<>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
<div className="clear-canvas-buttons">
<ToolButton
type="button"
title={t("buttons.clear")}
aria-label={t("buttons.clear")}
label={t("buttons.clear")}
onClick={() => {
onConfirm();
toggleDialog();
}}
data-testid="confirm-clear-canvas-button"
className="clear-canvas--confirm"
/>
<ToolButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")}
onClick={toggleDialog}
data-testid="cancel-clear-canvas-button"
className="clear-canvas--cancel"
/>
</div>
</>
</Dialog>
)}
</>
);
};
export default ClearCanvas;
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n";
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx";
import { Popover } from "./Popover";
+8 -7
View File
@@ -1,16 +1,15 @@
import "./ToolIcon.scss";
import React from "react";
import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
export type Appearence = "light" | "dark";
import { THEME } from "../constants";
import { Theme } from "../element/types";
// 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: Appearence;
onChange: (value: Appearence) => void;
value: Theme;
onChange: (value: Theme) => void;
title?: string;
}) => {
const title =
@@ -20,10 +19,12 @@ export const DarkModeToggle = (props: {
return (
<ToolButton
type="icon"
icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
title={title}
aria-label={title}
onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
onClick={() =>
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
}
data-testid="toggle-dark-mode"
/>
);
+2
View File
@@ -157,6 +157,8 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { questionCircle } from "../components/icons";
type HelpIconProps = {
+12 -3
View File
@@ -1,11 +1,14 @@
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 {
isImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
import { getShortcutKey } from "../utils";
interface Hint {
@@ -31,6 +34,10 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.text");
}
if (appState.elementType === "image" && appState.pendingImageElement) {
return t("hints.placeImage");
}
const selectedElements = getSelectedElements(elements, appState);
if (
isResizing &&
@@ -41,7 +48,9 @@ const getHints = ({ appState, elements }: Hint) => {
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
return t("hints.lockAngle");
}
return t("hints.resize");
return isImageElement(targetElement)
? t("hints.resizeImage")
: t("hints.resize");
}
if (isRotating && lastPointerDownWith === "mouse") {
+21 -20
View File
@@ -9,7 +9,7 @@ import { t } from "../i18n";
import { useIsMobile } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { AppState } from "../types";
import { AppState, BinaryFiles } from "../types";
import { Dialog } from "./Dialog";
import { clipboard, exportImage } from "./icons";
import Stack from "./Stack";
@@ -79,6 +79,7 @@ const ExportButton: React.FC<{
const ImageExportModal = ({
elements,
appState,
files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager,
onExportToPng,
@@ -87,6 +88,7 @@ const ImageExportModal = ({
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
@@ -112,29 +114,25 @@ const ImageExportModal = ({
if (!previewNode) {
return;
}
try {
const canvas = exportToCanvas(exportedElements, appState, {
exportBackground,
viewBackgroundColor,
exportPadding,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
canvasToBlob(canvas)
.then(() => {
exportToCanvas(exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
})
.then((canvas) => {
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas).then(() => {
renderPreview(canvas, previewNode);
})
.catch((error) => {
console.error(error);
renderPreview(new CanvasError(), previewNode);
});
} catch (error) {
console.error(error);
renderPreview(new CanvasError(), previewNode);
}
})
.catch((error) => {
console.error(error);
renderPreview(new CanvasError(), previewNode);
});
}, [
appState,
files,
exportedElements,
exportBackground,
exportPadding,
@@ -220,6 +218,7 @@ const ImageExportModal = ({
export const ImageExportDialog = ({
elements,
appState,
files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager,
onExportToPng,
@@ -228,6 +227,7 @@ export const ImageExportDialog = ({
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
@@ -258,6 +258,7 @@ export const ImageExportDialog = ({
<ImageExportModal
elements={elements}
appState={appState}
files={files}
exportPadding={exportPadding}
actionManager={actionManager}
onExportToPng={onExportToPng}
+15 -20
View File
@@ -1,30 +1,25 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { LoadingMessage } from "./LoadingMessage";
import { defaultLang, Language, languages, setLanguage } from "../i18n";
interface Props {
langCode: Language["code"];
children: React.ReactElement;
}
interface State {
isLoading: boolean;
}
export class InitializeApp extends React.Component<Props, State> {
public state: { isLoading: boolean } = {
isLoading: true,
};
async componentDidMount() {
export const InitializeApp = (props: Props) => {
const [loading, setLoading] = useState(true);
useEffect(() => {
const updateLang = async () => {
await setLanguage(currentLang);
};
const currentLang =
languages.find((lang) => lang.code === this.props.langCode) ||
defaultLang;
await setLanguage(currentLang);
this.setState({
isLoading: false,
});
}
languages.find((lang) => lang.code === props.langCode) || defaultLang;
updateLang();
setLoading(false);
}, [props.langCode]);
public render() {
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
}
}
return loading ? <LoadingMessage /> : props.children;
};
+11 -4
View File
@@ -3,7 +3,7 @@ import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { AppState, ExportOpts } from "../types";
import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
import { ToolButton } from "./ToolButton";
@@ -21,11 +21,13 @@ export type ExportCB = (
const JSONExportModal = ({
elements,
appState,
files,
actionManager,
exportOpts,
canvas,
}: {
appState: AppState;
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onCloseRequest: () => void;
@@ -68,12 +70,14 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() => onExportToBackend(elements, appState, canvas)}
onClick={() =>
onExportToBackend(elements, appState, files, canvas)
}
/>
</Card>
)}
{exportOpts.renderCustomUI &&
exportOpts.renderCustomUI(elements, appState, canvas)}
exportOpts.renderCustomUI(elements, appState, files, canvas)}
</div>
</div>
);
@@ -82,12 +86,14 @@ const JSONExportModal = ({
export const JSONExportDialog = ({
elements,
appState,
files,
actionManager,
exportOpts,
canvas,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
actionManager: ActionsManagerInterface;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
@@ -116,6 +122,7 @@ export const JSONExportDialog = ({
<JSONExportModal
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
onCloseRequest={handleClose}
exportOpts={exportOpts}
+45 -8
View File
@@ -20,6 +20,7 @@ import {
AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
LibraryItem,
LibraryItems,
} from "../types";
@@ -53,6 +54,7 @@ import { isImageFileHandle } from "../data/blob";
interface LayerUIProps {
actionManager: ActionManager;
appState: AppState;
files: BinaryFiles;
canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
@@ -65,7 +67,10 @@ interface LayerUIProps {
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderTopRightUI?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -73,6 +78,7 @@ interface LayerUIProps {
focusContainer: () => void;
library: Library;
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
}
const useOnClickOutside = (
@@ -115,6 +121,7 @@ const LibraryMenuItems = ({
libraryReturnUrl,
focusContainer,
library,
files,
id,
}: {
libraryItems: LibraryItems;
@@ -123,6 +130,7 @@ const LibraryMenuItems = ({
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -218,6 +226,7 @@ const LibraryMenuItems = ({
<Stack.Col key={x}>
<LibraryUnit
elements={libraryItems[y + x]}
files={files}
pendingElements={
shouldAddPendingElements ? pendingElements : undefined
}
@@ -252,6 +261,7 @@ const LibraryMenu = ({
onAddToLibrary,
theme,
setAppState,
files,
libraryReturnUrl,
focusContainer,
library,
@@ -262,6 +272,7 @@ const LibraryMenu = ({
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
@@ -283,12 +294,12 @@ const LibraryMenu = ({
"preloading" | "loading" | "ready"
>("preloading");
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = setTimeout(() => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
@@ -321,6 +332,12 @@ const LibraryMenu = ({
const addToLibrary = useCallback(
async (elements: LibraryItem) => {
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
@@ -352,6 +369,7 @@ const LibraryMenu = ({
focusContainer={focusContainer}
library={library}
theme={theme}
files={files}
id={id}
/>
)}
@@ -362,6 +380,7 @@ const LibraryMenu = ({
const LayerUI = ({
actionManager,
appState,
files,
setAppState,
canvas,
elements,
@@ -381,6 +400,7 @@ const LayerUI = ({
focusContainer,
library,
id,
onImageAction,
}: LayerUIProps) => {
const isMobile = useIsMobile();
@@ -393,6 +413,7 @@ const LayerUI = ({
<JSONExportDialog
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
@@ -408,11 +429,17 @@ const LayerUI = ({
const createExporter = (type: ExportType): ExportCB => async (
exportedElements,
) => {
const fileHandle = await exportCanvas(type, exportedElements, appState, {
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
})
const fileHandle = await exportCanvas(
type,
exportedElements,
appState,
files,
{
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
},
)
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
@@ -432,6 +459,7 @@ const LayerUI = ({
<ImageExportDialog
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
onExportToPng={createExporter("png")}
onExportToSvg={createExporter("svg")}
@@ -465,6 +493,7 @@ const LayerUI = ({
</Section>
);
};
const renderCanvasActions = () => (
<Section
heading="canvasActions"
@@ -557,6 +586,7 @@ const LayerUI = ({
focusContainer={focusContainer}
library={library}
theme={appState.theme}
files={files}
id={id}
/>
) : null;
@@ -601,6 +631,11 @@ const LayerUI = ({
canvas={canvas}
elementType={appState.elementType}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/>
</Stack.Row>
</Island>
@@ -761,6 +796,8 @@ const LayerUI = ({
renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled}
showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
/>
</>
) : (
+2 -2
View File
@@ -26,7 +26,7 @@ export const LibraryButton: React.FC<{
"zen-mode-visibility--hidden": appState.zenModeEnabled,
},
)}
title={`${capitalizeString(t("toolBar.library"))}9`}
title={`${capitalizeString(t("toolBar.library"))}0`}
style={{ marginInlineStart: "var(--space-factor)" }}
>
<input
@@ -38,7 +38,7 @@ export const LibraryButton: React.FC<{
}}
checked={appState.isLibraryOpen}
aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="9"
aria-keyshortcuts="0"
/>
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
</label>
+20 -20
View File
@@ -1,12 +1,12 @@
import clsx from "clsx";
import oc from "open-color";
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { close } from "../components/icons";
import { MIME_TYPES } from "../constants";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { exportToSvg } from "../scene/export";
import { LibraryItem } from "../types";
import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss";
// fa-plus
@@ -21,44 +21,44 @@ const PLUS_ICON = (
export const LibraryUnit = ({
elements,
files,
pendingElements,
onRemoveFromLibrary,
onClick,
}: {
elements?: LibraryItem;
files: BinaryFiles;
pendingElements?: LibraryItem;
onRemoveFromLibrary: () => void;
onClick: () => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const elementsToRender = elements || pendingElements;
if (!elementsToRender) {
const node = ref.current;
if (!node) {
return;
}
let svg: SVGSVGElement;
const current = ref.current!;
(async () => {
svg = await exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: oc.white,
});
for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
continue;
}
current!.removeChild(child);
const elementsToRender = elements || pendingElements;
if (!elementsToRender) {
return;
}
current!.appendChild(svg);
const svg = await exportToSvg(
elementsToRender,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
files,
);
node.innerHTML = svg.outerHTML;
})();
return () => {
if (svg) {
current.removeChild(svg);
}
node.innerHTML = "";
};
}, [elements, pendingElements]);
}, [elements, pendingElements, files]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile();
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { t } from "../i18n";
export const LoadingMessage = () => {
+13
View File
@@ -33,6 +33,11 @@ type MobileMenuProps = {
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean;
showThemeBtn: boolean;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
};
export const MobileMenu = ({
@@ -50,6 +55,8 @@ export const MobileMenu = ({
renderCustomFooter,
viewModeEnabled,
showThemeBtn,
onImageAction,
renderTopRightUI,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
@@ -65,9 +72,15 @@ export const MobileMenu = ({
canvas={canvas}
elementType={appState.elementType}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/>
</Stack.Row>
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<LockButton
checked={appState.elementLocked}
onChange={onLockToggle}
+2 -1
View File
@@ -6,6 +6,7 @@ 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;
@@ -15,7 +16,7 @@ export const Modal = (props: {
labelledBy: string;
theme?: AppState["theme"];
}) => {
const { theme = "light" } = props;
const { theme = THEME.LIGHT } = props;
const modalRoot = useBodyRoot(theme);
if (!modalRoot) {
+8 -4
View File
@@ -38,10 +38,14 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!;
(async () => {
svg = await exportToSvg(elements, {
exportBackground: false,
viewBackgroundColor: oc.white,
});
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
null, // files
);
previewNode.appendChild(svg);
+48
View File
@@ -0,0 +1,48 @@
@import "open-color/open-color.scss";
$duration: 1.6s;
.excalidraw {
.Spinner {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin-left: auto;
margin-right: auto;
--spinner-color: var(--icon-fill-color);
svg {
animation: rotate $duration linear infinite;
transform-origin: center center;
}
circle {
stroke: var(--spinner-color);
animation: dash $duration linear 0s infinite;
stroke-linecap: round;
}
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 300;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 150, 300;
stroke-dashoffset: -200;
}
100% {
stroke-dasharray: 1, 300;
stroke-dashoffset: -280;
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import React from "react";
import "./Spinner.scss";
const Spinner = ({
size = "1em",
circleWidth = 8,
}: {
size?: string | number;
circleWidth?: number;
}) => {
return (
<div className="Spinner">
<svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
<circle
cx="50"
cy="50"
r={50 - circleWidth / 2}
strokeWidth={circleWidth}
fill="none"
strokeMiterlimit="10"
/>
</svg>
</div>
);
};
export default Spinner;
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { TOAST_TIMEOUT } from "../constants";
import "./Toast.scss";
+58 -7
View File
@@ -1,8 +1,11 @@
import "./ToolIcon.scss";
import React from "react";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
import { AbortError } from "../errors";
import Spinner from "./Spinner";
import { PointerType } from "../element/types";
export type ToolButtonSize = "small" | "medium";
@@ -28,7 +31,7 @@ type ToolButtonProps =
| (ToolButtonBaseProps & {
type: "button";
children?: React.ReactNode;
onClick?(): void;
onClick?(event: React.MouseEvent): void;
})
| (ToolButtonBaseProps & {
type: "icon";
@@ -38,7 +41,7 @@ type ToolButtonProps =
| (ToolButtonBaseProps & {
type: "radio";
checked: boolean;
onChange?(): void;
onChange?(data: { pointerType: PointerType | null }): void;
});
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
@@ -47,6 +50,38 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${props.size}`;
const [isLoading, setIsLoading] = useState(false);
const isMountedRef = useRef(true);
const onClick = async (event: React.MouseEvent) => {
const ret = "onClick" in props && props.onClick?.(event);
if (ret && "then" in ret) {
try {
setIsLoading(true);
await ret;
} catch (error) {
if (!(error instanceof AbortError)) {
throw error;
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}
};
useEffect(
() => () => {
isMountedRef.current = false;
},
[],
);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (props.type === "button" || props.type === "icon") {
return (
<button
@@ -68,8 +103,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
title={props.title}
aria-label={props["aria-label"]}
type="button"
onClick={props.onClick}
onClick={onClick}
ref={innerRef}
disabled={isLoading}
>
{(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true">
@@ -82,7 +118,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">{props["aria-label"]}</div>
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
@@ -90,7 +128,18 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
}
return (
<label className={clsx("ToolIcon", props.className)} title={props.title}>
<label
className={clsx("ToolIcon", props.className)}
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
}}
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
<input
className={`ToolIcon_type_radio ${sizeCn}`}
type="radio"
@@ -99,7 +148,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
onChange={props.onChange}
onChange={() => {
props.onChange?.({ pointerType: lastPointerTypeRef.current });
}}
checked={props.checked}
ref={innerRef}
/>
+6
View File
@@ -54,10 +54,16 @@
}
.ToolIcon__label {
display: flex;
align-items: center;
color: var(--icon-fill-color);
font-family: var(--ui-font);
margin: 0 0.8em;
text-overflow: ellipsis;
.Spinner {
margin-left: 0.6em;
}
}
.ToolIcon_size_small .ToolIcon__icon {
+400 -426
View File
@@ -10,13 +10,15 @@ 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: "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";
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";
type Opts = {
width?: number;
@@ -175,88 +177,84 @@ export const resetZoom = createIcon(
{ width: 1024 },
);
export const BringForwardIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
d="M22 9.556C22 8.696 21.303 8 20.444 8H16v8H8v4.444C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<path
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
export const BringForwardIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
d="M22 9.556C22 8.696 21.303 8 20.444 8H16v8H8v4.444C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<path
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
);
export const SendBackwardIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
<path
d="M22 9.556C22 8.696 21.303 8 20.444 8H9.556C8.696 8 8 8.697 8 9.556v10.888C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
export const SendBackwardIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
<path
d="M22 9.556C22 8.696 21.303 8 20.444 8H9.556C8.696 8 8 8.697 8 9.556v10.888C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
);
export const BringToFrontIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
d="M13 21a1 1 0 001 1h7a1 1 0 001-1v-7a1 1 0 00-1-1h-3v5h-5v3zM11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h3V6h5V3z"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<path
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
export const BringToFrontIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
d="M13 21a1 1 0 001 1h7a1 1 0 001-1v-7a1 1 0 00-1-1h-3v5h-5v3zM11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h3V6h5V3z"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<path
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
);
export const SendToBackIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
<path
d="M11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h8V3zM22 14a1 1 0 00-1-1h-7a1 1 0 00-1 1v7a1 1 0 001 1h8v-8z"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
export const SendToBackIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
<path
d="M11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h8V3zM22 14a1 1 0 00-1-1h-7a1 1 0 00-1 1v7a1 1 0 001 1h8v-8z"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
);
//
@@ -265,96 +263,92 @@ export const SendToBackIcon = React.memo(
// 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: "light" | "dark" }) =>
createIcon(
<>
<path
d="M 2,5 H 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M 6,7 C 5.446,7 5,7.446 5,8 v 9.999992 c 0,0.554 0.446,1 1,1 h 3.0000001 c 0.554,0 0.9999999,-0.446 0.9999999,-1 V 8 C 10,7.446 9.5540001,7 9.0000001,7 Z m 9,0 c -0.554,0 -1,0.446 -1,1 v 5.999992 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 V 8 C 19,7.446 18.554,7 18,7 Z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
export const AlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
d="M 2,5 H 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M 6,7 C 5.446,7 5,7.446 5,8 v 9.999992 c 0,0.554 0.446,1 1,1 h 3.0000001 c 0.554,0 0.9999999,-0.446 0.9999999,-1 V 8 C 10,7.446 9.5540001,7 9.0000001,7 Z m 9,0 c -0.554,0 -1,0.446 -1,1 v 5.999992 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 V 8 C 19,7.446 18.554,7 18,7 Z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
);
export const AlignBottomIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
d="M 2,19 H 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="m 6,16.999992 c -0.554,0 -1,-0.446 -1,-1 V 6 C 5,5.446 5.446,5 6,5 H 9.0000001 C 9.5540001,5 10,5.446 10,6 v 9.999992 c 0,0.554 -0.4459999,1 -0.9999999,1 z m 9,0 c -0.554,0 -1,-0.446 -1,-1 V 10 c 0,-0.554 0.446,-1 1,-1 h 3 c 0.554,0 1,0.446 1,1 v 5.999992 c 0,0.554 -0.446,1 -1,1 z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
export const AlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
d="M 2,19 H 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="m 6,16.999992 c -0.554,0 -1,-0.446 -1,-1 V 6 C 5,5.446 5.446,5 6,5 H 9.0000001 C 9.5540001,5 10,5.446 10,6 v 9.999992 c 0,0.554 -0.4459999,1 -0.9999999,1 z m 9,0 c -0.554,0 -1,-0.446 -1,-1 V 10 c 0,-0.554 0.446,-1 1,-1 h 3 c 0.554,0 1,0.446 1,1 v 5.999992 c 0,0.554 -0.446,1 -1,1 z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24, mirror: true },
),
);
export const AlignLeftIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
d="M 5,2 V 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="m 7.000004,5.999996 c 0,-0.554 0.446,-1 1,-1 h 9.999992 c 0.554,0 1,0.446 1,1 v 3.0000001 c 0,0.554 -0.446,0.9999999 -1,0.9999999 H 8.000004 c -0.554,0 -1,-0.4459999 -1,-0.9999999 z m 0,9 c 0,-0.554 0.446,-1 1,-1 h 5.999992 c 0.554,0 1,0.446 1,1 v 3 c 0,0.554 -0.446,1 -1,1 H 8.000004 c -0.554,0 -1,-0.446 -1,-1 z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24 },
),
export const AlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
d="M 5,2 V 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="m 7.000004,5.999996 c 0,-0.554 0.446,-1 1,-1 h 9.999992 c 0.554,0 1,0.446 1,1 v 3.0000001 c 0,0.554 -0.446,0.9999999 -1,0.9999999 H 8.000004 c -0.554,0 -1,-0.4459999 -1,-0.9999999 z m 0,9 c 0,-0.554 0.446,-1 1,-1 h 5.999992 c 0.554,0 1,0.446 1,1 v 3 c 0,0.554 -0.446,1 -1,1 H 8.000004 c -0.554,0 -1,-0.446 -1,-1 z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24 },
),
);
export const AlignRightIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
d="M 19,2 V 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="m 16.999996,5.999996 c 0,-0.554 -0.446,-1 -1,-1 H 6.000004 c -0.554,0 -1,0.446 -1,1 v 3.0000001 c 0,0.554 0.446,0.9999999 1,0.9999999 h 9.999992 c 0.554,0 1,-0.4459999 1,-0.9999999 z m 0,9 c 0,-0.554 -0.446,-1 -1,-1 h -5.999992 c -0.554,0 -1,0.446 -1,1 v 3 c 0,0.554 0.446,1 1,1 h 5.999992 c 0.554,0 1,-0.446 1,-1 z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24 },
),
export const AlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
d="M 19,2 V 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="m 16.999996,5.999996 c 0,-0.554 -0.446,-1 -1,-1 H 6.000004 c -0.554,0 -1,0.446 -1,1 v 3.0000001 c 0,0.554 0.446,0.9999999 1,0.9999999 h 9.999992 c 0.554,0 1,-0.4459999 1,-0.9999999 z m 0,9 c 0,-0.554 -0.446,-1 -1,-1 h -5.999992 c -0.554,0 -1,0.446 -1,1 v 3 c 0,0.554 0.446,1 1,1 h 5.999992 c 0.554,0 1,-0.446 1,-1 z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
</>,
{ width: 24 },
),
);
export const DistributeHorizontallyIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
@@ -375,7 +369,7 @@ export const DistributeHorizontallyIcon = React.memo(
);
export const DistributeVerticallyIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
@@ -396,31 +390,30 @@ export const DistributeVerticallyIcon = React.memo(
),
);
export const CenterVerticallyIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
d="m 5.000004,16.999996 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -10 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z m 9,-2 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -6 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
<path
d="M 2,12 H 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeDasharray="1, 2.8"
strokeLinecap="round"
/>
</>,
{ width: 24, mirror: true },
),
export const CenterVerticallyIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
d="m 5.000004,16.999996 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -10 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z m 9,-2 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -6 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z"
fill={activeElementColor(theme)}
stroke={activeElementColor(theme)}
strokeWidth="2"
/>
<path
d="M 2,12 H 22"
fill={iconFillColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="2"
strokeDasharray="1, 2.8"
strokeLinecap="round"
/>
</>,
{ width: 24, mirror: true },
),
);
export const CenterHorizontallyIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
({ theme }: { theme: Theme }) =>
createIcon(
<>
<path
@@ -482,7 +475,7 @@ export const file = createIcon(
{ width: 384, height: 512 },
);
export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
export const GroupIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
@@ -512,73 +505,69 @@ export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
),
);
export const UngroupIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
<path
d="M25 111C25 80.2068 25 49.4135 25 26M25 26C48.6174 26 72.2348 26 111 26H25ZM25 26C53.3671 26 81.7343 26 111 26H25ZM111 26C111 52.303 111 78.606 111 111V26ZM111 26C111 51.2947 111 76.5893 111 111V26ZM111 111C87.0792 111 63.1585 111 25 111H111ZM111 111C87.4646 111 63.9293 111 25 111H111ZM25 111C25 81.1514 25 51.3028 25 26V111Z"
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<path d="M100 100H160V160H100" fill={iconFillColor(theme)} />
<path
d="M100 160C100 144.106 100 128.211 100 100M100 100C117.706 100 135.412 100 160 100H100ZM100 100C114.214 100 128.428 100 160 100H100ZM160 100C160 120.184 160 140.369 160 160V100ZM160 100C160 113.219 160 126.437 160 160V100ZM160 160C145.534 160 131.068 160 100 160H160ZM160 160C143.467 160 126.934 160 100 160H160ZM100 160C100 143.661 100 127.321 100 100V160Z"
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<g
fill={handlerColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="6"
>
<rect x="2.5" y="2.5" width="30" height="30" />
<rect x="78.5" y="149.5" width="30" height="30" />
<rect x="147.5" y="149.5" width="30" height="30" />
<rect x="147.5" y="78.5" width="30" height="30" />
<rect x="105.5" y="2.5" width="30" height="30" />
<rect x="2.5" y="102.5" width="30" height="30" />
</g>
</>,
{ width: 182, height: 182, mirror: true },
),
);
export const FillHachureIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<>
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
<path
fillRule="evenodd"
clipRule="evenodd"
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"
fill={iconFillColor(theme)}
/>,
{ width: 40, height: 20 },
),
d="M25 111C25 80.2068 25 49.4135 25 26M25 26C48.6174 26 72.2348 26 111 26H25ZM25 26C53.3671 26 81.7343 26 111 26H25ZM111 26C111 52.303 111 78.606 111 111V26ZM111 26C111 51.2947 111 76.5893 111 111V26ZM111 111C87.0792 111 63.1585 111 25 111H111ZM111 111C87.4646 111 63.9293 111 25 111H111ZM25 111C25 81.1514 25 51.3028 25 26V111Z"
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<path d="M100 100H160V160H100" fill={iconFillColor(theme)} />
<path
d="M100 160C100 144.106 100 128.211 100 100M100 100C117.706 100 135.412 100 160 100H100ZM100 100C114.214 100 128.428 100 160 100H100ZM160 100C160 120.184 160 140.369 160 160V100ZM160 100C160 113.219 160 126.437 160 160V100ZM160 160C145.534 160 131.068 160 100 160H160ZM160 160C143.467 160 126.934 160 100 160H160ZM100 160C100 143.661 100 127.321 100 100V160Z"
stroke={iconFillColor(theme)}
strokeWidth="2"
/>
<g
fill={handlerColor(theme)}
stroke={iconFillColor(theme)}
strokeWidth="6"
>
<rect x="2.5" y="2.5" width="30" height="30" />
<rect x="78.5" y="149.5" width="30" height="30" />
<rect x="147.5" y="149.5" width="30" height="30" />
<rect x="147.5" y="78.5" width="30" height="30" />
<rect x="105.5" y="2.5" width="30" height="30" />
<rect x="2.5" y="102.5" width="30" height="30" />
</g>
</>,
{ width: 182, height: 182, mirror: true },
),
);
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" />
<path d="M14.0001 18L3.00006 4.00002L4.5727 2.76438L15.5727 16.7644L14.0001 18ZM25.0001 18L14.0001 4.00002L15.5727 2.76438L26.5727 16.7644L25.0001 18ZM36.0001 18L25.0001 4.00002L26.5727 2.76438L37.5727 16.7644L36.0001 18Z" />
</g>,
{ width: 40, height: 20 },
),
export const FillHachureIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
fillRule="evenodd"
clipRule="evenodd"
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"
fill={iconFillColor(theme)}
/>,
{ width: 40, height: 20 },
),
);
export const FillSolidIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(theme)} />, {
width: 40,
height: 20,
}),
export const FillCrossHatchIcon = React.memo(({ theme }: { theme: Theme }) =>
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" />
<path d="M14.0001 18L3.00006 4.00002L4.5727 2.76438L15.5727 16.7644L14.0001 18ZM25.0001 18L14.0001 4.00002L15.5727 2.76438L26.5727 16.7644L25.0001 18ZM36.0001 18L25.0001 4.00002L26.5727 2.76438L37.5727 16.7644L36.0001 18Z" />
</g>,
{ width: 40, height: 20 },
),
);
export const FillSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(theme)} />, {
width: 40,
height: 20,
}),
);
export const StrokeWidthIcon = React.memo(
({ theme, strokeWidth }: { theme: "light" | "dark"; strokeWidth: number }) =>
({ theme, strokeWidth }: { theme: Theme; strokeWidth: number }) =>
createIcon(
<path
d="M6 10H32"
@@ -591,55 +580,52 @@ export const StrokeWidthIcon = React.memo(
),
);
export const StrokeStyleSolidIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H34"
stroke={iconFillColor(theme)}
strokeWidth={2}
fill="none"
strokeLinecap="round"
/>,
{
width: 40,
height: 20,
},
),
export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M6 10H34"
stroke={iconFillColor(theme)}
strokeWidth={2}
fill="none"
strokeLinecap="round"
/>,
{
width: 40,
height: 20,
},
),
);
export const StrokeStyleDashedIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H34"
stroke={iconFillColor(theme)}
strokeWidth={2.5}
strokeDasharray={"10, 8"}
fill="none"
strokeLinecap="round"
/>,
{ width: 40, height: 20 },
),
export const StrokeStyleDashedIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M6 10H34"
stroke={iconFillColor(theme)}
strokeWidth={2.5}
strokeDasharray={"10, 8"}
fill="none"
strokeLinecap="round"
/>,
{ width: 40, height: 20 },
),
);
export const StrokeStyleDottedIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H36"
stroke={iconFillColor(theme)}
strokeWidth={2.5}
strokeDasharray={"2, 4.5"}
fill="none"
strokeLinecap="round"
/>,
{ width: 40, height: 20 },
),
export const StrokeStyleDottedIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M6 10H36"
stroke={iconFillColor(theme)}
strokeWidth={2.5}
strokeDasharray={"2, 4.5"}
fill="none"
strokeLinecap="round"
/>,
{ width: 40, height: 20 },
),
);
export const SloppinessArchitectIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
({ theme }: { theme: Theme }) =>
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"
@@ -652,22 +638,21 @@ export const SloppinessArchitectIcon = React.memo(
),
);
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"
stroke={iconFillColor(theme)}
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>,
{ width: 40, height: 20, mirror: true },
),
export const SloppinessArtistIcon = React.memo(({ theme }: { theme: Theme }) =>
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"
stroke={iconFillColor(theme)}
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>,
{ width: 40, height: 20, mirror: true },
),
);
export const SloppinessCartoonistIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
({ theme }: { theme: Theme }) =>
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"
@@ -680,52 +665,49 @@ export const SloppinessCartoonistIcon = React.memo(
),
);
export const EdgeSharpIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M10 17L10 5L35 5"
stroke={iconFillColor(theme)}
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>,
{ width: 40, height: 20, mirror: true },
),
export const EdgeSharpIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M10 17L10 5L35 5"
stroke={iconFillColor(theme)}
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>,
{ width: 40, height: 20, mirror: true },
),
);
export const EdgeRoundIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M10 17V15C10 8 13 5 21 5L33.5 5"
stroke={iconFillColor(theme)}
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>,
{ width: 40, height: 20, mirror: true },
),
export const EdgeRoundIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M10 17V15C10 8 13 5 21 5L33.5 5"
stroke={iconFillColor(theme)}
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>,
{ width: 40, height: 20, mirror: true },
),
);
export const ArrowheadNoneIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H34"
stroke={iconFillColor(theme)}
strokeWidth={2}
fill="none"
/>,
{
width: 40,
height: 20,
},
),
export const ArrowheadNoneIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M6 10H34"
stroke={iconFillColor(theme)}
strokeWidth={2}
fill="none"
/>,
{
width: 40,
height: 20,
},
),
);
export const ArrowheadArrowIcon = React.memo(
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
@@ -741,7 +723,7 @@ export const ArrowheadArrowIcon = React.memo(
);
export const ArrowheadDotIcon = React.memo(
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
createIcon(
<g
stroke={iconFillColor(theme)}
@@ -756,7 +738,7 @@ export const ArrowheadDotIcon = React.memo(
);
export const ArrowheadBarIcon = React.memo(
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
createIcon(
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
<path
@@ -770,41 +752,38 @@ export const ArrowheadBarIcon = React.memo(
),
);
export const FontSizeSmallIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 0 69.092 L 0 55.03 A 124.24 124.24 0 0 0 4.706 57.02 Q 6.826 57.863 8.708 58.5 A 53.466 53.466 0 0 0 12.231 59.571 Q 17.236 60.889 21.387 60.889 A 20.909 20.909 0 0 0 24.265 60.704 Q 25.719 60.502 26.903 60.077 A 8.649 8.649 0 0 0 29.028 58.985 Q 31.689 57.08 31.689 53.321 Q 31.689 51.221 30.518 49.585 A 10.126 10.126 0 0 0 29.282 48.177 Q 28.352 47.287 27.075 46.436 A 23.719 23.719 0 0 0 25.752 45.627 Q 23.774 44.492 20.176 42.735 A 254.44 254.44 0 0 0 17.822 41.602 Q 11.503 38.631 8.236 35.888 A 19.742 19.742 0 0 1 8.008 35.694 A 22.18 22.18 0 0 1 2.783 29.102 Q 0.83 25.342 0.83 20.313 A 22.471 22.471 0 0 1 1.733 13.778 A 17.283 17.283 0 0 1 7.251 5.42 A 21.486 21.486 0 0 1 15.177 1.272 Q 18.361 0.338 22.166 0.09 A 43.573 43.573 0 0 1 25 0 A 42.399 42.399 0 0 1 34.349 1.01 A 39.075 39.075 0 0 1 35.62 1.319 A 67.407 67.407 0 0 1 42.108 3.382 A 83.357 83.357 0 0 1 46.191 5.03 L 41.309 16.797 Q 35.596 14.453 31.86 13.526 A 30.762 30.762 0 0 0 25.417 12.612 A 28.337 28.337 0 0 0 24.512 12.598 A 14.846 14.846 0 0 0 22.022 12.793 Q 19.498 13.224 17.92 14.6 Q 15.625 16.602 15.625 19.824 Q 15.625 21.826 16.553 23.316 Q 17.48 24.805 19.507 26.197 A 18.343 18.343 0 0 0 20.659 26.912 Q 22.596 28.035 26.516 29.953 A 299.99 299.99 0 0 0 29.102 31.201 Q 37.91 35.412 41.841 39.642 A 16.553 16.553 0 0 1 42.822 40.796 A 17.675 17.675 0 0 1 46.301 49.233 A 23.517 23.517 0 0 1 46.533 52.588 A 21.581 21.581 0 0 1 45.471 59.515 A 17.733 17.733 0 0 1 39.575 67.823 Q 33.745 72.486 24.094 73.243 A 49.683 49.683 0 0 1 20.215 73.389 A 51.712 51.712 0 0 1 9.448 72.315 A 40.672 40.672 0 0 1 0 69.092 Z"
/>,
{ width: 47, height: 77 },
),
export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 0 69.092 L 0 55.03 A 124.24 124.24 0 0 0 4.706 57.02 Q 6.826 57.863 8.708 58.5 A 53.466 53.466 0 0 0 12.231 59.571 Q 17.236 60.889 21.387 60.889 A 20.909 20.909 0 0 0 24.265 60.704 Q 25.719 60.502 26.903 60.077 A 8.649 8.649 0 0 0 29.028 58.985 Q 31.689 57.08 31.689 53.321 Q 31.689 51.221 30.518 49.585 A 10.126 10.126 0 0 0 29.282 48.177 Q 28.352 47.287 27.075 46.436 A 23.719 23.719 0 0 0 25.752 45.627 Q 23.774 44.492 20.176 42.735 A 254.44 254.44 0 0 0 17.822 41.602 Q 11.503 38.631 8.236 35.888 A 19.742 19.742 0 0 1 8.008 35.694 A 22.18 22.18 0 0 1 2.783 29.102 Q 0.83 25.342 0.83 20.313 A 22.471 22.471 0 0 1 1.733 13.778 A 17.283 17.283 0 0 1 7.251 5.42 A 21.486 21.486 0 0 1 15.177 1.272 Q 18.361 0.338 22.166 0.09 A 43.573 43.573 0 0 1 25 0 A 42.399 42.399 0 0 1 34.349 1.01 A 39.075 39.075 0 0 1 35.62 1.319 A 67.407 67.407 0 0 1 42.108 3.382 A 83.357 83.357 0 0 1 46.191 5.03 L 41.309 16.797 Q 35.596 14.453 31.86 13.526 A 30.762 30.762 0 0 0 25.417 12.612 A 28.337 28.337 0 0 0 24.512 12.598 A 14.846 14.846 0 0 0 22.022 12.793 Q 19.498 13.224 17.92 14.6 Q 15.625 16.602 15.625 19.824 Q 15.625 21.826 16.553 23.316 Q 17.48 24.805 19.507 26.197 A 18.343 18.343 0 0 0 20.659 26.912 Q 22.596 28.035 26.516 29.953 A 299.99 299.99 0 0 0 29.102 31.201 Q 37.91 35.412 41.841 39.642 A 16.553 16.553 0 0 1 42.822 40.796 A 17.675 17.675 0 0 1 46.301 49.233 A 23.517 23.517 0 0 1 46.533 52.588 A 21.581 21.581 0 0 1 45.471 59.515 A 17.733 17.733 0 0 1 39.575 67.823 Q 33.745 72.486 24.094 73.243 A 49.683 49.683 0 0 1 20.215 73.389 A 51.712 51.712 0 0 1 9.448 72.315 A 40.672 40.672 0 0 1 0 69.092 Z"
/>,
{ width: 47, height: 77 },
),
);
export const FontSizeMediumIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 44.092 71.387 L 30.225 71.387 L 13.037 15.381 L 12.598 15.381 A 1505.093 1505.093 0 0 1 12.959 22.313 Q 13.426 31.715 13.508 36.4 A 102.991 102.991 0 0 1 13.525 38.184 L 13.525 71.387 L 0 71.387 L 0 0 L 20.605 0 L 37.5 54.59 L 37.793 54.59 L 55.713 0 L 76.318 0 L 76.318 71.387 L 62.207 71.387 L 62.207 37.598 Q 62.207 35.205 62.28 32.08 A 160.703 160.703 0 0 1 62.326 30.544 Q 62.452 26.754 62.866 17.168 A 5390.536 5390.536 0 0 1 62.939 15.479 L 62.5 15.479 L 44.092 71.387 Z"
/>,
{ width: 77, height: 75 },
),
export const FontSizeMediumIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 44.092 71.387 L 30.225 71.387 L 13.037 15.381 L 12.598 15.381 A 1505.093 1505.093 0 0 1 12.959 22.313 Q 13.426 31.715 13.508 36.4 A 102.991 102.991 0 0 1 13.525 38.184 L 13.525 71.387 L 0 71.387 L 0 0 L 20.605 0 L 37.5 54.59 L 37.793 54.59 L 55.713 0 L 76.318 0 L 76.318 71.387 L 62.207 71.387 L 62.207 37.598 Q 62.207 35.205 62.28 32.08 A 160.703 160.703 0 0 1 62.326 30.544 Q 62.452 26.754 62.866 17.168 A 5390.536 5390.536 0 0 1 62.939 15.479 L 62.5 15.479 L 44.092 71.387 Z"
/>,
{ width: 77, height: 75 },
),
);
export const FontSizeLargeIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 44.092 71.387 L 0 71.387 L 0 0 L 15.137 0 L 15.137 58.887 L 44.092 58.887 L 44.092 71.387 Z"
/>,
{ width: 45, height: 75 },
),
export const FontSizeLargeIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
fill={iconFillColor(theme)}
d="M 44.092 71.387 L 0 71.387 L 0 0 L 15.137 0 L 15.137 58.887 L 44.092 58.887 L 44.092 71.387 Z"
/>,
{ width: 45, height: 75 },
),
);
export const FontSizeExtraLargeIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
({ theme }: { theme: Theme }) =>
createIcon(
<path
fill={iconFillColor(theme)}
@@ -815,7 +794,7 @@ export const FontSizeExtraLargeIcon = React.memo(
);
export const FontFamilyHandDrawnIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
({ theme }: { theme: Theme }) =>
createIcon(
<path
fill={iconFillColor(theme)}
@@ -825,67 +804,62 @@ export const FontFamilyHandDrawnIcon = React.memo(
),
);
export const FontFamilyNormalIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
fill={iconFillColor(theme)}
d="M 63.818 71.68 L 54.492 71.68 L 45.898 49.561 L 17.578 49.561 L 9.082 71.68 L 0 71.68 L 27.881 0 L 35.986 0 L 63.818 71.68 Z M 20.605 41.602 L 43.213 41.602 L 35.205 19.971 L 31.787 9.277 Q 30.322 15.137 28.711 19.971 L 20.605 41.602 Z"
/>
<path
fill={iconFillColor(theme)}
d="M 68.994 71.68 L 52.686 71.68 L 47.51 54.688 L 21.484 54.688 L 16.309 71.68 L 0 71.68 L 25.195 0 L 43.701 0 L 68.994 71.68 Z M 25.293 41.992 L 43.896 41.992 A 27590.463 27590.463 0 0 1 42.2 36.532 Q 36.965 19.676 35.937 16.273 A 120.932 120.932 0 0 1 35.815 15.869 A 131.65 131.65 0 0 1 35.396 14.435 Q 34.951 12.879 34.675 11.741 A 34.866 34.866 0 0 1 34.521 11.084 A 141.762 141.762 0 0 1 33.706 14.075 Q 31.482 21.957 25.293 41.992 Z"
/>
</>,
{ width: 70, height: 78 },
),
);
export const FontFamilyCodeIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
<>
<path
fill={iconFillColor(theme)}
d="M278.9 511.5l-61-17.7c-6.4-1.8-10-8.5-8.2-14.9L346.2 8.7c1.8-6.4 8.5-10 14.9-8.2l61 17.7c6.4 1.8 10 8.5 8.2 14.9L293.8 503.3c-1.9 6.4-8.5 10.1-14.9 8.2zm-114-112.2l43.5-46.4c4.6-4.9 4.3-12.7-.8-17.2L117 256l90.6-79.7c5.1-4.5 5.5-12.3.8-17.2l-43.5-46.4c-4.5-4.8-12.1-5.1-17-.5L3.8 247.2c-5.1 4.7-5.1 12.8 0 17.5l144.1 135.1c4.9 4.6 12.5 4.4 17-.5zm327.2.6l144.1-135.1c5.1-4.7 5.1-12.8 0-17.5L492.1 112.1c-4.8-4.5-12.4-4.3-17 .5L431.6 159c-4.6 4.9-4.3 12.7.8 17.2L523 256l-90.6 79.7c-5.1 4.5-5.5 12.3-.8 17.2l43.5 46.4c4.5 4.9 12.1 5.1 17 .6z"
/>
</>,
{ width: 640, height: 512 },
),
);
export const TextAlignLeftIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
export const FontFamilyNormalIcon = React.memo(({ theme }: { theme: Theme }) =>
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"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
d="M 63.818 71.68 L 54.492 71.68 L 45.898 49.561 L 17.578 49.561 L 9.082 71.68 L 0 71.68 L 27.881 0 L 35.986 0 L 63.818 71.68 Z M 20.605 41.602 L 43.213 41.602 L 35.205 19.971 L 31.787 9.277 Q 30.322 15.137 28.711 19.971 L 20.605 41.602 Z"
/>
<path
fill={iconFillColor(theme)}
d="M 68.994 71.68 L 52.686 71.68 L 47.51 54.688 L 21.484 54.688 L 16.309 71.68 L 0 71.68 L 25.195 0 L 43.701 0 L 68.994 71.68 Z M 25.293 41.992 L 43.896 41.992 A 27590.463 27590.463 0 0 1 42.2 36.532 Q 36.965 19.676 35.937 16.273 A 120.932 120.932 0 0 1 35.815 15.869 A 131.65 131.65 0 0 1 35.396 14.435 Q 34.951 12.879 34.675 11.741 A 34.866 34.866 0 0 1 34.521 11.084 A 141.762 141.762 0 0 1 33.706 14.075 Q 31.482 21.957 25.293 41.992 Z"
/>
</>,
{ width: 70, height: 78 },
),
);
export const TextAlignCenterIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) =>
createIcon(
export const FontFamilyCodeIcon = React.memo(({ theme }: { theme: Theme }) =>
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"
fill={iconFillColor(theme)}
/>,
{ width: 448, height: 512 },
),
d="M278.9 511.5l-61-17.7c-6.4-1.8-10-8.5-8.2-14.9L346.2 8.7c1.8-6.4 8.5-10 14.9-8.2l61 17.7c6.4 1.8 10 8.5 8.2 14.9L293.8 503.3c-1.9 6.4-8.5 10.1-14.9 8.2zm-114-112.2l43.5-46.4c4.6-4.9 4.3-12.7-.8-17.2L117 256l90.6-79.7c5.1-4.5 5.5-12.3.8-17.2l-43.5-46.4c-4.5-4.8-12.1-5.1-17-.5L3.8 247.2c-5.1 4.7-5.1 12.8 0 17.5l144.1 135.1c4.9 4.6 12.5 4.4 17-.5zm327.2.6l144.1-135.1c5.1-4.7 5.1-12.8 0-17.5L492.1 112.1c-4.8-4.5-12.4-4.3-17 .5L431.6 159c-4.6 4.9-4.3 12.7.8 17.2L523 256l-90.6 79.7c-5.1 4.5-5.5 12.3-.8 17.2l43.5 46.4c4.5 4.9 12.1 5.1 17 .6z"
/>
</>,
{ width: 640, height: 512 },
),
);
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"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
export const TextAlignLeftIcon = React.memo(({ theme }: { theme: Theme }) =>
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"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignCenterIcon = React.memo(({ theme }: { theme: Theme }) =>
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"
fill={iconFillColor(theme)}
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
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"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
+25
View File
@@ -70,6 +70,11 @@ export const FONT_FAMILY = {
Cascadia: 3,
};
export const THEME = {
LIGHT: "light",
DARK: "dark",
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const DEFAULT_FONT_SIZE = 20;
@@ -85,6 +90,12 @@ export const GRID_SIZE = 20; // TODO make it configurable?
export const MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json",
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
gif: "image/gif",
binary: "application/octet-stream",
} as const;
export const EXPORT_DATA_TYPES = {
@@ -100,6 +111,7 @@ export const STORAGE_KEYS = {
} as const;
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
@@ -149,3 +161,16 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const ALLOWED_IMAGE_MIME_TYPES = [
MIME_TYPES.png,
MIME_TYPES.jpg,
MIME_TYPES.svg,
MIME_TYPES.gif,
] as const;
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg";
+130 -13
View File
@@ -1,11 +1,16 @@
import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES } from "../constants";
import {
ALLOWED_IMAGE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState } from "../types";
import { AppState, DataURL } from "../types";
import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData } from "./json";
import { restore } from "./restore";
@@ -14,16 +19,22 @@ import { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => {
let contents: string;
if (blob.type === "image/png") {
if (blob.type === MIME_TYPES.png) {
try {
return await (
await import(/* webpackChunkName: "image" */ "./image")
).decodePngMetadata(blob);
} catch (error) {
if (error.message === "INVALID") {
throw new Error(t("alerts.imageDoesNotContainScene"));
throw new DOMException(
t("alerts.imageDoesNotContainScene"),
"EncodingError",
);
} else {
throw new Error(t("alerts.cannotRestoreFromImage"));
throw new DOMException(
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
}
}
} else {
@@ -40,7 +51,7 @@ const parseFileContents = async (blob: Blob | File) => {
};
});
}
if (blob.type === "image/svg+xml") {
if (blob.type === MIME_TYPES.svg) {
try {
return await (
await import(/* webpackChunkName: "image" */ "./image")
@@ -49,9 +60,15 @@ const parseFileContents = async (blob: Blob | File) => {
});
} catch (error) {
if (error.message === "INVALID") {
throw new Error(t("alerts.imageDoesNotContainScene"));
throw new DOMException(
t("alerts.imageDoesNotContainScene"),
"EncodingError",
);
} else {
throw new Error(t("alerts.cannotRestoreFromImage"));
throw new DOMException(
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
}
}
}
@@ -70,13 +87,13 @@ export const getMimeType = (blob: Blob | string): string => {
name = blob.name || "";
}
if (/\.(excalidraw|json)$/.test(name)) {
return "application/json";
return MIME_TYPES.json;
} else if (/\.png$/.test(name)) {
return "image/png";
return MIME_TYPES.png;
} else if (/\.jpe?g$/.test(name)) {
return "image/jpeg";
return MIME_TYPES.jpg;
} else if (/\.svg$/.test(name)) {
return "image/svg+xml";
return MIME_TYPES.svg;
}
return "";
};
@@ -100,6 +117,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
return type === "png" || type === "svg";
};
export const isSupportedImageFile = (
blob: Blob | null | undefined,
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
const { type } = blob || {};
return (
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
);
};
export const loadFromBlob = async (
blob: Blob,
/** @see restore.localAppState */
@@ -123,6 +149,7 @@ export const loadFromBlob = async (
? calculateScrollCenter(data.elements || [], localAppState, null)
: {}),
},
files: data.files,
},
localAppState,
localElements,
@@ -165,3 +192,93 @@ export const canvasToBlob = async (
}
});
};
/** generates SHA-1 digest from supplied file (if not supported, falls back
to a 40-char base64 random id) */
export const generateIdFromFile = async (file: File) => {
let id: FileId;
try {
const hashBuffer = await window.crypto.subtle.digest(
"SHA-1",
await file.arrayBuffer(),
);
id =
// convert buffer to byte array
Array.from(new Uint8Array(hashBuffer))
// convert to hex string
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("") as FileId;
} catch (error) {
console.error(error);
// length 40 to align with the HEX length of SHA-1 (which is 160 bit)
id = nanoid(40) as FileId;
}
return id;
};
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataURL = reader.result as DataURL;
resolve(dataURL);
};
reader.onerror = (error) => reject(error);
reader.readAsDataURL(file);
});
};
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
const dataIndexStart = dataURL.indexOf(",");
const byteString = atob(dataURL.slice(dataIndexStart + 1));
const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new File([ab], filename, { type: mimeType });
};
export const resizeImageFile = async (
file: File,
maxWidthOrHeight: number,
): Promise<File> => {
// SVG files shouldn't a can't be resized
if (file.type === MIME_TYPES.svg) {
return file;
}
const [pica, imageBlobReduce] = await Promise.all([
import("pica").then((res) => res.default),
// a wrapper for pica for better API
import("image-blob-reduce").then((res) => res.default),
]);
// CRA's minification settings break pica in WebWorkers, so let's disable
// them for now
// https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
const reduce = imageBlobReduce({
pica: pica({ features: ["js", "wasm"] }),
});
const fileType = file.type;
if (!isSupportedImageFile(file)) {
throw new Error(t("errors.unsupportedFileType"));
}
return new File(
[await reduce.toBlob(file, { max: maxWidthOrHeight })],
file.name,
{ type: fileType },
);
};
export const SVGStringToFile = (SVGString: string, filename: string = "") => {
return new File([new TextEncoder().encode(SVGString)], filename, {
type: MIME_TYPES.svg,
}) as File & { type: typeof MIME_TYPES.svg };
};
+267 -4
View File
@@ -1,16 +1,19 @@
import { deflate, inflate } from "pako";
import { encryptData, decryptData } from "./encryption";
// -----------------------------------------------------------------------------
// byte (binary) strings
// -----------------------------------------------------------------------------
// fast, Buffer-compatible implem
export const toByteString = (data: string | Uint8Array): Promise<string> => {
export const toByteString = (
data: string | Uint8Array | ArrayBuffer,
): Promise<string> => {
return new Promise((resolve, reject) => {
const blob =
typeof data === "string"
? new Blob([new TextEncoder().encode(data)])
: new Blob([data]);
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target || typeof event.target.result !== "string") {
@@ -44,12 +47,14 @@ const byteStringToString = (byteString: string) => {
* due to reencoding
*/
export const stringToBase64 = async (str: string, isByteString = false) => {
return isByteString ? btoa(str) : btoa(await toByteString(str));
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
};
// async to align with stringToBase64
export const base64ToString = async (base64: string, isByteString = false) => {
return isByteString ? atob(base64) : byteStringToString(atob(base64));
return isByteString
? window.atob(base64)
: byteStringToString(window.atob(base64));
};
// -----------------------------------------------------------------------------
@@ -114,3 +119,261 @@ export const decode = async (data: EncodedData): Promise<string> => {
return decoded;
};
// -----------------------------------------------------------------------------
// binary encoding
// -----------------------------------------------------------------------------
type FileEncodingInfo = {
/* version 2 is the version we're shipping the initial image support with.
version 1 was a PR version that a lot of people were using anyway.
Thus, if there are issues we can check whether they're not using the
unoffic version */
version: 1 | 2;
compression: "pako@1" | null;
encryption: "AES-GCM" | null;
};
// -----------------------------------------------------------------------------
const CONCAT_BUFFERS_VERSION = 1;
/** how many bytes we use to encode how many bytes the next chunk has.
* Corresponds to DataView setter methods (setUint32, setUint16, etc).
*
* NOTE ! values must not be changed, which would be backwards incompatible !
*/
const VERSION_DATAVIEW_BYTES = 4;
const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4;
// -----------------------------------------------------------------------------
const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const;
// getter
function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number;
// setter
function dataView(
buffer: Uint8Array,
bytes: 1 | 2 | 4,
offset: number,
value: number,
): Uint8Array;
/**
* abstraction over DataView that serves as a typed getter/setter in case
* you're using constants for the byte size and want to ensure there's no
* discrepenancy in the encoding across refactors.
*
* DataView serves for an endian-agnostic handling of numbers in ArrayBuffers.
*/
function dataView(
buffer: Uint8Array,
bytes: 1 | 2 | 4,
offset: number,
value?: number,
): Uint8Array | number {
if (value != null) {
if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) {
throw new Error(
`attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`,
);
}
const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
new DataView(buffer.buffer)[method](offset, value);
return buffer;
}
const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
return new DataView(buffer.buffer)[method](offset);
}
// -----------------------------------------------------------------------------
/**
* Resulting concatenated buffer has this format:
*
* [
* VERSION chunk (4 bytes)
* LENGTH chunk 1 (4 bytes)
* DATA chunk 1 (up to 2^32 bits)
* LENGTH chunk 2 (4 bytes)
* DATA chunk 2 (up to 2^32 bits)
* ...
* ]
*
* @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
*/
const concatBuffers = (...buffers: Uint8Array[]) => {
const bufferView = new Uint8Array(
VERSION_DATAVIEW_BYTES +
NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0),
);
let cursor = 0;
// as the first chunk we'll encode the version for backwards compatibility
dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION);
cursor += VERSION_DATAVIEW_BYTES;
for (const buffer of buffers) {
dataView(
bufferView,
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
cursor,
buffer.byteLength,
);
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
bufferView.set(buffer, cursor);
cursor += buffer.byteLength;
}
return bufferView;
};
/** can only be used on buffers created via `concatBuffers()` */
const splitBuffers = (concatenatedBuffer: Uint8Array) => {
const buffers = [];
let cursor = 0;
// first chunk is the version (ignored for now)
cursor += VERSION_DATAVIEW_BYTES;
while (true) {
const chunkSize = dataView(
concatenatedBuffer,
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
cursor,
);
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize));
cursor += chunkSize;
if (cursor >= concatenatedBuffer.byteLength) {
break;
}
}
return buffers;
};
// helpers for (de)compressing data with JSON metadata including encryption
// -----------------------------------------------------------------------------
/** @private */
const _encryptAndCompress = async (
data: Uint8Array | string,
encryptionKey: string,
) => {
const { encryptedBuffer, iv } = await encryptData(
encryptionKey,
deflate(data),
);
return { iv, buffer: new Uint8Array(encryptedBuffer) };
};
/**
* The returned buffer has following format:
* `[]` refers to a buffers wrapper (see `concatBuffers`)
*
* [
* encodingMetadataBuffer,
* iv,
* [
* contentsMetadataBuffer
* contentsBuffer
* ]
* ]
*/
export const compressData = async <T extends Record<string, any> = never>(
dataBuffer: Uint8Array,
options: {
encryptionKey: string;
} & ([T] extends [never]
? {
metadata?: T;
}
: {
metadata: T;
}),
): Promise<Uint8Array> => {
const fileInfo: FileEncodingInfo = {
version: 2,
compression: "pako@1",
encryption: "AES-GCM",
};
const encodingMetadataBuffer = new TextEncoder().encode(
JSON.stringify(fileInfo),
);
const contentsMetadataBuffer = new TextEncoder().encode(
JSON.stringify(options.metadata || null),
);
const { iv, buffer } = await _encryptAndCompress(
concatBuffers(contentsMetadataBuffer, dataBuffer),
options.encryptionKey,
);
return concatBuffers(encodingMetadataBuffer, iv, buffer);
};
/** @private */
const _decryptAndDecompress = async (
iv: Uint8Array,
decryptedBuffer: Uint8Array,
decryptionKey: string,
isCompressed: boolean,
) => {
decryptedBuffer = new Uint8Array(
await decryptData(iv, decryptedBuffer, decryptionKey),
);
if (isCompressed) {
return inflate(decryptedBuffer);
}
return decryptedBuffer;
};
export const decompressData = async <T extends Record<string, any>>(
bufferView: Uint8Array,
options: { decryptionKey: string },
) => {
// first chunk is encoding metadata (ignored for now)
const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView);
const encodingMetadata: FileEncodingInfo = JSON.parse(
new TextDecoder().decode(encodingMetadataBuffer),
);
try {
const [contentsMetadataBuffer, contentsBuffer] = splitBuffers(
await _decryptAndDecompress(
iv,
buffer,
options.decryptionKey,
!!encodingMetadata.compression,
),
);
const metadata = JSON.parse(
new TextDecoder().decode(contentsMetadataBuffer),
) as T;
return {
/** metadata source is always JSON so we can decode it here */
metadata,
/** data can be anything so the caller must decode it */
data: contentsBuffer,
};
} catch (error) {
console.error(
`Error during decompressing and decrypting the file.`,
encodingMetadata,
);
throw error;
}
};
// -----------------------------------------------------------------------------
+79
View File
@@ -0,0 +1,79 @@
export const IV_LENGTH_BYTES = 12;
export const createIV = () => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
export const generateEncryptionKey = async () => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
return (await window.crypto.subtle.exportKey("jwk", key)).k;
};
export const getImportedKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: 128,
},
false, // extractable
[usage],
);
export const encryptData = async (
key: string,
data: Uint8Array | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
const importedKey = await getImportedKey(key, "encrypt");
const iv = createIV();
const buffer: ArrayBuffer | Uint8Array =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: data instanceof Blob
? await data.arrayBuffer()
: data;
const encryptedBuffer = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
buffer as ArrayBuffer | Uint8Array,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
iv: Uint8Array,
encrypted: Uint8Array | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getImportedKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};
+2 -10
View File
@@ -10,6 +10,7 @@ import { AbortError } from "../errors";
import { debounce } from "../utils";
type FILE_EXTENSION =
| "gif"
| "jpg"
| "png"
| "svg"
@@ -17,15 +18,6 @@ type FILE_EXTENSION =
| "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: {
@@ -41,7 +33,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
: FileWithHandle[];
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(FILE_TYPE_TO_MIME_TYPE[type]);
mimeTypes.push(MIME_TYPES[type]);
return mimeTypes;
}, [] as string[]);
+1 -1
View File
@@ -57,7 +57,7 @@ export const encodePngMetadata = async ({
// insert metadata before last chunk (iEND)
chunks.splice(-1, 0, metadataChunk);
return new Blob([encodePng(chunks)], { type: "image/png" });
return new Blob([encodePng(chunks)], { type: MIME_TYPES.png });
};
export const decodePngMetadata = async (blob: Blob) => {
+18 -13
View File
@@ -2,12 +2,12 @@ import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { DEFAULT_EXPORT_PADDING, MIME_TYPES } 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 { AppState, BinaryFiles } from "../types";
import { canvasToBlob } from "./blob";
import { fileSave, FileSystemHandle } from "./filesystem";
import { serializeAsJSON } from "./json";
@@ -19,6 +19,7 @@ export const exportCanvas = async (
type: ExportType,
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
{
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
@@ -37,17 +38,21 @@ export const exportCanvas = async (
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {
const tempSvg = await exportToSvg(elements, {
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
});
const tempSvg = await exportToSvg(
elements,
{
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files,
);
if (type === "svg") {
return await fileSave(
new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }),
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
{
name,
extension: "svg",
@@ -60,7 +65,7 @@ export const exportCanvas = async (
}
}
const tempCanvas = exportToCanvas(elements, appState, {
const tempCanvas = await exportToCanvas(elements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
@@ -76,7 +81,7 @@ export const exportCanvas = async (
await import(/* webpackChunkName: "image" */ "./image")
).encodePngMetadata({
blob,
metadata: serializeAsJSON(elements, appState),
metadata: serializeAsJSON(elements, appState, files, "local"),
});
}
+42 -15
View File
@@ -1,9 +1,9 @@
import { fileOpen, fileSave } from "./filesystem";
import { cleanAppStateForExport } from "../appState";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { clearElementsForDatabase, clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { AppState, BinaryFiles } from "../types";
import { isImageFileHandle, loadFromBlob } from "./blob";
import {
@@ -13,16 +13,50 @@ import {
} from "./types";
import Library from "./library";
/**
* Strips out files which are only referenced by deleted elements
*/
const filterOutDeletedFiles = (
elements: readonly ExcalidrawElement[],
files: BinaryFiles,
) => {
const nextFiles: BinaryFiles = {};
for (const element of elements) {
if (
!element.isDeleted &&
"fileId" in element &&
element.fileId &&
files[element.fileId]
) {
nextFiles[element.fileId] = files[element.fileId];
}
}
return nextFiles;
};
export const serializeAsJSON = (
elements: readonly ExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
type: "local" | "database",
): string => {
const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw,
version: 2,
source: EXPORT_SOURCE,
elements: clearElementsForExport(elements),
appState: cleanAppStateForExport(appState),
elements:
type === "local"
? clearElementsForExport(elements)
: clearElementsForDatabase(elements),
appState:
type === "local"
? cleanAppStateForExport(appState)
: clearAppStateForDatabase(appState),
files:
type === "local"
? filterOutDeletedFiles(elements, files)
: // will be stripped from JSON
undefined,
};
return JSON.stringify(data, null, 2);
@@ -31,8 +65,9 @@ export const serializeAsJSON = (
export const saveAsJSON = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const serialized = serializeAsJSON(elements, appState);
const serialized = serializeAsJSON(elements, appState, files, "local");
const blob = new Blob([serialized], {
type: MIME_TYPES.excalidraw,
});
@@ -56,15 +91,7 @@ export const loadFromJSON = async (
description: "Excalidraw files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
/*
extensions: [".json", ".excalidraw", ".png", ".svg"],
mimeTypes: [
MIME_TYPES.excalidraw,
"application/json",
"image/png",
"image/svg+xml",
],
*/
// extensions: ["json", "excalidraw", "png", "svg"],
});
return loadFromBlob(blob, localAppState, localElements);
};
+3 -1
View File
@@ -1,5 +1,5 @@
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { AppState, BinaryFiles } from "../types";
import { exportCanvas } from ".";
import { getNonDeletedElements } from "../element";
import { getFileHandleType, isImageFileHandleType } from "./blob";
@@ -7,6 +7,7 @@ import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
@@ -26,6 +27,7 @@ export const resaveAsImageWithScene = async (
fileHandleType,
getNonDeletedElements(elements),
appState,
files,
{
exportBackground,
viewBackgroundColor,
+23 -11
View File
@@ -3,7 +3,7 @@ import {
ExcalidrawSelectionElement,
FontFamilyValues,
} from "../element/types";
import { AppState, NormalizedZoomValue } from "../types";
import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
import { ImportedDataState } from "./types";
import {
getElementMap,
@@ -37,6 +37,7 @@ export const AllowedExcalidrawElementTypes: Record<
diamond: true,
ellipse: true,
line: true,
image: true,
arrow: true,
freedraw: true,
};
@@ -44,6 +45,7 @@ export const AllowedExcalidrawElementTypes: Record<
export type RestoredDataState = {
elements: ExcalidrawElement[];
appState: RestoredAppState;
files: BinaryFiles;
};
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
@@ -57,16 +59,19 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const restoreElementWithProperties = <
T extends ExcalidrawElement,
K extends keyof Omit<
Required<T>,
Exclude<keyof ExcalidrawElement, "type" | "x" | "y">
>
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>
>(
element: Required<T>,
extra: Pick<T, K>,
extra: Pick<
T,
// This extra Pick<T, keyof K> ensure no excess properties are passed.
// @ts-ignore TS complains here but type checks the call sites fine.
keyof K
> &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
): T => {
const base: Pick<T, keyof ExcalidrawElement> = {
type: (extra as Partial<T>).type || element.type,
type: extra.type || element.type,
// all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements
version: element.version || 1,
@@ -79,8 +84,8 @@ const restoreElementWithProperties = <
roughness: element.roughness ?? 1,
opacity: element.opacity == null ? 100 : element.opacity,
angle: element.angle || 0,
x: (extra as Partial<T>).x ?? element.x ?? 0,
y: (extra as Partial<T>).y ?? element.y ?? 0,
x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor,
backgroundColor: element.backgroundColor,
width: element.width || 0,
@@ -102,7 +107,7 @@ const restoreElementWithProperties = <
const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
): typeof element => {
): typeof element | null => {
switch (element.type) {
case "text":
let fontSize = element.fontSize;
@@ -131,6 +136,12 @@ const restoreElement = (
pressures: element.pressures,
});
}
case "image":
return restoreElementWithProperties(element, {
status: element.status || "pending",
fileId: element.fileId,
scale: element.scale || [1, 1],
});
case "line":
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
@@ -194,7 +205,7 @@ export const restoreElements = (
// 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);
let migratedElement: ExcalidrawElement | null = restoreElement(element);
if (migratedElement) {
const localElement = localElementsMap?.[element.id];
if (localElement && localElement.version > migratedElement.version) {
@@ -260,5 +271,6 @@ export const restore = (
return {
elements: restoreElements(data?.elements, localElements),
appState: restoreAppState(data?.appState, localAppState || null),
files: data?.files || {},
};
};
+3 -1
View File
@@ -1,5 +1,5 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, LibraryItems } from "../types";
import { AppState, BinaryFiles, LibraryItems } from "../types";
import type { cleanAppStateForExport } from "../appState";
export interface ExportedDataState {
@@ -8,6 +8,7 @@ export interface ExportedDataState {
source: string;
elements: readonly ExcalidrawElement[];
appState: ReturnType<typeof cleanAppStateForExport>;
files: BinaryFiles | undefined;
}
export interface ImportedDataState {
@@ -18,6 +19,7 @@ export interface ImportedDataState {
appState?: Readonly<Partial<AppState>> | null;
scrollToContent?: boolean;
libraryItems?: LibraryItems;
files?: BinaryFiles;
}
export interface ExportedLibraryData {
+13 -3
View File
@@ -23,6 +23,7 @@ import {
ExcalidrawEllipseElement,
NonDeleted,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -30,6 +31,7 @@ import { Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import { isImageElement } from "./typeChecks";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@@ -47,8 +49,7 @@ const isElementDraggableFromInside = (
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
return isDraggableFromInside;
return isDraggableFromInside || isImageElement(element);
};
export const hitTest = (
@@ -161,6 +162,7 @@ type HitTestArgs = {
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
switch (args.element.type) {
case "rectangle":
case "image":
case "text":
case "diamond":
case "ellipse":
@@ -195,6 +197,7 @@ export const distanceToBindableElement = (
): number => {
switch (element.type) {
case "rectangle":
case "image":
case "text":
return distanceToRectangle(element, point);
case "diamond":
@@ -224,7 +227,8 @@ const distanceToRectangle = (
element:
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement,
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -486,6 +490,7 @@ export const determineFocusDistance = (
const nabs = Math.abs(n);
switch (element.type) {
case "rectangle":
case "image":
case "text":
return c / (hwidth * (nabs + q * mabs));
case "diamond":
@@ -516,6 +521,7 @@ export const determineFocusPoint = (
let point;
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "diamond":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
@@ -565,6 +571,7 @@ const getSortedElementLineIntersections = (
let intersections: GA.Point[];
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "diamond":
const corners = getCorners(element);
@@ -598,6 +605,7 @@ const getSortedElementLineIntersections = (
const getCorners = (
element:
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
scale: number = 1,
@@ -606,6 +614,7 @@ const getCorners = (
const hy = (scale * element.height) / 2;
switch (element.type) {
case "rectangle":
case "image":
case "text":
return [
GA.point(hx, hy),
@@ -747,6 +756,7 @@ export const findFocusPointForEllipse = (
export const findFocusPointForRectangulars = (
element:
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
// Between -1 and 1 for how far away should the focus point be relative
+18 -11
View File
@@ -62,25 +62,32 @@ export const dragNewElement = (
y: number,
width: number,
height: number,
isResizeWithSidesSameLength: boolean,
isResizeCenterPoint: boolean,
shouldMaintainAspectRatio: boolean,
shouldResizeFromCenter: boolean,
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null,
) => {
if (isResizeWithSidesSameLength) {
({ width, height } = getPerfectElementSize(
elementType,
width,
y < originY ? -height : height,
));
if (shouldMaintainAspectRatio) {
if (widthAspectRatio) {
height = width / widthAspectRatio;
} else {
({ width, height } = getPerfectElementSize(
elementType,
width,
y < originY ? -height : height,
));
if (height < 0) {
height = -height;
if (height < 0) {
height = -height;
}
}
}
let newX = x < originX ? originX - width : originX;
let newY = y < originY ? originY - height : originY;
if (isResizeCenterPoint) {
if (shouldResizeFromCenter) {
width += width;
height += height;
newX = originX - width / 2;
+111
View File
@@ -0,0 +1,111 @@
// -----------------------------------------------------------------------------
// ExcalidrawImageElement & related helpers
// -----------------------------------------------------------------------------
import { MIME_TYPES, SVG_NS } from "../constants";
import { t } from "../i18n";
import { AppClassProperties, DataURL, BinaryFiles } from "../types";
import { isInitializedImageElement } from "./typeChecks";
import {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
} from "./types";
export const loadHTMLImageElement = (dataURL: DataURL) => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve(image);
};
image.onerror = (error) => {
reject(error);
};
image.src = dataURL;
});
};
/** NOTE: updates cache even if already populated with given image. Thus,
* you should filter out the images upstream if you want to optimize this. */
export const updateImageCache = async ({
fileIds,
files,
imageCache,
}: {
fileIds: FileId[];
files: BinaryFiles;
imageCache: AppClassProperties["imageCache"];
}) => {
const updatedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
await Promise.all(
fileIds.reduce((promises, fileId) => {
const fileData = files[fileId as string];
if (fileData && !updatedFiles.has(fileId)) {
updatedFiles.set(fileId, true);
return promises.concat(
(async () => {
try {
if (fileData.mimeType === MIME_TYPES.binary) {
throw new Error("Only images can be added to ImageCache");
}
const imagePromise = loadHTMLImageElement(fileData.dataURL);
const data = {
image: imagePromise,
mimeType: fileData.mimeType,
} as const;
// store the promise immediately to indicate there's an in-progress
// initialization
imageCache.set(fileId, data);
const image = await imagePromise;
imageCache.set(fileId, { ...data, image });
} catch (error) {
erroredFiles.set(fileId, true);
}
})(),
);
}
return promises;
}, [] as Promise<any>[]),
);
return {
imageCache,
/** includes errored files because they cache was updated nonetheless */
updatedFiles,
/** files that failed when creating HTMLImageElement */
erroredFiles,
};
};
export const getInitializedImageElements = (
elements: readonly ExcalidrawElement[],
) =>
elements.filter((element) =>
isInitializedImageElement(element),
) as InitializedExcalidrawImageElement[];
export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
// lower-casing due to XML/HTML convention differences
// https://johnresig.com/blog/nodename-case-sensitivity
return node?.nodeName.toLowerCase() === "svg";
};
export const normalizeSVG = async (SVGString: string) => {
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror");
if (errorNode || !isHTMLSVGElement(svg)) {
throw new Error(t("errors.invalidSVGString"));
} else {
if (!svg.hasAttribute("xmlns")) {
svg.setAttribute("xmlns", SVG_NS);
}
return svg.outerHTML;
}
};
+5
View File
@@ -11,6 +11,7 @@ export {
newTextElement,
updateTextElement,
newLinearElement,
newImageElement,
duplicateElement,
} from "./newElement";
export {
@@ -93,6 +94,10 @@ const _clearElements = (
: element,
);
export const clearElementsForDatabase = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export const clearElementsForExport = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
+26 -10
View File
@@ -17,12 +17,13 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
) => {
informMutation = true,
): TElement => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points } = updates as any;
const { points, fileId } = updates as any;
if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
@@ -33,13 +34,23 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update in case its deep prop was mutated
(typeof value !== "object" || value === null || key === "groupIds")
// if object, always update because its attrs could have changed
// (except for specific keys we handle below)
(typeof value !== "object" ||
value === null ||
key === "groupIds" ||
key === "scale")
) {
continue;
}
if (key === "points") {
if (key === "scale") {
const prevScale = (element as any)[key];
const nextScale = value;
if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) {
continue;
}
} else if (key === "points") {
const prevPoints = (element as any)[key];
const nextPoints = value;
if (prevPoints.length === nextPoints.length) {
@@ -66,14 +77,14 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
didChange = true;
}
}
if (!didChange) {
return;
return element;
}
if (
typeof updates.height !== "undefined" ||
typeof updates.width !== "undefined" ||
typeof fileId != "undefined" ||
typeof points !== "undefined"
) {
invalidateShapeForElement(element);
@@ -81,7 +92,12 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.version++;
element.versionNonce = randomInteger();
Scene.getScene(element)?.informMutation();
if (informMutation) {
Scene.getScene(element)?.informMutation();
}
return element;
};
export const newElementWith = <TElement extends ExcalidrawElement>(
@@ -94,8 +110,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update in case its deep prop was mutated
(typeof value !== "object" || value === null || key === "groupIds")
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
) {
continue;
}
+17
View File
@@ -1,5 +1,6 @@
import {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawTextElement,
ExcalidrawLinearElement,
ExcalidrawGenericElement,
@@ -248,6 +249,22 @@ export const newLinearElement = (
};
};
export const newImageElement = (
opts: {
type: ExcalidrawImageElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => {
return {
..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements,
// and `transparent` will likely mean "use original colors of the image"
strokeColor: "transparent",
status: "pending",
fileId: null,
scale: [1, 1],
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
//
+40 -28
View File
@@ -47,9 +47,9 @@ export const transformElements = (
transformHandleType: MaybeTransformHandleType,
selectedElements: readonly NonDeletedExcalidrawElement[],
resizeArrowDirection: "origin" | "end",
isRotateWithDiscreteAngle: boolean,
isResizeCenterPoint: boolean,
shouldKeepSidesRatio: boolean,
shouldRotateWithDiscreteAngle: boolean,
shouldResizeFromCenter: boolean,
shouldMaintainAspectRatio: boolean,
pointerX: number,
pointerY: number,
centerX: number,
@@ -62,7 +62,7 @@ export const transformElements = (
element,
pointerX,
pointerY,
isRotateWithDiscreteAngle,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element);
} else if (
@@ -76,7 +76,7 @@ export const transformElements = (
reshapeSingleTwoPointElement(
element,
resizeArrowDirection,
isRotateWithDiscreteAngle,
shouldRotateWithDiscreteAngle,
pointerX,
pointerY,
);
@@ -90,7 +90,7 @@ export const transformElements = (
resizeSingleTextElement(
element,
transformHandleType,
isResizeCenterPoint,
shouldResizeFromCenter,
pointerX,
pointerY,
);
@@ -98,10 +98,10 @@ export const transformElements = (
} else if (transformHandleType) {
resizeSingleElement(
pointerDownState.originalElements.get(element.id) as typeof element,
shouldKeepSidesRatio,
shouldMaintainAspectRatio,
element,
transformHandleType,
isResizeCenterPoint,
shouldResizeFromCenter,
pointerX,
pointerY,
);
@@ -115,7 +115,7 @@ export const transformElements = (
selectedElements,
pointerX,
pointerY,
isRotateWithDiscreteAngle,
shouldRotateWithDiscreteAngle,
centerX,
centerY,
);
@@ -142,13 +142,13 @@ const rotateSingleElement = (
element: NonDeletedExcalidrawElement,
pointerX: number,
pointerY: number,
isRotateWithDiscreteAngle: boolean,
shouldRotateWithDiscreteAngle: boolean,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
if (isRotateWithDiscreteAngle) {
if (shouldRotateWithDiscreteAngle) {
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
}
@@ -187,7 +187,7 @@ const getPerfectElementSizeWithRotation = (
export const reshapeSingleTwoPointElement = (
element: NonDeleted<ExcalidrawLinearElement>,
resizeArrowDirection: "origin" | "end",
isRotateWithDiscreteAngle: boolean,
shouldRotateWithDiscreteAngle: boolean,
pointerX: number,
pointerY: number,
) => {
@@ -212,7 +212,7 @@ export const reshapeSingleTwoPointElement = (
element.x + element.points[1][0] - rotatedX,
element.y + element.points[1][1] - rotatedY,
];
if (isRotateWithDiscreteAngle) {
if (shouldRotateWithDiscreteAngle) {
[width, height] = getPerfectElementSizeWithRotation(
element.type,
width,
@@ -281,28 +281,28 @@ const measureFontSizeFromWH = (
const getSidesForTransformHandle = (
transformHandleType: TransformHandleType,
isResizeFromCenter: boolean,
shouldResizeFromCenter: boolean,
) => {
return {
n:
/^(n|ne|nw)$/.test(transformHandleType) ||
(isResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
(shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
s:
/^(s|se|sw)$/.test(transformHandleType) ||
(isResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
(shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
w:
/^(w|nw|sw)$/.test(transformHandleType) ||
(isResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
(shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
e:
/^(e|ne|se)$/.test(transformHandleType) ||
(isResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
(shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
};
};
const resizeSingleTextElement = (
element: NonDeleted<ExcalidrawTextElement>,
transformHandleType: "nw" | "ne" | "sw" | "se",
isResizeFromCenter: boolean,
shouldResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
) => {
@@ -361,7 +361,7 @@ const resizeSingleTextElement = (
const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2;
const [nextElementX, nextElementY] = adjustXYWithRotation(
getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
element.x,
element.y,
element.angle,
@@ -383,10 +383,10 @@ const resizeSingleTextElement = (
export const resizeSingleElement = (
stateAtResizeStart: NonDeletedExcalidrawElement,
shouldKeepSidesRatio: boolean,
shouldMaintainAspectRatio: boolean,
element: NonDeletedExcalidrawElement,
transformHandleDirection: TransformHandleDirection,
isResizeFromCenter: boolean,
shouldResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
) => {
@@ -444,13 +444,13 @@ export const resizeSingleElement = (
let eleNewHeight = element.height * scaleY;
// adjust dimensions for resizing from center
if (isResizeFromCenter) {
if (shouldResizeFromCenter) {
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
}
// adjust dimensions to keep sides ratio
if (shouldKeepSidesRatio) {
if (shouldMaintainAspectRatio) {
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
if (transformHandleDirection.length === 1) {
@@ -495,7 +495,7 @@ export const resizeSingleElement = (
}
// Keeps opposite handle fixed during resize
if (shouldKeepSidesRatio) {
if (shouldMaintainAspectRatio) {
if (["s", "n"].includes(transformHandleDirection)) {
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
@@ -523,7 +523,7 @@ export const resizeSingleElement = (
}
}
if (isResizeFromCenter) {
if (shouldResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
}
@@ -558,6 +558,18 @@ export const resizeSingleElement = (
...rescaledPoints,
};
if ("scale" in element && "scale" in stateAtResizeStart) {
mutateElement(element, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(scaleX) || stateAtResizeStart.scale[0]) *
stateAtResizeStart.scale[0],
(Math.sign(scaleY) || stateAtResizeStart.scale[1]) *
stateAtResizeStart.scale[1],
],
});
}
if (
resizedElement.width !== 0 &&
resizedElement.height !== 0 &&
@@ -692,13 +704,13 @@ const rotateMultipleElements = (
elements: readonly NonDeletedExcalidrawElement[],
pointerX: number,
pointerY: number,
isRotateWithDiscreteAngle: boolean,
shouldRotateWithDiscreteAngle: boolean,
centerX: number,
centerY: number,
) => {
let centerAngle =
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
if (isRotateWithDiscreteAngle) {
if (shouldRotateWithDiscreteAngle) {
centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
}
+14
View File
@@ -5,6 +5,8 @@ import {
ExcalidrawBindableElement,
ExcalidrawGenericElement,
ExcalidrawFreeDrawElement,
InitializedExcalidrawImageElement,
ExcalidrawImageElement,
} from "./types";
export const isGenericElement = (
@@ -19,6 +21,18 @@ export const isGenericElement = (
);
};
export const isInitializedImageElement = (
element: ExcalidrawElement | null,
): element is InitializedExcalidrawImageElement => {
return !!element && element.type === "image" && !!element.fileId;
};
export const isImageElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawImageElement => {
return !!element && element.type === "image";
};
export const isTextElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElement => {
+24 -4
View File
@@ -1,10 +1,11 @@
import { Point } from "../types";
import { FONT_FAMILY } from "../constants";
import { FONT_FAMILY, THEME } 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 FontString = string & { _brand: "fontString" };
export type GroupId = string;
export type PointerType = "mouse" | "pen" | "touch";
@@ -62,6 +63,21 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
type: "ellipse";
};
export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{
type: "image";
fileId: FileId | null;
/** whether respective file is persisted */
status: "pending" | "saved" | "error";
/** X and Y scale factors <-1, 1>, used for image axis flipping */
scale: [number, number];
}>;
export type InitializedExcalidrawImageElement = MarkNonNullable<
ExcalidrawImageElement,
"fileId"
>;
/**
* These are elements that don't have any additional properties.
*/
@@ -80,10 +96,11 @@ export type ExcalidrawElement =
| ExcalidrawGenericElement
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawFreeDrawElement;
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: false;
isDeleted: boolean;
};
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
@@ -103,7 +120,8 @@ export type ExcalidrawBindableElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement;
| ExcalidrawTextElement
| ExcalidrawImageElement;
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
@@ -132,3 +150,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
simulatePressure: boolean;
lastCommittedPoint: Point | null;
}>;
export type FileId = string & { _brand: "FileId" };
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { debounce, getVersion, nFormatter } from "../utils";
import {
getElementsStorageSize,
+11
View File
@@ -1,8 +1,14 @@
// time constants (ms)
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
export const FILE_UPLOAD_TIMEOUT = 300;
export const LOAD_IMAGES_TIMEOUT = 500;
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
export const BROADCAST = {
SERVER_VOLATILE: "server-volatile-broadcast",
SERVER: "server-broadcast",
@@ -12,3 +18,8 @@ export enum SCENE {
INIT = "SCENE_INIT",
UPDATE = "SCENE_UPDATE",
}
export const FIREBASE_STORAGE_PREFIXES = {
shareLinkFiles: `/files/shareLinks`,
collabFiles: `/files/rooms`,
};
+129 -10
View File
@@ -1,18 +1,28 @@
import throttle from "lodash.throttle";
import React, { PureComponent } from "react";
import { PureComponent } from "react";
import { ExcalidrawImperativeAPI } from "../../types";
import { ErrorDialog } from "../../components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import {
ExcalidrawElement,
InitializedExcalidrawImageElement,
} from "../../element/types";
import {
getElementMap,
getSceneVersion,
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
import {
preventUnload,
resolvablePromise,
withBatchedUpdates,
} from "../../utils";
import {
FILE_UPLOAD_MAX_BYTES,
FIREBASE_STORAGE_PREFIXES,
INITIAL_SCENE_UPDATE_TIMEOUT,
LOAD_IMAGES_TIMEOUT,
SCENE,
SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants";
@@ -25,7 +35,9 @@ import {
} from "../data";
import {
isSavedToFirebase,
loadFilesFromFirebase,
loadFromFirebase,
saveFilesToFirebase,
saveToFirebase,
} from "../data/firebase";
import {
@@ -41,6 +53,17 @@ import { UserIdleState } from "../../types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
import { trackEvent } from "../../analytics";
import { isInvisiblySmallElement } from "../../element";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { AbortError } from "../../errors";
import {
isImageElement,
isInitializedImageElement,
} from "../../element/typeChecks";
import { mutateElement } from "../../element/mutateElement";
interface CollabState {
modalIsShown: boolean;
@@ -61,6 +84,7 @@ export interface CollabAPI {
initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
broadcastElements: CollabInstance["broadcastElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
}
type ReconciledElements = readonly ExcalidrawElement[] & {
@@ -69,6 +93,7 @@ type ReconciledElements = readonly ExcalidrawElement[] & {
interface Props {
excalidrawAPI: ExcalidrawImperativeAPI;
onRoomClose?: () => void;
}
const {
@@ -81,12 +106,13 @@ export { CollabContext, CollabContextConsumer };
class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
fileManager: FileManager;
excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
activeIntervalId: number | null;
idleTimeoutId: number | null;
private socketInitializationTimer?: NodeJS.Timeout;
private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>();
@@ -100,6 +126,31 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
activeRoomLink: "",
};
this.portal = new Portal(this);
this.fileManager = new FileManager({
getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) {
throw new AbortError();
}
return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
},
saveFiles: async ({ addedFiles }) => {
const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) {
throw new AbortError();
}
return saveFilesToFirebase({
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
files: await encodeFilesForUpload({
files: addedFiles,
encryptionKey: roomKey,
maxBytes: FILE_UPLOAD_MAX_BYTES,
}),
});
},
});
this.excalidrawAPI = props.excalidrawAPI;
this.activeIntervalId = null;
this.idleTimeoutId = null;
@@ -152,15 +203,14 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
if (
this.isCollaborating &&
!isSavedToFirebase(this.portal, syncableElements)
(this.fileManager.shouldPreventUnload(syncableElements) ||
!isSavedToFirebase(this.portal, syncableElements))
) {
// this won't run in time if user decides to leave the site, but
// the purpose is to run in immediately after user decides to stay
this.saveCollabRoomToFirebase(syncableElements);
event.preventDefault();
// NOTE: modern browsers no longer allow showing a custom message here
event.returnValue = "";
preventUnload(event);
}
if (this.isCollaborating || this.portal.roomId) {
@@ -199,6 +249,22 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
trackEvent("share", "room closed");
this.props.onRoomClose?.();
const elements = this.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (isImageElement(element) && element.status === "saved") {
return mutateElement(element, { status: "pending" }, false);
}
return element;
});
this.excalidrawAPI.updateScene({
elements,
commitToHistory: false,
});
}
};
@@ -213,7 +279,26 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
});
this.isCollaborating = false;
}
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
this.fileManager.reset();
};
private fetchImageFilesFromFirebase = async (scene: {
elements: readonly ExcalidrawElement[];
}) => {
const unfetchedImages = scene.elements
.filter((element) => {
return (
isInitializedImageElement(element) &&
!this.fileManager.isFileHandled(element.fileId) &&
!element.isDeleted &&
element.status === "saved"
);
})
.map((element) => (element as InitializedExcalidrawImageElement).fileId);
return await this.fileManager.getFiles(unfetchedImages);
};
private initializeSocketClient = async (
@@ -267,7 +352,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
console.error(error);
}
} else {
const elements = this.excalidrawAPI.getSceneElements();
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") {
return mutateElement(
element,
{ status: "pending" },
/* informMutation */ false,
);
}
return element;
});
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
@@ -277,11 +371,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
elements,
commitToHistory: true,
});
this.broadcastElements(elements);
const syncableElements = this.getSyncableElements(elements);
this.saveCollabRoomToFirebase(syncableElements);
}
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_UPDATE message
this.socketInitializationTimer = setTimeout(() => {
this.socketInitializationTimer = window.setTimeout(() => {
this.initializeSocket();
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
@@ -446,6 +545,23 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
return newElements as ReconciledElements;
};
private loadImageFiles = throttle(async () => {
const {
loadedFiles,
erroredFiles,
} = await this.fetchImageFilesFromFirebase({
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
});
this.excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI: this.excalidrawAPI,
erroredFiles,
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
});
}, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = (
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
@@ -460,6 +576,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles();
};
private onPointerMove = () => {
@@ -622,6 +740,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.contextValue.initializeSocketClient = this.initializeSocketClient;
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
this.contextValue.broadcastElements = this.broadcastElements;
this.contextValue.fetchImageFilesFromFirebase = this.fetchImageFilesFromFirebase;
return this.contextValue;
};
+38 -1
View File
@@ -7,9 +7,11 @@ import {
import CollabWrapper from "./CollabWrapper";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants";
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
import { UserIdleState } from "../../types";
import { trackEvent } from "../../analytics";
import { throttle } from "lodash";
import { mutateElement } from "../../element/mutateElement";
class Portal {
collab: CollabWrapper;
@@ -87,6 +89,39 @@ class Portal {
}
}
queueFileUpload = throttle(async () => {
try {
await this.collab.fileManager.saveFiles({
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
files: this.collab.excalidrawAPI.getFiles(),
});
} catch (error) {
this.collab.excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
}
this.collab.excalidrawAPI.updateScene({
elements: this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return mutateElement(
element,
{ status: "saved" },
/* informMutation */ false,
);
}
return element;
}),
});
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
sceneType: SCENE.INIT | SCENE.UPDATE,
syncableElements: ExcalidrawElement[],
@@ -126,6 +161,8 @@ class Portal {
data as SocketUpdateData,
);
this.queueFileUpload();
if (syncAll && this.collab.isCollaborating) {
await Promise.all([
broadcastPromise,
@@ -2,69 +2,81 @@ 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 { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } 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 };
};
import { encryptData, generateEncryptionKey } from "../../data/encryption";
import { isInitializedImageElement } from "../../element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager";
import { MIME_TYPES } from "../../constants";
const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const firebase = await loadFirebaseStorage();
const id = `${nanoid(12)}`;
const key = (await generateEncryptionKey())!;
const encryptionKey = (await generateEncryptionKey())!;
const encryptedData = await encryptData(
key,
serializeAsJSON(elements, appState),
encryptionKey,
serializeAsJSON(elements, appState, files, "database"),
);
const blob = new Blob([encryptedData.iv, encryptedData.blob], {
type: "application/octet-stream",
});
const blob = new Blob(
[encryptedData.iv, new Uint8Array(encryptedData.encryptedBuffer)],
{
type: MIME_TYPES.binary,
},
);
await firebase
.storage()
.ref(`/migrations/scenes/${id}`)
.put(blob, {
customMetadata: {
data: JSON.stringify({ version: 1, name: appState.name }),
data: JSON.stringify({ version: 2, name: appState.name }),
created: Date.now().toString(),
},
});
window.open(`https://plus.excalidraw.com/import?excalidraw=${id},${key}`);
const filesMap = new Map<FileId, BinaryFileData>();
for (const element of elements) {
if (isInitializedImageElement(element) && files[element.fileId]) {
filesMap.set(element.fileId, files[element.fileId]);
}
}
if (filesMap.size) {
const filesToUpload = await encodeFilesForUpload({
files: filesMap,
encryptionKey,
maxBytes: FILE_UPLOAD_MAX_BYTES,
});
await saveFilesToFirebase({
prefix: `/migrations/files/scenes/${id}`,
files: filesToUpload,
});
}
window.open(
`https://plus.excalidraw.com/import?excalidraw=${id},${encryptionKey}`,
);
};
export const ExportToExcalidrawPlus: React.FC<{
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
onError: (error: Error) => void;
}> = ({ elements, appState, onError }) => {
}> = ({ elements, appState, files, onError }) => {
return (
<Card color="indigo">
<div className="Card-icon">{excalidrawPlusIcon}</div>
@@ -80,7 +92,7 @@ export const ExportToExcalidrawPlus: React.FC<{
showAriaLabel={true}
onClick={async () => {
try {
await exportToExcalidrawPlus(elements, appState);
await exportToExcalidrawPlus(elements, appState, files);
} catch (error) {
console.error(error);
onError(new Error(t("exportDialog.excalidrawplus_exportError")));
@@ -1,9 +1,11 @@
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: "light" | "dark"; dir: string }) => (
({ theme, dir }: { theme: Theme; dir: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
@@ -25,18 +27,18 @@ export const GitHubCorner = React.memo(
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === "light" ? oc.gray[6] : oc.gray[7]}
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === "light" ? oc.white : "var(--default-bg-color)"}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
</a>
</svg>
+249
View File
@@ -0,0 +1,249 @@
import { compressData } from "../../data/encode";
import { mutateElement } from "../../element/mutateElement";
import { isInitializedImageElement } from "../../element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "../../element/types";
import { t } from "../../i18n";
import {
BinaryFileData,
BinaryFileMetadata,
ExcalidrawImperativeAPI,
BinaryFiles,
} from "../../types";
export class FileManager {
/** files being fetched */
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
/** files being saved */
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
/* files already saved to persistent storage */
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private _getFiles;
private _saveFiles;
constructor({
getFiles,
saveFiles,
}: {
getFiles: (
fileIds: FileId[],
) => Promise<{
loadedFiles: BinaryFileData[];
erroredFiles: Map<FileId, true>;
}>;
saveFiles: (data: {
addedFiles: Map<FileId, BinaryFileData>;
}) => Promise<{
savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, true>;
}>;
}) {
this._getFiles = getFiles;
this._saveFiles = saveFiles;
}
/**
* returns whether file is already saved or being processed
*/
isFileHandled = (id: FileId) => {
return (
this.savedFiles.has(id) ||
this.fetchingFiles.has(id) ||
this.savingFiles.has(id) ||
this.erroredFiles.has(id)
);
};
isFileSaved = (id: FileId) => {
return this.savedFiles.has(id);
};
saveFiles = async ({
elements,
files,
}: {
elements: readonly ExcalidrawElement[];
files: BinaryFiles;
}) => {
const addedFiles: Map<FileId, BinaryFileData> = new Map();
for (const element of elements) {
if (
isInitializedImageElement(element) &&
files[element.fileId] &&
!this.isFileHandled(element.fileId)
) {
addedFiles.set(element.fileId, files[element.fileId]);
this.savingFiles.set(element.fileId, true);
}
}
try {
const { savedFiles, erroredFiles } = await this._saveFiles({
addedFiles,
});
for (const [fileId] of savedFiles) {
this.savedFiles.set(fileId, true);
}
return {
savedFiles,
erroredFiles,
};
} finally {
for (const [fileId] of addedFiles) {
this.savingFiles.delete(fileId);
}
}
};
getFiles = async (
ids: FileId[],
): Promise<{
loadedFiles: BinaryFileData[];
erroredFiles: Map<FileId, true>;
}> => {
if (!ids.length) {
return {
loadedFiles: [],
erroredFiles: new Map(),
};
}
for (const id of ids) {
this.fetchingFiles.set(id, true);
}
try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
for (const file of loadedFiles) {
this.savedFiles.set(file.id, true);
}
for (const [fileId] of erroredFiles) {
this.erroredFiles.set(fileId, true);
}
return { loadedFiles, erroredFiles };
} finally {
for (const id of ids) {
this.fetchingFiles.delete(id);
}
}
};
/** a file element prevents unload only if it's being saved regardless of
* its `status`. This ensures that elements who for any reason haven't
* beed set to `saved` status don't prevent unload in future sessions.
* Technically we should prevent unload when the origin client haven't
* yet saved the `status` update to storage, but that should be taken care
* of during regular beforeUnload unsaved files check.
*/
shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => {
return elements.some((element) => {
return (
isInitializedImageElement(element) &&
!element.isDeleted &&
this.savingFiles.has(element.fileId)
);
});
};
/**
* helper to determine if image element status needs updating
*/
shouldUpdateImageElementStatus = (
element: ExcalidrawElement,
): element is InitializedExcalidrawImageElement => {
return (
isInitializedImageElement(element) &&
this.isFileSaved(element.fileId) &&
element.status === "pending"
);
};
reset() {
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
this.erroredFiles.clear();
}
}
export const encodeFilesForUpload = async ({
files,
maxBytes,
encryptionKey,
}: {
files: Map<FileId, BinaryFileData>;
maxBytes: number;
encryptionKey: string;
}) => {
const processedFiles: {
id: FileId;
buffer: Uint8Array;
}[] = [];
for (const [id, fileData] of files) {
const buffer = new TextEncoder().encode(fileData.dataURL);
const encodedFile = await compressData<BinaryFileMetadata>(buffer, {
encryptionKey,
metadata: {
id,
mimeType: fileData.mimeType,
created: Date.now(),
},
});
if (buffer.byteLength > maxBytes) {
throw new Error(
t("errors.fileTooBig", {
maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
}),
);
}
processedFiles.push({
id,
buffer: encodedFile,
});
}
return processedFiles;
};
export const updateStaleImageStatuses = (params: {
excalidrawAPI: ExcalidrawImperativeAPI;
erroredFiles: Map<FileId, true>;
elements: readonly ExcalidrawElement[];
}) => {
if (!params.erroredFiles.size) {
return;
}
params.excalidrawAPI.updateScene({
elements: params.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (
isInitializedImageElement(element) &&
params.erroredFiles.has(element.fileId)
) {
return mutateElement(
element,
{
status: "error",
},
false,
);
}
return element;
}),
});
};
+118 -12
View File
@@ -1,26 +1,45 @@
import { getImportedKey } from "../data";
import { createIV } from "./index";
import { ExcalidrawElement } from "../../element/types";
import { ExcalidrawElement, FileId } from "../../element/types";
import { getSceneVersion } from "../../element";
import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore";
import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../data/encode";
import { getImportedKey, createIV } from "../../data/encryption";
import { MIME_TYPES } from "../../constants";
// private
// -----------------------------------------------------------------------------
const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
let firebasePromise: Promise<
typeof import("firebase/app").default
> | null = null;
let firestorePromise: Promise<any> | null = null;
let firebseStoragePromise: Promise<any> | null = null;
let firestorePromise: Promise<any> | null | true = null;
let firebaseStoragePromise: Promise<any> | null | true = null;
let isFirebaseInitialized = false;
const _loadFirebase = async () => {
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
firebase.initializeApp(firebaseConfig);
if (!isFirebaseInitialized) {
try {
firebase.initializeApp(FIREBASE_CONFIG);
} catch (error) {
// trying initialize again throws. Usually this is harmless, and happens
// mainly in dev (HMR)
if (error.code === "app/duplicate-app") {
console.warn(error.name, error.code);
} else {
throw error;
}
}
isFirebaseInitialized = true;
}
return firebase;
};
@@ -42,18 +61,24 @@ const loadFirestore = async () => {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
}
if (firestorePromise !== true) {
await firestorePromise;
firestorePromise = true;
}
return firebase;
};
export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase();
if (!firebseStoragePromise) {
firebseStoragePromise = import(
if (!firebaseStoragePromise) {
firebaseStoragePromise = import(
/* webpackChunkName: "storage" */ "firebase/storage"
);
await firebseStoragePromise;
}
if (firebaseStoragePromise !== true) {
await firebaseStoragePromise;
firebaseStoragePromise = true;
}
return firebase;
};
@@ -87,7 +112,7 @@ const encryptElements = async (
const decryptElements = async (
key: string,
iv: Uint8Array,
ciphertext: ArrayBuffer,
ciphertext: ArrayBuffer | Uint8Array,
): Promise<readonly ExcalidrawElement[]> => {
const importedKey = await getImportedKey(key, "decrypt");
const decrypted = await window.crypto.subtle.decrypt(
@@ -100,7 +125,7 @@ const decryptElements = async (
);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted) as any,
new Uint8Array(decrypted),
);
return JSON.parse(decodedData);
};
@@ -113,6 +138,7 @@ export const isSavedToFirebase = (
): boolean => {
if (portal.socket && portal.roomId && portal.roomKey) {
const sceneVersion = getSceneVersion(elements);
return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
}
// if no room exists, consider the room saved so that we don't unnecessarily
@@ -120,6 +146,42 @@ export const isSavedToFirebase = (
return true;
};
export const saveFilesToFirebase = async ({
prefix,
files,
}: {
prefix: string;
files: { id: FileId; buffer: Uint8Array }[];
}) => {
const firebase = await loadFirebaseStorage();
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>();
await Promise.all(
files.map(async ({ id, buffer }) => {
try {
await firebase
.storage()
.ref(`${prefix}/${id}`)
.put(
new Blob([buffer], {
type: MIME_TYPES.binary,
}),
{
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
savedFiles.set(id, true);
} catch (error) {
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
};
export const saveToFirebase = async (
portal: Portal,
elements: readonly ExcalidrawElement[],
@@ -198,3 +260,47 @@ export const loadFromFirebase = async (
return restoreElements(elements, null);
};
export const loadFilesFromFirebase = async (
prefix: string,
decryptionKey: string,
filesIds: readonly FileId[],
) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
await Promise.all(
[...new Set(filesIds)].map(async (id) => {
try {
const url = `https://firebasestorage.googleapis.com/v0/b/${
FIREBASE_CONFIG.storageBucket
}/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`;
const response = await fetch(`${url}?alt=media`);
if (response.status < 400) {
const arrayBuffer = await response.arrayBuffer();
const { data, metadata } = await decompressData<BinaryFileMetadata>(
new Uint8Array(arrayBuffer),
{
decryptionKey,
},
);
const dataURL = new TextDecoder().decode(data) as DataURL;
loadedFiles.push({
mimeType: metadata.mimeType || MIME_TYPES.binary,
id,
dataURL,
created: metadata?.created || Date.now(),
});
}
} catch (error) {
erroredFiles.set(id, true);
console.error(error);
}
}),
);
return { loadedFiles, erroredFiles };
};
+51 -47
View File
@@ -1,9 +1,24 @@
import {
createIV,
generateEncryptionKey,
getImportedKey,
IV_LENGTH_BYTES,
} from "../../data/encryption";
import { serializeAsJSON } from "../../data/json";
import { restore } from "../../data/restore";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import { isInitializedImageElement } from "../../element/typeChecks";
import { ExcalidrawElement, FileId } from "../../element/types";
import { t } from "../../i18n";
import { AppState, UserIdleState } from "../../types";
import {
AppState,
BinaryFileData,
BinaryFiles,
UserIdleState,
} from "../../types";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
@@ -17,18 +32,6 @@ const generateRandomID = async () => {
return Array.from(arr, byteToHex).join("");
};
export const generateEncryptionKey = async () => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
return (await window.crypto.subtle.exportKey("jwk", key)).k;
};
export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
export type EncryptedData = {
@@ -79,13 +82,6 @@ export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSour
_brand: "socketUpdateData";
};
const IV_LENGTH_BYTES = 12; // 96 bits
export const createIV = () => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
export const encryptAESGEM = async (
data: Uint8Array,
key: string,
@@ -122,7 +118,7 @@ export const decryptAESGEM = async (
);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted) as any,
new Uint8Array(decrypted),
);
return JSON.parse(decodedData);
} catch (error) {
@@ -162,26 +158,8 @@ export const getCollaborationLink = (data: {
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
};
export const getImportedKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: 128,
},
false, // extractable
[usage],
);
export const decryptImported = async (
iv: ArrayBuffer,
iv: ArrayBuffer | Uint8Array,
encrypted: ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
@@ -227,7 +205,7 @@ const importFromBackend = async (
// We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted) as any,
new Uint8Array(decrypted),
);
data = JSON.parse(string);
} else {
@@ -270,6 +248,10 @@ export const loadScene = async (
return {
elements: data.elements,
appState: data.appState,
// note: this will always be empty because we're not storing files
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
commitToHistory: false,
};
};
@@ -277,11 +259,12 @@ export const loadScene = async (
export const exportToBackend = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const json = serializeAsJSON(elements, appState);
const json = serializeAsJSON(elements, appState, files, "database");
const encoded = new TextEncoder().encode(json);
const key = await window.crypto.subtle.generateKey(
const cryptoKey = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
@@ -298,7 +281,7 @@ export const exportToBackend = async (
name: "AES-GCM",
iv,
},
key,
cryptoKey,
encoded,
);
@@ -308,9 +291,24 @@ export const exportToBackend = async (
// We use jwk encoding to be able to extract just the base64 encoded key.
// We will hardcode the rest of the attributes when importing back the key.
const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
const exportedKey = await window.crypto.subtle.exportKey("jwk", cryptoKey);
try {
const filesMap = new Map<FileId, BinaryFileData>();
for (const element of elements) {
if (isInitializedImageElement(element) && files[element.fileId]) {
filesMap.set(element.fileId, files[element.fileId]);
}
}
const encryptionKey = exportedKey.k!;
const filesToUpload = await encodeFilesForUpload({
files: filesMap,
encryptionKey,
maxBytes: FILE_UPLOAD_MAX_BYTES,
});
const response = await fetch(BACKEND_V2_POST, {
method: "POST",
body: payload,
@@ -320,8 +318,14 @@ export const exportToBackend = async (
const url = new URL(window.location.href);
// We need to store the key (and less importantly the id) as hash instead
// of queryParam in order to never send it to the server
url.hash = `json=${json.id},${exportedKey.k!}`;
url.hash = `json=${json.id},${encryptionKey}`;
const urlString = url.toString();
await saveFilesToFirebase({
prefix: `/files/shareLinks/${json.id}`,
files: filesToUpload,
});
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
} else if (json.error_class === "RequestTooLargeError") {
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
+263 -46
View File
@@ -1,11 +1,5 @@
import LanguageDetector from "i18next-browser-languagedetector";
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { ErrorDialog } from "../components/ErrorDialog";
@@ -22,6 +16,7 @@ import { loadFromBlob } from "../data/blob";
import { ImportedDataState } from "../data/types";
import {
ExcalidrawElement,
FileId,
NonDeletedExcalidrawElement,
} from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
@@ -30,14 +25,24 @@ import Excalidraw, {
defaultLang,
languages,
} from "../packages/excalidraw/index";
import { AppState, LibraryItems, ExcalidrawImperativeAPI } from "../types";
import {
AppState,
LibraryItems,
ExcalidrawImperativeAPI,
BinaryFileData,
BinaryFiles,
} from "../types";
import {
debounce,
getVersion,
preventUnload,
ResolvablePromise,
resolvablePromise,
} from "../utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
import {
FIREBASE_STORAGE_PREFIXES,
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
} from "./app_constants";
import CollabWrapper, {
CollabAPI,
CollabContext,
@@ -57,6 +62,64 @@ import { shield } from "../components/icons";
import "./index.scss";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import { getMany, set, del, keys, createStore } from "idb-keyval";
import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
import { mutateElement } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
const filesStore = createStore("files-db", "files-store");
const clearObsoleteFilesFromIndexedDB = async (opts: {
currentFileIds: FileId[];
}) => {
const allIds = await keys(filesStore);
for (const id of allIds) {
if (!opts.currentFileIds.includes(id as FileId)) {
del(id, filesStore);
}
}
};
const localFileStorage = new FileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
(filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
loadedFiles.push(data);
} else {
erroredFiles.set(id, true);
}
});
return { loadedFiles, erroredFiles };
},
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
await Promise.all(
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
} catch (error) {
console.error(error);
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
},
});
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {
@@ -67,8 +130,20 @@ languageDetector.init({
});
const saveDebounced = debounce(
(elements: readonly ExcalidrawElement[], state: AppState) => {
saveToLocalStorage(elements, state);
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveToLocalStorage(elements, appState);
await localFileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
@@ -79,7 +154,12 @@ const onBlur = () => {
const initializeScene = async (opts: {
collabAPI: CollabAPI;
}): Promise<ImportedDataState | null> => {
}): Promise<
{ scene: ImportedDataState | null } & (
| { isExternalScene: true; id: string; key: string }
| { isExternalScene: false; id?: null; key?: null }
)
> => {
const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id");
const jsonBackendMatch = window.location.hash.match(
@@ -146,23 +226,38 @@ const initializeScene = async (opts: {
!scene.elements.length ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
) {
return data;
return { scene: data, isExternalScene };
}
} catch (error) {
return {
appState: {
errorMessage: t("alerts.invalidSceneUrl"),
scene: {
appState: {
errorMessage: t("alerts.invalidSceneUrl"),
},
},
isExternalScene,
};
}
}
if (roomLinkData) {
return opts.collabAPI.initializeSocketClient(roomLinkData);
return {
scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
isExternalScene: true,
id: roomLinkData.roomId,
key: roomLinkData.roomKey,
};
} else if (scene) {
return scene;
return isExternalScene && jsonBackendMatch
? {
scene,
isExternalScene,
id: jsonBackendMatch[1],
key: jsonBackendMatch[2],
}
: { scene, isExternalScene: false };
}
return null;
return { scene: null, isExternalScene: false };
};
const PlusLinkJSX = (
@@ -213,20 +308,84 @@ const ExcalidrawWrapper = () => {
return;
}
initializeScene({ collabAPI }).then((scene) => {
if (scene) {
try {
scene.libraryItems =
JSON.parse(
localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
) as string,
) || [];
} catch (e) {
console.error(e);
const loadImages = (
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
return;
}
if (collabAPI.isCollaborating()) {
if (data.scene.elements) {
collabAPI
.fetchImageFilesFromFirebase({
elements: data.scene.elements,
})
.then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
} else {
const fileIds =
data.scene.elements?.reduce((acc, element) => {
if (isInitializedImageElement(element)) {
return acc.concat(element.fileId);
}
return acc;
}, [] as FileId[]) || [];
if (data.isExternalScene) {
loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
fileIds,
).then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
} else if (isInitialLoad) {
if (fileIds.length) {
localFileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
// on fresh load, clear unused files from IDB (from previous
// session)
clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds });
}
}
initialStatePromiseRef.current.promise.resolve(scene);
try {
data.scene.libraryItems =
JSON.parse(
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
) || [];
} catch (e) {
console.error(e);
}
};
initializeScene({ collabAPI }).then((data) => {
loadImages(data, /* isInitialLoad */ true);
initialStatePromiseRef.current.promise.resolve(data.scene);
});
const onHashChange = (event: HashChangeEvent) => {
@@ -241,11 +400,12 @@ const ExcalidrawWrapper = () => {
window.history.replaceState({}, "", event.oldURL);
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
} else {
initializeScene({ collabAPI }).then((scene) => {
if (scene) {
initializeScene({ collabAPI }).then((data) => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
...scene,
appState: restoreAppState(scene.appState, null),
...data.scene,
appState: restoreAppState(data.scene.appState, null),
});
}
});
@@ -267,6 +427,23 @@ const ExcalidrawWrapper = () => {
};
}, [collabAPI, excalidrawAPI]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
saveDebounced.flush();
if (
excalidrawAPI &&
localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements())
) {
preventUnload(event);
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
return () => {
window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
};
}, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
@@ -274,20 +451,43 @@ const ExcalidrawWrapper = () => {
const onChange = (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
if (collabAPI?.isCollaborating()) {
collabAPI.broadcastElements(elements);
} else {
// collab scenes are persisted to the server, so we don't have to persist
// them locally, which has the added benefit of not overwriting whatever
// the user was working on before joining
saveDebounced(elements, appState);
saveDebounced(elements, appState, files, () => {
if (excalidrawAPI) {
let didChange = false;
const elements = excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (localFileStorage.shouldUpdateImageElementStatus(element)) {
didChange = true;
return mutateElement(
element,
{ status: "saved" },
/* informMutation */ false,
);
}
return element;
});
if (didChange) {
excalidrawAPI.updateScene({
elements,
});
}
}
});
}
};
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
canvas: HTMLCanvasElement | null,
) => {
if (exportedElements.length === 0) {
@@ -295,12 +495,16 @@ const ExcalidrawWrapper = () => {
}
if (canvas) {
try {
await exportToBackend(exportedElements, {
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
});
await exportToBackend(
exportedElements,
{
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
},
files,
);
} catch (error) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
@@ -313,6 +517,9 @@ const ExcalidrawWrapper = () => {
const renderTopRightUI = useCallback(
(isMobile: boolean, appState: AppState) => {
if (isMobile) {
return null;
}
return (
<div
style={{
@@ -412,6 +619,10 @@ const ExcalidrawWrapper = () => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
};
const onRoomClose = useCallback(() => {
localFileStorage.reset();
}, []);
return (
<>
<Excalidraw
@@ -425,11 +636,12 @@ const ExcalidrawWrapper = () => {
canvasActions: {
export: {
onExportToBackend,
renderCustomUI: (elements, appState) => {
renderCustomUI: (elements, appState, files) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
files={files}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
@@ -452,7 +664,12 @@ const ExcalidrawWrapper = () => {
onLibraryChange={onLibraryChange}
autoFocus={true}
/>
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{excalidrawAPI && (
<CollabWrapper
excalidrawAPI={excalidrawAPI}
onRoomClose={onRoomClose}
/>
)}
{errorMessage && (
<ErrorDialog
message={errorMessage}
+39
View File
@@ -47,6 +47,11 @@ type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
Required<Pick<T, RK>>;
type MarkNonNullable<T, K extends keyof T> = {
[P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
} &
{ [P in keyof T]: T[P] };
// PNG encoding/decoding
// -----------------------------------------------------------------------------
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
@@ -91,3 +96,37 @@ interface Blob {
}
declare module "*.scss";
// --------------------------------------------------------------------------—
// ensure Uint8Array isn't assignable to ArrayBuffer
// (due to TS structural typing)
// https://github.com/microsoft/TypeScript/issues/31311#issuecomment-490690695
interface ArrayBuffer {
private _brand?: "ArrayBuffer";
}
interface Uint8Array {
private _brand?: "Uint8Array";
}
// --------------------------------------------------------------------------—
// https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848
declare module "image-blob-reduce" {
import { PicaResizeOptions } from "pica";
namespace ImageBlobReduce {
interface ImageBlobReduce {
toBlob(file: File, options: ImageBlobReduceOptions): Promise<Blob>;
}
interface ImageBlobReduceStatic {
new (options?: any): ImageBlobReduce;
(options?: any): ImageBlobReduce;
}
interface ImageBlobReduceOptions extends PicaResizeOptions {
max: number;
}
}
const reduce: ImageBlobReduce.ImageBlobReduceStatic;
export = reduce;
}
+1
View File
@@ -66,6 +66,7 @@ const canvas = exportToCanvas(
width: 0,
height: 0,
},
{}, // files
{
exportBackground: true,
viewBackgroundColor: "#ffffff",
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom";
import ExcalidrawApp from "./excalidraw-app";
+5 -5
View File
@@ -45,6 +45,7 @@ export const KEYS = {
D: "d",
E: "e",
G: "g",
I: "i",
L: "l",
O: "o",
P: "p",
@@ -66,13 +67,12 @@ export const isArrowKey = (key: string) =>
key === KEYS.ARROW_DOWN ||
key === KEYS.ARROW_UP;
export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) =>
event.altKey;
export const getResizeWithSidesSameLengthKey = (
event: MouseEvent | KeyboardEvent,
) => event.shiftKey;
export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
event.shiftKey;
export const getRotateWithDiscreteAngleKey = (
export const shouldRotateWithDiscreteAngle = (
event: MouseEvent | KeyboardEvent,
) => event.shiftKey;
+18 -3
View File
@@ -136,7 +136,9 @@
"darkMode": "Dark mode",
"lightMode": "Light mode",
"zenMode": "Zen mode",
"exitZenMode": "Exit zen mode"
"exitZenMode": "Exit zen mode",
"cancel": "Cancel",
"clear": "Clear"
},
"alerts": {
"clearReset": "This will clear the whole canvas. Are you sure?",
@@ -154,14 +156,22 @@
"errorAddingToLibrary": "Couldn't add item to the library",
"errorRemovingFromLibrary": "Couldn't remove item from the library",
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
"imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",
"imageDoesNotContainScene": "This image does not seem to contain any scene data. Have you enabled scene embedding during export?",
"cannotRestoreFromImage": "Scene couldn't be restored from this image file",
"invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
"resetLibrary": "This will clear your library. Are you sure?",
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
},
"errors": {
"unsupportedFileType": "Unsupported file type.",
"imageInsertError": "Couldn't insert image. Try again later...",
"fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
"invalidSVGString": "errors.invalidSVGString"
},
"toolBar": {
"selection": "Selection",
"image": "Insert image",
"rectangle": "Rectangle",
"diamond": "Diamond",
"ellipse": "Ellipse",
@@ -186,10 +196,12 @@
"linearElementMulti": "Click on last point or press Escape or Enter to finish",
"lockAngle": "You can constrain angle by holding SHIFT",
"resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
"resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
"rotate": "You can constrain angles by holding SHIFT while rotating",
"lineEditor_info": "Double-click or press Enter to edit points",
"lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move",
"lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points"
"lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points",
"placeImage": "Click to place the image, or click and drag to set its size manually"
},
"canvasError": {
"cannotShowPreview": "Cannot show preview",
@@ -256,6 +268,9 @@
"zoomToFit": "Zoom to fit all elements",
"zoomToSelection": "Zoom to selection"
},
"clearCanvasDialog": {
"title": "Clear Canvas"
},
"encrypted": {
"tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them.",
"link": "Blog post on end-to-end encryption in Excalidraw"
+47
View File
@@ -11,12 +11,58 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section.
-->
## Unreleased
- Image support.
NOTE: the unreleased API is highly unstable and may change significantly before the next stable release. As such it's largely undocumented at this point. You are encouraged to read through the [PR](https://github.com/excalidraw/excalidraw/pull/4011) description if you want to know more about the internals.
General notes:
- File data are encoded as DataURLs (base64) for portability reasons.
[ExcalidrawAPI](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#ref):
- added `getFiles()` to get current `BinaryFiles` (`Record<FileId, BinaryFileData>`). It may contain files that aren't referenced by any element, so if you're persisting the files to a storage, you should compare them against stored elements.
Excalidraw app props:
- added `generateIdForFile(file: File)` optional prop so you can generate your own ids for added files.
- `onChange(elements, appState, files)` prop callback is now passed `BinaryFiles` as third argument.
- `onPaste(data, event)` data prop should contain `data.files` (`BinaryFiles`) if the elements pasted are referencing new files.
- `initialData` object now supports additional `files` (`BinaryFiles`) attribute.
Other notes:
- `.excalidraw` files may now contain top-level `files` key in format of `Record<FileId, BinaryFileData>` when exporting any (image) elements.
- Changes were made to various export utilityies exported from the package so that they take `files`. For now, TypeScript should help you figure the changes out.
- Expose App instance from [ExcalidrawAPI](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#ref) [#4088](https://github.com/excalidraw/excalidraw/pull/4088).
## Excalidraw API
### Features
- Export [`isLinearElement`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#isLinearElement) and [`getNonDeletedElements`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#getNonDeletedElements).
- Support [`renderTopRightUI`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#renderTopRightUI) in mobile UI.
- Export `THEME` constant from the package so host can use this when passing the theme.
#### BREAKING CHANGE
The `Appearance` type is now removed and renamed to `Theme` so `Theme` type needs to be used.
---
## 0.10.0 (2021-10-13)
## Excalidraw API
### Fixes
- Don't show save file to disk button in export dialog when `saveFileToDisk` passed as `false` in [`UIOptions.canvasActions.export`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportOpts).
- [`onPaste`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onPaste) prop should return false to prevent the native excalidraw paste action [#3974](https://github.com/excalidraw/excalidraw/pull/3974).
#### BREAKING CHANGE
@@ -360,6 +406,7 @@ Please add the latest change on the top under the correct section.
- #### BREAKING CHANGE
Use `location.hash` when importing libraries to fix installation issues. This will require host apps to add a `hashchange` listener and call the newly exposed `excalidrawAPI.importLibrary(url)` API when applicable [#3320](https://github.com/excalidraw/excalidraw/pull/3320). Check the [readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#importlibrary) for more details.
- Append `location.pathname` to `libraryReturnUrl` default url [#3325](https://github.com/excalidraw/excalidraw/pull/3325).
- Support image elements [#3424](https://github.com/excalidraw/excalidraw/pull/3424).
### Build
+176 -104
View File
@@ -371,7 +371,7 @@ To view the full example visit :point_down:
| [`zenModeEnabled`](#zenModeEnabled) | boolean | | This implies if the zen mode is enabled |
| [`gridModeEnabled`](#gridModeEnabled) | boolean | | This implies if the grid mode is enabled |
| [`libraryReturnUrl`](#libraryReturnUrl) | string | | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`theme`](#theme) | `light` or `dark` | | The theme of the Excalidraw component |
| [`theme`](#theme) | [THEME.LIGHT](#THEME-1) &#124; [THEME.LIGHT](#THEME-1) | [THEME.LIGHT](#THEME-1) | The theme of the Excalidraw component |
| [`name`](#name) | string | | Name of the drawing |
| [`UIOptions`](#UIOptions) | <pre>{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }</pre> | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L129) | To customise UI options. Currently we support customising [`canvas actions`](#canvasActions) |
| [`onPaste`](#onPaste) | <pre>(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L17">ClipboardData</a>, event: ClipboardEvent &#124; null) => boolean</pre> | | Callback to be triggered if passed when the something is pasted in to the scene |
@@ -379,6 +379,7 @@ To view the full example visit :point_down:
| [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. |
| [`onLibraryChange`](#onLibraryChange) | <pre>(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void &#124; Promise&lt;any&gt; </pre> | | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`autoFocus`](#autoFocus) | boolean | false | Implies whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateIdForFile) | `(file: File) => string | Promise<string>` | Allows you to override `id` generation for files added on canvas |
### Dimensions of Excalidraw
@@ -448,7 +449,9 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the
| --- | --- | --- |
| ready | `boolean` | This is set to true once Excalidraw is rendered |
| readyPromise | [resolvablePromise](https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317) | This promise will be resolved with the api once excalidraw has rendered. This will be helpful when you want do some action on the host app once this promise resolves. For this to work you will have to pass ref as shown [here](#readyPromise) |
| [updateScene](#updateScene) | <pre>(<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>)) => void </pre> | updates the scene with the sceneData |
| [app](#app) | [`InstanceType<typeof App>`](https://github.com/excalidraw/excalidraw/blob/master/src/components/App.tsx) | Excalidraw App instance |
| [updateScene](#updateScene) | <pre>(scene: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>) => void </pre> | updates the scene with the sceneData |
| [addFiles](#addFiles) | <pre>(files: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts">BinaryFileData</a>) => void </pre> | add files data to the appState |
| resetScene | `({ resetLoadingState: boolean }) => void` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
| getSceneElementsIncludingDeleted | <pre> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a></pre> | Returns all the elements including the deleted in the scene |
| getSceneElements | <pre> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a></pre> | Returns all the elements excluding the deleted in the scene |
@@ -468,10 +471,14 @@ const excalidrawRef = { current: { readyPromise: <a href="https://github.com/exc
Since plain object is passed as a `ref`, the `readyPromise` is resolved as soon as the component is mounted. Most of the time you will not need this unless you have a specific use case where you can't pass the `ref` in the react way and want to do some action on the host when this promise resolves. You can check the [example](https://codesandbox.io/s/eexcalidraw-resolvable-promise-d0qg3?file=/src/App.js) for the usage.
#### `app`
The App instance. Generally, you shouldn't need to use this, but there may be cases you need to do something for which we don't yet expose a public API.
### `updateScene`
<pre>
(<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>)) => void
(scene: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>) => void
</pre>
You can use this function to update the scene with the sceneData. It accepts the below attributes.
@@ -483,6 +490,12 @@ You can use this function to update the scene with the sceneData. It accepts the
| `collaborators` | <pre>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L29">Collaborator></a></pre> | The list of collaborators to be updated in the scene. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
### `addFiles`
<pre>(files: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts">BinaryFileData</a>) => void </pre>
Adds supplied files data to the `appState.files` cache, on top of existing files present in the cache.
#### `onCollabButtonClick`
This callback is triggered when clicked on the collab button in excalidraw. If not supplied, the collab dialog button is not rendered.
@@ -564,7 +577,7 @@ If supplied, this URL will be used when user tries to install a library from [li
#### `theme`
This prop controls Excalidraw's theme. When supplied, the value takes precedence over `intialData.appState.theme`, the theme will be fully controlled by the host app, and users won't be able to toggle it from within the app.
This prop controls Excalidraw's theme. When supplied, the value takes precedence over `intialData.appState.theme`, the theme will be fully controlled by the host app, and users won't be able to toggle it from within the app. You can use [`THEME`](#THEME-1) to specify the theme.
#### `name`
@@ -612,11 +625,7 @@ This callback must return a `boolean` value or a [promise](https://developer.moz
In case you want to prevent the excalidraw paste action you must return `false`, it will stop the native excalidraw clipboard management flow (nothing will be pasted into the scene).
### Does it support collaboration ?
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).
### importLibrary
#### `importLibrary`
Imports library from given URL. You should call this on `hashchange`, passing the `addLibrary` value if you detect it as shown below. Optionally pass a CSRF `token` to skip prompting during installation (retrievable via `token` key from the url coming from [https://libraries.excalidraw.com](https://libraries.excalidraw.com/)).
@@ -638,17 +647,17 @@ useEffect(() => {
Try out the [Demo](#Demo) to see it in action.
### detectScroll
#### `detectScroll`
Indicates whether Excalidraw should listen for `scroll` event on the nearest scrollable container in the DOM tree and recompute the coordinates (e.g. to correctly handle the cursor) when the component's position changes. You can disable this when you either know this doesn't affect your app or you want to take care of it yourself (calling the [`refresh()`](#ref) method).
### handleKeyboardGlobally
#### `handleKeyboardGlobally`
Indicates whether to bind keyboard events to `document`. Disabled by default, meaning the keyboard events are bound to the Excalidraw component. This allows for multiple Excalidraw components to live on the same page, and ensures that Excalidraw keyboard handling doesn't collide with your app's (or the browser) when the component isn't focused.
Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar).
### onLibraryChange
#### `onLibraryChange`
Ths callback if supplied will get triggered when the library is updated and has the below signature.
@@ -658,58 +667,25 @@ Ths callback if supplied will get triggered when the library is updated and has
It is invoked with empty items when user clears the library. You can use this callback when you want to do something additional when library is updated for example persisting it to local storage.
### id
#### `id`
The unique id of the excalidraw component. This can be used to identify the excalidraw component, for example importing the library items to the excalidraw component from where it was initiated when you have multiple excalidraw components rendered on the same page as shown in [multiple excalidraw demo](https://codesandbox.io/s/multiple-excalidraw-k1xx5).
### autoFocus
#### `autoFocus`
This prop implies whether to focus the Excalidraw component on page load. Defaults to false.
### Extra API's
#### `generateIdForFile`
#### `getSceneVersion`
Allows you to override `id` generation for files added on canvas (images). By default, an SHA-1 digest of the file is used.
**How to use**
<pre>
import { getSceneVersion } from "@excalidraw/excalidraw-next";
getSceneVersion(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>)
</pre>
This function returns the current scene version.
#### `isInvisiblySmallElement`
**_Signature_**
<pre>
isInvisiblySmallElement(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement</a>): boolean
</pre>
**How to use**
```js
import { isInvisiblySmallElement } from "@excalidraw/excalidraw-next";
```
(file: File) => string | Promise<string>
```
Returns `true` if element is invisibly small (e.g. width & height are zero).
### Does it support collaboration ?
#### `getElementMap`
**_Signature_**
<pre>
getElementsMap(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>): {[id: string]: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement</a>}
</pre>
**How to use**
```js
import { getElementsMap } from "@excalidraw/excalidraw-next";
```
This function returns an object where each element is mapped to its id.
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).
### Restore utilities
@@ -767,19 +743,6 @@ import { restore } from "@excalidraw/excalidraw-next";
This function makes sure elements and state is set to appropriate values and set to default value if not present. It is a combination of [restoreElements](#restoreElements) and [restoreAppState](#restoreAppState).
#### `serializeAsJSON`
**_Signature_**
<pre>
serializeAsJSON({
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>,
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>,
}): string
</pre>
Takes the scene elements and state and returns a JSON string. Deleted `elements`as well as most properties from `AppState` are removed from the resulting JSON. (see [`serializeAsJSON()`](https://github.com/excalidraw/excalidraw/blob/master/src/data/json.ts#L16) source for details).
### Export utilities
#### `exportToCanvas`
@@ -864,7 +827,145 @@ This function returns a promise which resolves to svg of the exported drawing.
| exportWithDarkMode | boolean | false | Indicates whether to export with dark mode |
| exportEmbedScene | boolean | false | Indicates whether scene data should be embedded in svg. This will increase the svg size. |
### FONT_FAMILY
### Extra API's
#### `serializeAsJSON`
**_Signature_**
<pre>
serializeAsJSON({
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>,
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>,
}): string
</pre>
Takes the scene elements and state and returns a JSON string. Deleted `elements`as well as most properties from `AppState` are removed from the resulting JSON. (see [`serializeAsJSON()`](https://github.com/excalidraw/excalidraw/blob/master/src/data/json.ts#L16) source for details).
#### `getSceneVersion`
**How to use**
<pre>
import { getSceneVersion } from "@excalidraw/excalidraw-next";
getSceneVersion(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>)
</pre>
This function returns the current scene version.
#### `isInvisiblySmallElement`
**_Signature_**
<pre>
isInvisiblySmallElement(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement</a>): boolean
</pre>
**How to use**
```js
import { isInvisiblySmallElement } from "@excalidraw/excalidraw-next";
```
Returns `true` if element is invisibly small (e.g. width & height are zero).
#### `getElementMap`
**_Signature_**
<pre>
getElementsMap(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>): {[id: string]: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement</a>}
</pre>
**How to use**
```js
import { getElementsMap } from "@excalidraw/excalidraw-next";
```
This function returns an object where each element is mapped to its id.
#### `loadLibraryFromBlob`
```js
import { loadLibraryFromBlob } from "@excalidraw/excalidraw-next";
```
**_Signature_**
<pre>
loadLibraryFromBlob(blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>)
</pre>
This function loads the library from the blob.
#### `loadFromBlob`
**How to use**
```js
import { loadFromBlob } from "@excalidraw/excalidraw-next";
```
**Signature**
<pre>
loadFromBlob(blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>, localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a> | null)
</pre>
This function loads the scene data from the blob. If you pass `localAppState`, `localAppState` value will be preferred over the `appState` derived from `blob`
#### `getFreeDrawSvgPath`
**How to use**
```js
import { getFreeDrawSvgPath } from "@excalidraw/excalidraw-next";
```
**Signature**
<pre>
getFreeDrawSvgPath(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L127">ExcalidrawFreeDrawElement</a>
</pre>
This function returns the free draw svg path for the element.
#### `isLinearElement`
**How to use**
```js
import { isLinearElement } from "@excalidraw/excalidraw-next";
```
**Signature**
<pre>
isLinearElement(elementType?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L80">ExcalidrawElement</a>): boolean
</pre>
This function returns true if the element is linear type (`arrow` |`line`) else returns false.
#### `getNonDeletedElements`
**How to use**
```js
import { getNonDeletedElements } from "@excalidraw/excalidraw-next";
```
**Signature**
<pre>
getNonDeletedElements(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L80"> readonly ExcalidrawElement[]</a>): as readonly <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L90">NonDeletedExcalidrawElement[]</a>
</pre>
This function returns an array of deleted elements.
### Exported constants
#### `FONT_FAMILY`
**How to use**
@@ -882,51 +983,22 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw-next";
Defaults to `FONT_FAMILY.Virgil` unless passed in `initialData.appState.currentItemFontFamily`.
### loadLibraryFromBlob
```js
import { loadLibraryFromBlob } from "@excalidraw/excalidraw-next";
```
**_Signature_**
<pre>
loadLibraryFromBlob(blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>)
</pre>
This function loads the library from the blob.
### loadFromBlob
#### `THEME`
**How to use**
```js
import { loadFromBlob } from "@excalidraw/excalidraw-next";
import { THEME } from "@excalidraw/excalidraw-next";
```
**Signature**
`THEME` contains all the themes supported by `Excalidraw` as explained below
<pre>
loadFromBlob(blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>, localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a> | null)
</pre>
| Theme | Description |
| ----- | --------------- |
| LIGHT | The light theme |
| DARK | The Dark theme |
This function loads the scene data from the blob. If you pass `localAppState`, `localAppState` value will be preferred over the `appState` derived from `blob`
### getFreeDrawSvgPath
**How to use**
```js
import { getFreeDrawSvgPath } from "@excalidraw/excalidraw-next";
```
**Signature**
<pre>
getFreeDrawSvgPath(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L127">ExcalidrawFreeDrawElement</a>
</pre>
This function returns the free draw svg path for the element.
Defaults to `THEME.LIGHT` unless passed in `initialData.appState.theme`
## Need help?
+13 -2
View File
@@ -34,6 +34,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
handleKeyboardGlobally = false,
onLibraryChange,
autoFocus = false,
generateIdForFile,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@@ -47,7 +48,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
if (canvasActions?.export) {
UIOptions.canvasActions.export.saveFileToDisk =
canvasActions.export?.saveFileToDisk ||
canvasActions.export?.saveFileToDisk ??
DEFAULT_UI_OPTIONS.canvasActions.export.saveFileToDisk;
}
@@ -94,6 +95,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
handleKeyboardGlobally={handleKeyboardGlobally}
onLibraryChange={onLibraryChange}
autoFocus={autoFocus}
generateIdForFile={generateIdForFile}
/>
</InitializeApp>
);
@@ -171,6 +173,7 @@ export {
getSceneVersion,
getElementMap,
isInvisiblySmallElement,
getNonDeletedElements,
} from "../../element";
export { defaultLang, languages } from "../../i18n";
export { restore, restoreAppState, restoreElements } from "../../data/restore";
@@ -183,4 +186,12 @@ export {
loadFromBlob,
getFreeDrawSvgPath,
} from "../../packages/utils";
export { FONT_FAMILY } from "../../constants";
export { isLinearElement } from "../../element/typeChecks";
export { FONT_FAMILY, THEME } from "../../constants";
export {
mutateElement,
newElementWith,
bumpVersion,
} from "../../element/mutateElement";
+1 -1
View File
@@ -47,7 +47,7 @@
"@babel/core": "7.14.8",
"@babel/plugin-transform-arrow-functions": "7.14.5",
"@babel/plugin-transform-async-to-generator": "7.14.5",
"@babel/plugin-transform-runtime": "7.14.5",
"@babel/plugin-transform-runtime": "7.15.8",
"@babel/plugin-transform-typescript": "7.14.6",
"@babel/preset-env": "7.14.9",
"@babel/preset-react": "7.14.5",
+68 -28
View File
@@ -140,19 +140,12 @@
dependencies:
"@babel/types" "^7.14.5"
"@babel/helper-module-imports@^7.12.13":
version "7.13.12"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz#c6a369a6f3621cb25da014078684da9196b61977"
integrity sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==
"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.14.5", "@babel/helper-module-imports@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.15.4.tgz#e18007d230632dea19b47853b984476e7b4e103f"
integrity sha512-jeAHZbzUwdW/xHgHQ3QmWR4Jg6j15q4w/gCfwZvtqOxoo5DKtLHk8Bsf4c5RZRC7NmLEs+ohkdq8jFefuvIxAA==
dependencies:
"@babel/types" "^7.13.12"
"@babel/helper-module-imports@^7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3"
integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==
dependencies:
"@babel/types" "^7.14.5"
"@babel/types" "^7.15.4"
"@babel/helper-module-transforms@^7.14.5", "@babel/helper-module-transforms@^7.14.8":
version "7.14.8"
@@ -754,15 +747,15 @@
dependencies:
"@babel/helper-plugin-utils" "^7.14.5"
"@babel/plugin-transform-runtime@7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.14.5.tgz#30491dad49c6059f8f8fa5ee8896a0089e987523"
integrity sha512-fPMBhh1AV8ZyneiCIA+wYYUH1arzlXR1UMcApjvchDhfKxhy2r2lReJv8uHEyihi4IFIGlr1Pdx7S5fkESDQsg==
"@babel/plugin-transform-runtime@7.15.8":
version "7.15.8"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.15.8.tgz#9d15b1e94e1c7f6344f65a8d573597d93c6cd886"
integrity sha512-+6zsde91jMzzvkzuEA3k63zCw+tm/GvuuabkpisgbDMTPQsIMHllE3XczJFFtEHLjjhKQFZmGQVRdELetlWpVw==
dependencies:
"@babel/helper-module-imports" "^7.14.5"
"@babel/helper-module-imports" "^7.15.4"
"@babel/helper-plugin-utils" "^7.14.5"
babel-plugin-polyfill-corejs2 "^0.2.2"
babel-plugin-polyfill-corejs3 "^0.2.2"
babel-plugin-polyfill-corejs3 "^0.2.5"
babel-plugin-polyfill-regenerator "^0.2.2"
semver "^6.3.0"
@@ -968,7 +961,7 @@
debug "^4.1.0"
globals "^11.1.0"
"@babel/types@^7.13.12", "@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.14.9", "@babel/types@^7.4.4":
"@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.14.9", "@babel/types@^7.4.4":
version "7.14.9"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.14.9.tgz#f2b19c3f2f77c5708d67fe8f6046e9cea2b5036d"
integrity sha512-u0bLTnv3DFHeaQLYzb7oRJ1JHr1sv/SYDM7JSqHFFLwXG1wTZRughxFI5NCP8qBEo1rVVsn7Yg2Lvw49nne/Ow==
@@ -976,6 +969,14 @@
"@babel/helper-validator-identifier" "^7.14.9"
to-fast-properties "^2.0.0"
"@babel/types@^7.15.4":
version "7.15.6"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.6.tgz#99abdc48218b2881c058dd0a7ab05b99c9be758f"
integrity sha512-BPU+7QhqNjmWyDO0/vitH/CuhpV8ZmK1wpKva8nuyNF5MJfuRNWMc+hc14+u9xT93kvykMdncrJT19h74uB1Ig==
dependencies:
"@babel/helper-validator-identifier" "^7.14.9"
to-fast-properties "^2.0.0"
"@discoveryjs/json-ext@^0.5.0":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz#8f03a22a04de437254e8ce8cc84ba39689288752"
@@ -1302,13 +1303,13 @@ babel-plugin-polyfill-corejs2@^0.2.2:
"@babel/helper-define-polyfill-provider" "^0.2.2"
semver "^6.1.1"
babel-plugin-polyfill-corejs3@^0.2.2:
version "0.2.3"
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.3.tgz#72add68cf08a8bf139ba6e6dfc0b1d504098e57b"
integrity sha512-rCOFzEIJpJEAU14XCcV/erIf/wZQMmMT5l5vXOpL5uoznyOGfDIjPj6FVytMvtzaKSTSVKouOCTPJ5OMUZH30g==
babel-plugin-polyfill-corejs3@^0.2.2, babel-plugin-polyfill-corejs3@^0.2.5:
version "0.2.5"
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.5.tgz#2779846a16a1652244ae268b1e906ada107faf92"
integrity sha512-ninF5MQNwAX9Z7c9ED+H2pGt1mXdP4TqzlHKyPIYmJIYz0N+++uwdM7RnJukklhzJ54Q84vA4ZJkgs7lu5vqcw==
dependencies:
"@babel/helper-define-polyfill-provider" "^0.2.2"
core-js-compat "^3.14.0"
core-js-compat "^3.16.2"
babel-plugin-polyfill-regenerator@^0.2.2:
version "0.2.2"
@@ -1404,6 +1405,17 @@ browserslist@^4.14.5, browserslist@^4.16.6:
escalade "^3.1.1"
node-releases "^1.1.71"
browserslist@^4.17.3:
version "4.17.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.17.3.tgz#2844cd6eebe14d12384b0122d217550160d2d624"
integrity sha512-59IqHJV5VGdcJZ+GZ2hU5n4Kv3YiASzW6Xk5g9tf5a/MAzGeFwgGWU39fVzNIOVcgB3+Gp+kiQu0HEfTVU/3VQ==
dependencies:
caniuse-lite "^1.0.30001264"
electron-to-chromium "^1.3.857"
escalade "^3.1.1"
node-releases "^1.1.77"
picocolors "^0.2.1"
buffer-from@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
@@ -1427,6 +1439,11 @@ caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001243:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001245.tgz#45b941bbd833cb0fa53861ff2bae746b3c6ca5d4"
integrity sha512-768fM9j1PKXpOCKws6eTo3RHmvTUsG9UrpT4WoREFeZgJBTi4/X9g565azS/rVUGtqb8nt7FjLeF5u4kukERnA==
caniuse-lite@^1.0.30001264:
version "1.0.30001265"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001265.tgz#0613c9e6c922e422792e6fcefdf9a3afeee4f8c3"
integrity sha512-YzBnspggWV5hep1m9Z6sZVLOt7vrju8xWooFAgN6BA5qvy98qPAPb7vNUzypFaoh2pb3vlfzbDO8tB57UPGbtw==
chalk@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
@@ -1527,7 +1544,7 @@ convert-source-map@^1.7.0:
dependencies:
safe-buffer "~5.1.1"
core-js-compat@^3.14.0, core-js-compat@^3.16.0:
core-js-compat@^3.16.0:
version "3.16.0"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.16.0.tgz#fced4a0a534e7e02f7e084bff66c701f8281805f"
integrity sha512-5D9sPHCdewoUK7pSUPfTF7ZhLh8k9/CoJXWUEo+F1dZT5Z1DVgcuRqUKhjeKW+YLb8f21rTFgWwQJiNw1hoZ5Q==
@@ -1535,6 +1552,14 @@ core-js-compat@^3.14.0, core-js-compat@^3.16.0:
browserslist "^4.16.6"
semver "7.0.0"
core-js-compat@^3.16.2:
version "3.18.3"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.18.3.tgz#e0e7e87abc55efb547e7fa19169e45fa9df27a67"
integrity sha512-4zP6/y0a2RTHN5bRGT7PTq9lVt3WzvffTNjqnTKsXhkAYNDTkdCLOIfAdOLcQ/7TDdyRj3c+NeHe1NmF1eDScw==
dependencies:
browserslist "^4.17.3"
semver "7.0.0"
core-js@^2.4.0:
version "2.6.12"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
@@ -1619,6 +1644,11 @@ electron-to-chromium@^1.3.723:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz#f07756aa92cabd5a6eec6f491525a64fe62f98b9"
integrity sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A==
electron-to-chromium@^1.3.857:
version "1.3.867"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.867.tgz#7cb484db4b57c28da0b65c51e434c3a1f3f9aa0d"
integrity sha512-WbTXOv7hsLhjJyl7jBfDkioaY++iVVZomZ4dU6TMe/SzucV6mUAs2VZn/AehBwuZMiNEQDaPuTGn22YK5o+aDw==
emojis-list@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
@@ -2131,6 +2161,11 @@ node-releases@^1.1.71:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe"
integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==
node-releases@^1.1.77:
version "1.1.77"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.77.tgz#50b0cfede855dd374e7585bf228ff34e57c1c32e"
integrity sha512-rB1DUFUNAN4Gn9keO2K1efO35IDK7yKHCdCaIMvFO7yUYmmZYeDjnGKle26G4rwj+LKRQpjyUUvMkPglwGCYNQ==
normalize-range@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
@@ -2224,15 +2259,20 @@ path-key@^3.0.0, path-key@^3.1.0:
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
path-parse@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
version "1.0.7"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
path-type@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
picocolors@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f"
integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==
picomatch@^2.0.5:
version "2.2.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
+21 -11
View File
@@ -3,14 +3,16 @@ import {
exportToSvg as _exportToSvg,
} from "../scene/export";
import { getDefaultAppState } from "../appState";
import { AppState } from "../types";
import { AppState, BinaryFiles } from "../types";
import { ExcalidrawElement } from "../element/types";
import { getNonDeletedElements } from "../element";
import { restore } from "../data/restore";
import { MIME_TYPES } from "../constants";
type ExportOpts = {
elements: readonly ExcalidrawElement[];
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null;
getDimensions?: (
width: number,
height: number,
@@ -20,6 +22,7 @@ type ExportOpts = {
export const exportToCanvas = ({
elements,
appState,
files,
getDimensions = (width, height) => ({ width, height, scale: 1 }),
}: ExportOpts) => {
const { elements: restoredElements, appState: restoredAppState } = restore(
@@ -31,6 +34,7 @@ export const exportToCanvas = ({
return _exportToCanvas(
getNonDeletedElements(restoredElements),
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
files || {},
{ exportBackground, viewBackgroundColor },
(width: number, height: number) => {
const canvas = document.createElement("canvas");
@@ -44,22 +48,23 @@ export const exportToCanvas = ({
);
};
export const exportToBlob = (
export const exportToBlob = async (
opts: ExportOpts & {
mimeType?: string;
quality?: number;
},
): Promise<Blob | null> => {
const canvas = exportToCanvas(opts);
const canvas = await exportToCanvas(opts);
let { mimeType = "image/png", quality } = opts;
let { mimeType = MIME_TYPES.png, quality } = opts;
if (mimeType === "image/png" && typeof quality === "number") {
console.warn(`"quality" will be ignored for "image/png" mimeType`);
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = "image/jpeg";
mimeType = MIME_TYPES.jpg;
}
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
@@ -78,6 +83,7 @@ export const exportToBlob = (
export const exportToSvg = async ({
elements,
appState = getDefaultAppState(),
files = {},
exportPadding,
}: Omit<ExportOpts, "getDimensions"> & {
exportPadding?: number;
@@ -87,10 +93,14 @@ export const exportToSvg = async ({
null,
null,
);
return _exportToSvg(getNonDeletedElements(restoredElements), {
...restoredAppState,
exportPadding,
});
return _exportToSvg(
getNonDeletedElements(restoredElements),
{
...restoredAppState,
exportPadding,
},
files,
);
};
export { serializeAsJSON } from "../data/json";
+155 -19
View File
@@ -5,11 +5,13 @@ import {
Arrowhead,
NonDeletedExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
} from "../element/types";
import {
isTextElement,
isLinearElement,
isFreeDrawElement,
isInitializedImageElement,
} from "../element/typeChecks";
import {
getDiamondPoints,
@@ -21,22 +23,23 @@ import { Drawable, Options } from "roughjs/bin/core";
import { RoughSVG } from "roughjs/bin/svg";
import { RoughGenerator } from "roughjs/bin/generator";
import { SceneState } from "../scene/types";
import {
SVG_NS,
distance,
getFontString,
getFontFamilyString,
isRTL,
} from "../utils";
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
import { isPathALoop } from "../math";
import rough from "roughjs/bin/rough";
import { Zoom } from "../types";
import { AppState, BinaryFiles, Zoom } from "../types";
import { getDefaultAppState } from "../appState";
import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS } from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand";
import { MAX_DECIMALS_FOR_SVG_EXPORT } from "../constants";
const defaultAppState = getDefaultAppState();
const isPendingImageElement = (
element: ExcalidrawElement,
sceneState: SceneState,
) =>
isInitializedImageElement(element) &&
!sceneState.imageCache.has(element.fileId);
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
@@ -47,6 +50,7 @@ const getCanvasPadding = (element: ExcalidrawElement) =>
export interface ExcalidrawElementWithCanvas {
element: ExcalidrawElement | ExcalidrawTextElement;
canvas: HTMLCanvasElement;
theme: SceneState["theme"];
canvasZoom: Zoom["value"];
canvasOffsetX: number;
canvasOffsetY: number;
@@ -55,6 +59,7 @@ export interface ExcalidrawElementWithCanvas {
const generateElementCanvas = (
element: NonDeletedExcalidrawElement,
zoom: Zoom,
sceneState: SceneState,
): ExcalidrawElementWithCanvas => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
@@ -111,21 +116,73 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas);
drawElementOnCanvas(element, rc, context);
if (
sceneState.theme === "dark" &&
isInitializedImageElement(element) &&
!isPendingImageElement(element, sceneState) &&
sceneState.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
) {
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
// color scheme (it's still not quite there and the clors look slightly
// desaturing/black is not as black, but...)
context.filter = "invert(100%) hue-rotate(180deg) saturate(1.25)";
}
drawElementOnCanvas(element, rc, context, sceneState);
context.restore();
return {
element,
canvas,
theme: sceneState.theme,
canvasZoom: zoom.value,
canvasOffsetX,
canvasOffsetY,
};
};
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
)}`;
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
)}`;
const drawImagePlaceholder = (
element: ExcalidrawImageElement,
context: CanvasRenderingContext2D,
zoomValue: AppState["zoom"]["value"],
) => {
context.fillStyle = "#E7E7E7";
context.fillRect(0, 0, element.width, element.height);
const imageMinWidthOrHeight = Math.min(element.width, element.height);
const size = Math.min(
imageMinWidthOrHeight,
Math.min(imageMinWidthOrHeight * 0.4, 100),
);
context.drawImage(
element.status === "error"
? IMAGE_ERROR_PLACEHOLDER_IMG
: IMAGE_PLACEHOLDER_IMG,
element.width / 2 - size / 2,
element.height / 2 - size / 2,
size,
size,
);
};
const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
sceneState: SceneState,
) => {
context.globalAlpha = element.opacity / 100;
switch (element.type) {
@@ -160,6 +217,23 @@ const drawElementOnCanvas = (
context.restore();
break;
}
case "image": {
const img = isInitializedImageElement(element)
? sceneState.imageCache.get(element.fileId)?.image
: undefined;
if (img != null && !(img instanceof Promise)) {
context.drawImage(
img,
0 /* hardcoded for the selection box*/,
0,
element.width,
element.height,
);
} else {
drawImagePlaceholder(element, context, sceneState.zoom.value);
}
break;
}
default: {
if (isTextElement(element)) {
const rtl = isRTL(element.text);
@@ -254,6 +328,7 @@ export const generateRoughOptions = (
switch (element.type) {
case "rectangle":
case "diamond":
case "image":
case "ellipse": {
options.fillStyle = element.fillStyle;
options.fill =
@@ -459,7 +534,8 @@ const generateElementShape = (
shape = [];
break;
}
case "text": {
case "text":
case "image": {
// just to ensure we don't regenerate element.canvas on rerenders
shape = [];
break;
@@ -471,7 +547,7 @@ const generateElementShape = (
const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement,
sceneState?: SceneState,
sceneState: SceneState,
) => {
const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
const prevElementWithCanvas = elementWithCanvasCache.get(element);
@@ -479,8 +555,13 @@ const generateElementWithCanvas = (
prevElementWithCanvas &&
prevElementWithCanvas.canvasZoom !== zoom.value &&
!sceneState?.shouldCacheIgnoreZoom;
if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) {
const elementWithCanvas = generateElementCanvas(element, zoom);
if (
!prevElementWithCanvas ||
shouldRegenerateBecauseZoom ||
prevElementWithCanvas.theme !== sceneState.theme
) {
const elementWithCanvas = generateElementCanvas(element, zoom, sceneState);
elementWithCanvasCache.set(element, elementWithCanvas);
@@ -509,10 +590,25 @@ const drawElementFromCanvas = (
const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
const _isPendingImageElement = isPendingImageElement(element, sceneState);
const scaleXFactor =
"scale" in elementWithCanvas.element && !_isPendingImageElement
? elementWithCanvas.element.scale[0]
: 1;
const scaleYFactor =
"scale" in elementWithCanvas.element && !_isPendingImageElement
? elementWithCanvas.element.scale[1]
: 1;
context.save();
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
context.translate(cx, cy);
context.rotate(element.angle);
context.scale(
(1 / window.devicePixelRatio) * scaleXFactor,
(1 / window.devicePixelRatio) * scaleYFactor,
);
context.translate(cx * scaleXFactor, cy * scaleYFactor);
context.rotate(element.angle * scaleXFactor * scaleYFactor);
context.drawImage(
elementWithCanvas.canvas!,
@@ -567,7 +663,7 @@ export const renderElement = (
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context);
drawElementOnCanvas(element, rc, context, sceneState);
context.restore();
}
@@ -578,6 +674,7 @@ export const renderElement = (
case "ellipse":
case "line":
case "arrow":
case "image":
case "text": {
generateElementShape(element, generator);
if (renderOptimizations) {
@@ -596,7 +693,7 @@ export const renderElement = (
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context);
drawElementOnCanvas(element, rc, context, sceneState);
context.restore();
}
break;
@@ -628,6 +725,7 @@ export const renderElementToSvg = (
element: NonDeletedExcalidrawElement,
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
offsetX?: number,
offsetY?: number,
) => {
@@ -723,6 +821,44 @@ export const renderElementToSvg = (
svgRoot.appendChild(node);
break;
}
case "image": {
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (fileData) {
const symbolId = `image-${fileData.id}`;
let symbol = svgRoot.querySelector(`#${symbolId}`);
if (!symbol) {
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
symbol.id = symbolId;
const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
image.setAttribute("width", "100%");
image.setAttribute("height", "100%");
image.setAttribute("href", fileData.dataURL);
symbol.appendChild(image);
svgRoot.prepend(symbol);
}
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
use.setAttribute("href", `#${symbolId}`);
use.setAttribute("width", `${Math.round(element.width)}`);
use.setAttribute("height", `${Math.round(element.height)}`);
use.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
svgRoot.appendChild(use);
}
break;
}
default: {
if (isTextElement(element)) {
const opacity = element.opacity / 100;
+9 -3
View File
@@ -2,7 +2,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
import { RoughSVG } from "roughjs/bin/svg";
import oc from "open-color";
import { AppState, Zoom } from "../types";
import { AppState, BinaryFiles, Zoom } from "../types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
@@ -181,7 +181,7 @@ export const renderScene = (
rc: RoughCanvas,
canvas: HTMLCanvasElement,
sceneState: SceneState,
// extra options, currently passed by export helper
// extra options passed to the renderer
{
renderScrollbars = true,
renderSelection = true,
@@ -190,11 +190,15 @@ export const renderScene = (
// doesn't guarantee pixel-perfect output.
renderOptimizations = false,
renderGrid = true,
/** when exporting the behavior is slightly different (e.g. we can't use
CSS filters) */
isExport = false,
}: {
renderScrollbars?: boolean;
renderSelection?: boolean;
renderOptimizations?: boolean;
renderGrid?: boolean;
isExport?: boolean;
} = {},
) => {
if (canvas === null) {
@@ -211,7 +215,7 @@ export const renderScene = (
const normalizedCanvasWidth = canvas.width / scale;
const normalizedCanvasHeight = canvas.height / scale;
if (sceneState.exportWithDarkMode) {
if (isExport && sceneState.theme === "dark") {
context.filter = THEME_FILTER;
}
@@ -805,6 +809,7 @@ export const renderSceneToSvg = (
elements: readonly NonDeletedExcalidrawElement[],
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
{
offsetX = 0,
offsetY = 0,
@@ -824,6 +829,7 @@ export const renderSceneToSvg = (
element,
rsvg,
svgRoot,
files,
element.x + offsetX,
element.y + offsetY,
);
+2
View File
@@ -11,6 +11,8 @@ export const hasBackground = (type: string) =>
type === "diamond" ||
type === "line";
export const hasStrokeColor = (type: string) => type !== "image";
export const hasStrokeWidth = (type: string) =>
type === "rectangle" ||
type === "ellipse" ||
+27 -9
View File
@@ -2,17 +2,22 @@ import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getCommonBounds } from "../element/bounds";
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
import { distance, SVG_NS } from "../utils";
import { AppState } from "../types";
import { DEFAULT_EXPORT_PADDING, THEME_FILTER } from "../constants";
import { distance } from "../utils";
import { AppState, BinaryFiles } from "../types";
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
import { getDefaultAppState } from "../appState";
import { serializeAsJSON } from "../data/json";
import {
getInitializedImageElements,
updateImageCache,
} from "../element/image";
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
export const exportToCanvas = (
export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
{
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
@@ -36,6 +41,16 @@ export const exportToCanvas = (
const { canvas, scale = 1 } = createCanvas(width, height);
const defaultAppState = getDefaultAppState();
const { imageCache } = await updateImageCache({
imageCache: new Map(),
fileIds: getInitializedImageElements(elements).map(
(element) => element.fileId,
),
files,
});
renderScene(
elements,
appState,
@@ -45,21 +60,23 @@ export const exportToCanvas = (
canvas,
{
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
exportWithDarkMode: appState.exportWithDarkMode,
scrollX: -minX + exportPadding,
scrollY: -minY + exportPadding,
zoom: getDefaultAppState().zoom,
zoom: defaultAppState.zoom,
remotePointerViewportCoords: {},
remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false,
remotePointerUsernames: {},
remotePointerUserStates: {},
theme: appState.exportWithDarkMode ? "dark" : "light",
imageCache,
},
{
renderScrollbars: false,
renderSelection: false,
renderOptimizations: false,
renderOptimizations: true,
renderGrid: false,
isExport: true,
},
);
@@ -76,6 +93,7 @@ export const exportToSvg = async (
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
},
files: BinaryFiles | null,
): Promise<SVGSVGElement> => {
const {
exportPadding = DEFAULT_EXPORT_PADDING,
@@ -89,7 +107,7 @@ export const exportToSvg = async (
metadata = await (
await import(/* webpackChunkName: "image" */ "../../src/data/image")
).encodeSvgMetadata({
text: serializeAsJSON(elements, appState),
text: serializeAsJSON(elements, appState, files || {}, "local"),
});
} catch (err) {
console.error(err);
@@ -137,7 +155,7 @@ export const exportToSvg = async (
}
const rsvg = rough.svg(svgRoot);
renderSceneToSvg(elements, rsvg, svgRoot, {
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
offsetX: -minX + exportPadding,
offsetY: -minY + exportPadding,
});
+3 -2
View File
@@ -1,12 +1,11 @@
import { ExcalidrawTextElement } from "../element/types";
import { Zoom } from "../types";
import { AppClassProperties, AppState, Zoom } from "../types";
export type SceneState = {
scrollX: number;
scrollY: number;
// null indicates transparent bg
viewBackgroundColor: string | null;
exportWithDarkMode?: boolean;
zoom: Zoom;
shouldCacheIgnoreZoom: boolean;
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
@@ -14,6 +13,8 @@ export type SceneState = {
remoteSelectedElementIds: { [elementId: string]: string[] };
remotePointerUsernames: { [id: string]: string };
remotePointerUserStates: { [id: string]: string };
theme: AppState["theme"];
imageCache: AppClassProperties["imageCache"];
};
export type SceneScroll = {
+17 -4
View File
@@ -1,4 +1,3 @@
import React from "react";
import { KEYS } from "./keys";
// We inline font-awesome icons in order to save on js size rather than including the font awesome react library
@@ -93,15 +92,29 @@ export const SHAPES = [
value: "text",
key: KEYS.T,
},
{
icon: (
// fa-image
<svg viewBox="0 0 512 512">
<path
fill="currentColor"
d="M464 64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V112c0-26.51-21.49-48-48-48zm-6 336H54a6 6 0 0 1-6-6V118a6 6 0 0 1 6-6h404a6 6 0 0 1 6 6v276a6 6 0 0 1-6 6zM128 152c-22.091 0-40 17.909-40 40s17.909 40 40 40 40-17.909 40-40-17.909-40-40-40zM96 352h320v-80l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L192 304l-39.515-39.515c-4.686-4.686-12.284-4.686-16.971 0L96 304v48z"
></path>
</svg>
),
value: "image",
key: null,
},
] as const;
export const findShapeByKey = (key: string) => {
const shape = SHAPES.find((shape, index) => {
return (
key === (index + 1).toString() ||
(typeof shape.key === "string"
? shape.key === key
: (shape.key as readonly string[]).includes(key))
(shape.key &&
(typeof shape.key === "string"
? shape.key === key
: (shape.key as readonly string[]).includes(key)))
);
});
return shape?.value || null;
@@ -49,6 +49,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -216,6 +217,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -529,6 +531,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -842,6 +845,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -1009,6 +1013,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -1209,6 +1214,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -1462,6 +1468,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id1": true,
},
@@ -1793,6 +1800,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -2526,6 +2534,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -2839,6 +2848,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -3152,6 +3162,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id1": true,
},
@@ -3539,6 +3550,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id2": true,
@@ -3798,6 +3810,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id2": true,
@@ -4132,6 +4145,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -4234,6 +4248,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -4314,6 +4329,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -49,6 +49,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id1": true,
@@ -518,6 +519,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id1": true,
@@ -993,6 +995,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -1783,6 +1786,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
},
@@ -1991,6 +1995,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id3": true,
@@ -2457,6 +2462,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
},
@@ -2714,6 +2720,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -2881,6 +2888,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id2": true,
},
@@ -3330,6 +3338,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -3571,6 +3580,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
},
@@ -3779,6 +3789,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id1": true,
@@ -4028,6 +4039,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id1": true,
},
@@ -4284,6 +4296,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id2": true,
},
@@ -4672,6 +4685,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id1": true,
@@ -4971,6 +4985,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id1": true,
@@ -5248,6 +5263,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
},
@@ -5459,6 +5475,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
},
@@ -5626,6 +5643,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -6087,6 +6105,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id1": true,
@@ -6410,6 +6429,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -8474,6 +8494,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id2": true,
@@ -8841,6 +8862,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id2": true,
@@ -9098,6 +9120,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id2": true,
@@ -9319,6 +9342,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id2": true,
@@ -9603,6 +9627,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -9770,6 +9795,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -9937,6 +9963,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -10104,6 +10131,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -10301,6 +10329,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -10498,6 +10527,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -10713,6 +10743,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -10910,6 +10941,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -11077,6 +11109,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -11244,6 +11277,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -11441,6 +11475,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -11608,6 +11643,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -11823,6 +11859,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id1": true,
@@ -12550,6 +12587,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id3": true,
@@ -12807,6 +12845,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": -5.416666666666667,
@@ -12911,6 +12950,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -13013,6 +13053,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
},
@@ -13183,6 +13224,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id1": true,
@@ -13509,6 +13551,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -13713,6 +13756,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
"id1": true,
@@ -14549,6 +14593,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 60,
@@ -14651,6 +14696,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id0": true,
},
@@ -15420,6 +15466,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id1": true,
"id2": true,
@@ -15830,6 +15877,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {
"id1": true,
},
@@ -16107,6 +16155,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 11.046099290780141,
@@ -16211,6 +16260,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -16715,6 +16765,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
@@ -16817,6 +16868,7 @@ Object {
"data": null,
"shown": false,
},
"pendingImageElement": null,
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,

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