feat: image support (#4011)
Co-authored-by: Emil Atanasov <heitara@gmail.com> Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
This commit is contained in:
parent
0f0244224d
commit
163ad1f4c4
@ -5,3 +5,4 @@ package-lock.json
|
|||||||
firebase/
|
firebase/
|
||||||
dist/
|
dist/
|
||||||
public/workbox
|
public/workbox
|
||||||
|
src/packages/excalidraw/types
|
||||||
|
@ -26,12 +26,16 @@
|
|||||||
"@testing-library/react": "11.2.6",
|
"@testing-library/react": "11.2.6",
|
||||||
"@tldraw/vec": "0.0.106",
|
"@tldraw/vec": "0.0.106",
|
||||||
"@types/jest": "26.0.22",
|
"@types/jest": "26.0.22",
|
||||||
|
"@types/pica": "5.1.3",
|
||||||
"@types/react": "17.0.3",
|
"@types/react": "17.0.3",
|
||||||
"@types/react-dom": "17.0.3",
|
"@types/react-dom": "17.0.3",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
|
"fake-indexeddb": "3.1.3",
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.0",
|
"i18next-browser-languagedetector": "6.1.0",
|
||||||
|
"idb-keyval": "5.1.3",
|
||||||
|
"image-blob-reduce": "3.0.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.22",
|
"nanoid": "3.1.22",
|
||||||
"open-color": "1.8.0",
|
"open-color": "1.8.0",
|
||||||
|
@ -47,13 +47,15 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
|
app.imageCache.clear();
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) =>
|
elements: elements.map((element) =>
|
||||||
newElementWith(element, { isDeleted: true }),
|
newElementWith(element, { isDeleted: true }),
|
||||||
),
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
|
files: {},
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
elementLocked: appState.elementLocked,
|
elementLocked: appState.elementLocked,
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
|
@ -9,8 +9,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
copyToClipboard(getNonDeletedElements(elements), appState);
|
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
app.files,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@ -88,6 +89,7 @@ export const actionCopyAsPng = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
app.files,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
@ -128,13 +128,13 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
|
|
||||||
export const actionSaveToActiveFile = register({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "saveToActiveFile",
|
name: "saveToActiveFile",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
const fileHandleExists = !!appState.fileHandle;
|
const fileHandleExists = !!appState.fileHandle;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||||
? await resaveAsImageWithScene(elements, appState)
|
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||||
: await saveAsJSON(elements, appState);
|
: await saveAsJSON(elements, appState, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@ -170,12 +170,16 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
export const actionSaveFileToDisk = register({
|
export const actionSaveFileToDisk = register({
|
||||||
name: "saveFileToDisk",
|
name: "saveFileToDisk",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, {
|
const { fileHandle } = await saveAsJSON(
|
||||||
...appState,
|
elements,
|
||||||
fileHandle: null,
|
{
|
||||||
});
|
...appState,
|
||||||
|
fileHandle: null,
|
||||||
|
},
|
||||||
|
app.files,
|
||||||
|
);
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
@ -202,15 +206,17 @@ export const actionSaveFileToDisk = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: async (elements, appState) => {
|
perform: async (elements, appState, _, app) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
|
files,
|
||||||
} = await loadFromJSON(appState, elements);
|
} = await loadFromJSON(appState, elements);
|
||||||
return {
|
return {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
|
files,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -220,6 +226,7 @@ export const actionLoadScene = register({
|
|||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: { ...appState, errorMessage: error.message },
|
appState: { ...appState, errorMessage: error.message },
|
||||||
|
files: app.files,
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -49,6 +49,11 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
|
if (appState.pendingImageElement) {
|
||||||
|
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||||
|
}
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
focusContainer();
|
focusContainer();
|
||||||
}
|
}
|
||||||
@ -152,6 +157,7 @@ export const actionFinalize = register({
|
|||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
|
pendingImageElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.elementType === "freedraw",
|
commitToHistory: appState.elementType === "freedraw",
|
||||||
};
|
};
|
||||||
|
@ -93,13 +93,13 @@ const flipElements = (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
for (let i = 0; i < elements.length; i++) {
|
elements.forEach((element) => {
|
||||||
flipElement(elements[i], appState);
|
flipElement(element, appState);
|
||||||
// If vertical flip, rotate an extra 180
|
// If vertical flip, rotate an extra 180
|
||||||
if (flipDirection === "vertical") {
|
if (flipDirection === "vertical") {
|
||||||
rotateElement(elements[i], Math.PI);
|
rotateElement(element, Math.PI);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
return elements;
|
return elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -59,6 +59,7 @@ import {
|
|||||||
getTargetElements,
|
getTargetElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
@ -103,11 +104,13 @@ export const actionChangeStrokeColor = register({
|
|||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemStrokeColor && {
|
...(value.currentItemStrokeColor && {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
newElementWith(el, {
|
return hasStrokeColor(el.type)
|
||||||
strokeColor: value.currentItemStrokeColor,
|
? newElementWith(el, {
|
||||||
}),
|
strokeColor: value.currentItemStrokeColor,
|
||||||
),
|
})
|
||||||
|
: el;
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
@ -8,18 +8,8 @@ import {
|
|||||||
PanelComponentProps,
|
PanelComponentProps,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppProps, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { MODES } from "../constants";
|
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 {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
@ -28,13 +18,13 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
getAppState: () => Readonly<AppState>;
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
app: App;
|
app: AppClassProperties;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||||
app: App,
|
app: AppClassProperties,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = (actionResult) => {
|
||||||
if (actionResult && "then" in actionResult) {
|
if (actionResult && "then" in actionResult) {
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import {
|
||||||
import Library from "../data/library";
|
AppClassProperties,
|
||||||
|
AppState,
|
||||||
|
ExcalidrawProps,
|
||||||
|
BinaryFiles,
|
||||||
|
} from "../types";
|
||||||
import { ToolButtonSize } from "../components/ToolButton";
|
import { ToolButtonSize } from "../components/ToolButton";
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
@ -12,22 +16,18 @@ export type ActionResult =
|
|||||||
AppState,
|
AppState,
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> | null;
|
> | null;
|
||||||
|
files?: BinaryFiles | null;
|
||||||
commitToHistory: boolean;
|
commitToHistory: boolean;
|
||||||
syncHistory?: boolean;
|
syncHistory?: boolean;
|
||||||
|
replaceFiles?: boolean;
|
||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|
||||||
type AppAPI = {
|
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
focusContainer(): void;
|
|
||||||
library: Library;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
app: AppAPI,
|
app: AppClassProperties,
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult) => void;
|
||||||
|
150
src/appState.ts
150
src/appState.ts
@ -79,6 +79,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
|
pendingImageElement: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -92,78 +93,87 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
browser: boolean;
|
browser: boolean;
|
||||||
/** whether to keep when exporting to file/database */
|
/** whether to keep when exporting to file/database */
|
||||||
export: boolean;
|
export: boolean;
|
||||||
|
/** server (shareLink/collab/...) */
|
||||||
|
server: boolean;
|
||||||
},
|
},
|
||||||
T extends Record<keyof AppState, Values>
|
T extends Record<keyof AppState, Values>
|
||||||
>(
|
>(
|
||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||||
) => config)({
|
) => config)({
|
||||||
theme: { browser: true, export: false },
|
theme: { browser: true, export: false, server: false },
|
||||||
collaborators: { browser: false, export: false },
|
collaborators: { browser: false, export: false, server: false },
|
||||||
currentChartType: { browser: true, export: false },
|
currentChartType: { browser: true, export: false, server: false },
|
||||||
currentItemBackgroundColor: { browser: true, export: false },
|
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
||||||
currentItemEndArrowhead: { browser: true, export: false },
|
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
||||||
currentItemFillStyle: { browser: true, export: false },
|
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||||
currentItemFontFamily: { browser: true, export: false },
|
currentItemFontFamily: { browser: true, export: false, server: false },
|
||||||
currentItemFontSize: { browser: true, export: false },
|
currentItemFontSize: { browser: true, export: false, server: false },
|
||||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
currentItemLinearStrokeSharpness: {
|
||||||
currentItemOpacity: { browser: true, export: false },
|
browser: true,
|
||||||
currentItemRoughness: { browser: true, export: false },
|
export: false,
|
||||||
currentItemStartArrowhead: { browser: true, export: false },
|
server: false,
|
||||||
currentItemStrokeColor: { browser: true, export: false },
|
},
|
||||||
currentItemStrokeSharpness: { browser: true, export: false },
|
currentItemOpacity: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeStyle: { browser: true, export: false },
|
currentItemRoughness: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false },
|
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||||
currentItemTextAlign: { browser: true, export: false },
|
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false },
|
currentItemStrokeSharpness: { browser: true, export: false, server: false },
|
||||||
draggingElement: { browser: false, export: false },
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
editingElement: { browser: false, export: false },
|
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false },
|
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
elementLocked: { browser: true, export: false },
|
draggingElement: { browser: false, export: false, server: false },
|
||||||
elementType: { browser: true, export: false },
|
editingElement: { browser: false, export: false, server: false },
|
||||||
errorMessage: { browser: false, export: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
exportBackground: { browser: true, export: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
exportEmbedScene: { browser: true, export: false },
|
elementLocked: { browser: true, export: false, server: false },
|
||||||
exportScale: { browser: true, export: false },
|
elementType: { browser: true, export: false, server: false },
|
||||||
exportWithDarkMode: { browser: true, export: false },
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
fileHandle: { browser: false, export: false },
|
exportBackground: { browser: true, export: false, server: false },
|
||||||
gridSize: { browser: true, export: true },
|
exportEmbedScene: { browser: true, export: false, server: false },
|
||||||
height: { browser: false, export: false },
|
exportScale: { browser: true, export: false, server: false },
|
||||||
isBindingEnabled: { browser: false, export: false },
|
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||||
isLibraryOpen: { browser: false, export: false },
|
fileHandle: { browser: false, export: false, server: false },
|
||||||
isLoading: { browser: false, export: false },
|
gridSize: { browser: true, export: true, server: true },
|
||||||
isResizing: { browser: false, export: false },
|
height: { browser: false, export: false, server: false },
|
||||||
isRotating: { browser: false, export: false },
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
lastPointerDownWith: { browser: true, export: false },
|
isLibraryOpen: { browser: false, export: false, server: false },
|
||||||
multiElement: { browser: false, export: false },
|
isLoading: { browser: false, export: false, server: false },
|
||||||
name: { browser: true, export: false },
|
isResizing: { browser: false, export: false, server: false },
|
||||||
offsetLeft: { browser: false, export: false },
|
isRotating: { browser: false, export: false, server: false },
|
||||||
offsetTop: { browser: false, export: false },
|
lastPointerDownWith: { browser: true, export: false, server: false },
|
||||||
openMenu: { browser: true, export: false },
|
multiElement: { browser: false, export: false, server: false },
|
||||||
openPopup: { browser: false, export: false },
|
name: { browser: true, export: false, server: false },
|
||||||
pasteDialog: { browser: false, export: false },
|
offsetLeft: { browser: false, export: false, server: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
offsetTop: { browser: false, export: false, server: false },
|
||||||
resizingElement: { browser: false, export: false },
|
openMenu: { browser: true, export: false, server: false },
|
||||||
scrolledOutside: { browser: true, export: false },
|
openPopup: { browser: false, export: false, server: false },
|
||||||
scrollX: { browser: true, export: false },
|
pasteDialog: { browser: false, export: false, server: false },
|
||||||
scrollY: { browser: true, export: false },
|
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||||
selectedElementIds: { browser: true, export: false },
|
resizingElement: { browser: false, export: false, server: false },
|
||||||
selectedGroupIds: { browser: true, export: false },
|
scrolledOutside: { browser: true, export: false, server: false },
|
||||||
selectionElement: { browser: false, export: false },
|
scrollX: { browser: true, export: false, server: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
scrollY: { browser: true, export: false, server: false },
|
||||||
showHelpDialog: { browser: false, export: false },
|
selectedElementIds: { browser: true, export: false, server: false },
|
||||||
showStats: { browser: true, export: false },
|
selectedGroupIds: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false },
|
selectionElement: { browser: false, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
toastMessage: { browser: false, export: false },
|
showHelpDialog: { browser: false, export: false, server: false },
|
||||||
viewBackgroundColor: { browser: true, export: true },
|
showStats: { browser: true, export: false, server: false },
|
||||||
width: { browser: false, export: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
zenModeEnabled: { browser: true, export: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
zoom: { browser: true, export: false },
|
toastMessage: { browser: false, export: false, server: false },
|
||||||
viewModeEnabled: { browser: false, export: 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>,
|
appState: Partial<AppState>,
|
||||||
exportType: ExportType,
|
exportType: ExportType,
|
||||||
) => {
|
) => {
|
||||||
@ -176,8 +186,10 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
|||||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||||
if (propConfig?.[exportType]) {
|
if (propConfig?.[exportType]) {
|
||||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
const nextValue = appState[key];
|
||||||
stateForExport[key] = appState[key];
|
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/31445
|
||||||
|
(stateForExport as any)[key] = nextValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stateForExport;
|
return stateForExport;
|
||||||
@ -190,3 +202,7 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
|||||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||||
return _clearAppStateForStorage(appState, "export");
|
return _clearAppStateForStorage(appState, "export");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||||
|
return _clearAppStateForStorage(appState, "server");
|
||||||
|
};
|
||||||
|
@ -3,19 +3,22 @@ import {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { getSelectedElements } from "./scene";
|
import { getSelectedElements } from "./scene";
|
||||||
import { AppState } from "./types";
|
import { AppState, BinaryFiles } from "./types";
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
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 ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
elements: ExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
|
files: BinaryFiles | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ClipboardData {
|
export interface ClipboardData {
|
||||||
spreadsheet?: Spreadsheet;
|
spreadsheet?: Spreadsheet;
|
||||||
elements?: readonly ExcalidrawElement[];
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
files?: BinaryFiles;
|
||||||
text?: string;
|
text?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
@ -37,7 +40,7 @@ export const probablySupportsClipboardBlob =
|
|||||||
|
|
||||||
const clipboardContainsElements = (
|
const clipboardContainsElements = (
|
||||||
contents: any,
|
contents: any,
|
||||||
): contents is { elements: ExcalidrawElement[] } => {
|
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
EXPORT_DATA_TYPES.excalidraw,
|
EXPORT_DATA_TYPES.excalidraw,
|
||||||
@ -53,10 +56,18 @@ const clipboardContainsElements = (
|
|||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
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);
|
const json = JSON.stringify(contents);
|
||||||
CLIPBOARD = json;
|
CLIPBOARD = json;
|
||||||
@ -138,7 +149,10 @@ export const parseClipboard = async (
|
|||||||
try {
|
try {
|
||||||
const systemClipboardData = JSON.parse(systemClipboard);
|
const systemClipboardData = JSON.parse(systemClipboard);
|
||||||
if (clipboardContainsElements(systemClipboardData)) {
|
if (clipboardContainsElements(systemClipboardData)) {
|
||||||
return { elements: systemClipboardData.elements };
|
return {
|
||||||
|
elements: systemClipboardData.elements,
|
||||||
|
files: systemClipboardData.files,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return appClipboardData;
|
return appClipboardData;
|
||||||
} catch {
|
} catch {
|
||||||
@ -153,7 +167,7 @@ export const parseClipboard = async (
|
|||||||
|
|
||||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([
|
||||||
new window.ClipboardItem({ "image/png": blob }),
|
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import {
|
import {
|
||||||
@ -18,6 +18,7 @@ import { AppState, Zoom } from "../types";
|
|||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
@ -48,9 +49,22 @@ export const SelectedShapeActions = ({
|
|||||||
hasBackground(elementType) ||
|
hasBackground(elementType) ||
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
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 (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{renderAction("changeStrokeColor")}
|
{((hasStrokeColor(elementType) &&
|
||||||
|
elementType !== "image" &&
|
||||||
|
commonSelectedType !== "image") ||
|
||||||
|
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||||
|
renderAction("changeStrokeColor")}
|
||||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
|
||||||
@ -155,18 +169,20 @@ export const ShapesSwitcher = ({
|
|||||||
canvas,
|
canvas,
|
||||||
elementType,
|
elementType,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
onImageAction,
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter = typeof key === "string" ? key : key[0];
|
const letter = key && (typeof key === "string" ? key : key[0]);
|
||||||
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
const shortcut = letter
|
||||||
index + 1
|
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
|
||||||
}`;
|
: `${index + 1}`;
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Shape"
|
className="Shape"
|
||||||
@ -180,14 +196,16 @@ export const ShapesSwitcher = ({
|
|||||||
aria-label={capitalizeString(label)}
|
aria-label={capitalizeString(label)}
|
||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={value}
|
data-testid={value}
|
||||||
onChange={() => {
|
onChange={({ pointerType }) => {
|
||||||
setAppState({
|
setAppState({
|
||||||
elementType: value,
|
elementType: value,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
});
|
});
|
||||||
setCursorForShape(canvas, value);
|
setCursorForShape(canvas, value);
|
||||||
setAppState({});
|
if (value === "image") {
|
||||||
|
onImageAction({ pointerType });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -48,6 +48,10 @@
|
|||||||
.ToolIcon__label {
|
.ToolIcon__label {
|
||||||
color: $oc-white;
|
color: $oc-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -157,6 +157,8 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={["Shift+P", "7"]}
|
shortcuts={["Shift+P", "7"]}
|
||||||
/>
|
/>
|
||||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||||
|
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
||||||
|
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.editSelectedShape")}
|
label={t("helpDialog.editSelectedShape")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
|
@ -4,7 +4,11 @@ import { getSelectedElements } from "../scene";
|
|||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isLinearElement, isTextElement } from "../element/typeChecks";
|
import {
|
||||||
|
isImageElement,
|
||||||
|
isLinearElement,
|
||||||
|
isTextElement,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
|
||||||
interface Hint {
|
interface Hint {
|
||||||
@ -30,6 +34,10 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.text");
|
return t("hints.text");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||||
|
return t("hints.placeImage");
|
||||||
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
if (
|
if (
|
||||||
isResizing &&
|
isResizing &&
|
||||||
@ -40,7 +48,9 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||||
return t("hints.lockAngle");
|
return t("hints.lockAngle");
|
||||||
}
|
}
|
||||||
return t("hints.resize");
|
return isImageElement(targetElement)
|
||||||
|
? t("hints.resizeImage")
|
||||||
|
: t("hints.resize");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRotating && lastPointerDownWith === "mouse") {
|
if (isRotating && lastPointerDownWith === "mouse") {
|
||||||
|
@ -9,7 +9,7 @@ import { t } from "../i18n";
|
|||||||
import { useIsMobile } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../scene/export";
|
import { exportToCanvas } from "../scene/export";
|
||||||
import { AppState } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { clipboard, exportImage } from "./icons";
|
import { clipboard, exportImage } from "./icons";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
@ -79,6 +79,7 @@ const ExportButton: React.FC<{
|
|||||||
const ImageExportModal = ({
|
const ImageExportModal = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
@ -87,6 +88,7 @@ const ImageExportModal = ({
|
|||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
@ -112,29 +114,25 @@ const ImageExportModal = ({
|
|||||||
if (!previewNode) {
|
if (!previewNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
exportToCanvas(exportedElements, appState, files, {
|
||||||
const canvas = exportToCanvas(exportedElements, appState, {
|
exportBackground,
|
||||||
exportBackground,
|
viewBackgroundColor,
|
||||||
viewBackgroundColor,
|
exportPadding,
|
||||||
exportPadding,
|
})
|
||||||
});
|
.then((canvas) => {
|
||||||
|
// if converting to blob fails, there's some problem that will
|
||||||
// if converting to blob fails, there's some problem that will
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
return canvasToBlob(canvas).then(() => {
|
||||||
canvasToBlob(canvas)
|
|
||||||
.then(() => {
|
|
||||||
renderPreview(canvas, previewNode);
|
renderPreview(canvas, previewNode);
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
renderPreview(new CanvasError(), previewNode);
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
})
|
||||||
console.error(error);
|
.catch((error) => {
|
||||||
renderPreview(new CanvasError(), previewNode);
|
console.error(error);
|
||||||
}
|
renderPreview(new CanvasError(), previewNode);
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
exportedElements,
|
exportedElements,
|
||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
@ -220,6 +218,7 @@ const ImageExportModal = ({
|
|||||||
export const ImageExportDialog = ({
|
export const ImageExportDialog = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
@ -228,6 +227,7 @@ export const ImageExportDialog = ({
|
|||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
@ -258,6 +258,7 @@ export const ImageExportDialog = ({
|
|||||||
<ImageExportModal
|
<ImageExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
exportPadding={exportPadding}
|
exportPadding={exportPadding}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportToPng={onExportToPng}
|
onExportToPng={onExportToPng}
|
||||||
|
@ -3,7 +3,7 @@ import { ActionsManagerInterface } from "../actions/types";
|
|||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "./App";
|
import { useIsMobile } from "./App";
|
||||||
import { AppState, ExportOpts } from "../types";
|
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
@ -21,11 +21,13 @@ export type ExportCB = (
|
|||||||
const JSONExportModal = ({
|
const JSONExportModal = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
actionManager,
|
actionManager,
|
||||||
exportOpts,
|
exportOpts,
|
||||||
canvas,
|
canvas,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
@ -68,12 +70,14 @@ const JSONExportModal = ({
|
|||||||
title={t("exportDialog.link_button")}
|
title={t("exportDialog.link_button")}
|
||||||
aria-label={t("exportDialog.link_button")}
|
aria-label={t("exportDialog.link_button")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={() => onExportToBackend(elements, appState, canvas)}
|
onClick={() =>
|
||||||
|
onExportToBackend(elements, appState, files, canvas)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{exportOpts.renderCustomUI &&
|
{exportOpts.renderCustomUI &&
|
||||||
exportOpts.renderCustomUI(elements, appState, canvas)}
|
exportOpts.renderCustomUI(elements, appState, files, canvas)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -82,12 +86,14 @@ const JSONExportModal = ({
|
|||||||
export const JSONExportDialog = ({
|
export const JSONExportDialog = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
actionManager,
|
actionManager,
|
||||||
exportOpts,
|
exportOpts,
|
||||||
canvas,
|
canvas,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
actionManager: ActionsManagerInterface;
|
actionManager: ActionsManagerInterface;
|
||||||
exportOpts: ExportOpts;
|
exportOpts: ExportOpts;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
@ -116,6 +122,7 @@ export const JSONExportDialog = ({
|
|||||||
<JSONExportModal
|
<JSONExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
exportOpts={exportOpts}
|
exportOpts={exportOpts}
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
AppProps,
|
AppProps,
|
||||||
AppState,
|
AppState,
|
||||||
ExcalidrawProps,
|
ExcalidrawProps,
|
||||||
|
BinaryFiles,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
LibraryItems,
|
LibraryItems,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
@ -53,6 +54,7 @@ import { isImageFileHandle } from "../data/blob";
|
|||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
@ -76,6 +78,7 @@ interface LayerUIProps {
|
|||||||
focusContainer: () => void;
|
focusContainer: () => void;
|
||||||
library: Library;
|
library: Library;
|
||||||
id: string;
|
id: string;
|
||||||
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
@ -118,6 +121,7 @@ const LibraryMenuItems = ({
|
|||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
focusContainer,
|
focusContainer,
|
||||||
library,
|
library,
|
||||||
|
files,
|
||||||
id,
|
id,
|
||||||
}: {
|
}: {
|
||||||
libraryItems: LibraryItems;
|
libraryItems: LibraryItems;
|
||||||
@ -126,6 +130,7 @@ const LibraryMenuItems = ({
|
|||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
onAddToLibrary: (elements: LibraryItem) => void;
|
onAddToLibrary: (elements: LibraryItem) => void;
|
||||||
theme: AppState["theme"];
|
theme: AppState["theme"];
|
||||||
|
files: BinaryFiles;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
setLibraryItems: (library: LibraryItems) => void;
|
setLibraryItems: (library: LibraryItems) => void;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
@ -221,6 +226,7 @@ const LibraryMenuItems = ({
|
|||||||
<Stack.Col key={x}>
|
<Stack.Col key={x}>
|
||||||
<LibraryUnit
|
<LibraryUnit
|
||||||
elements={libraryItems[y + x]}
|
elements={libraryItems[y + x]}
|
||||||
|
files={files}
|
||||||
pendingElements={
|
pendingElements={
|
||||||
shouldAddPendingElements ? pendingElements : undefined
|
shouldAddPendingElements ? pendingElements : undefined
|
||||||
}
|
}
|
||||||
@ -255,6 +261,7 @@ const LibraryMenu = ({
|
|||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
theme,
|
theme,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
files,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
focusContainer,
|
focusContainer,
|
||||||
library,
|
library,
|
||||||
@ -265,6 +272,7 @@ const LibraryMenu = ({
|
|||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
onAddToLibrary: () => void;
|
onAddToLibrary: () => void;
|
||||||
theme: AppState["theme"];
|
theme: AppState["theme"];
|
||||||
|
files: BinaryFiles;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
focusContainer: () => void;
|
focusContainer: () => void;
|
||||||
@ -286,12 +294,12 @@ const LibraryMenu = ({
|
|||||||
"preloading" | "loading" | "ready"
|
"preloading" | "loading" | "ready"
|
||||||
>("preloading");
|
>("preloading");
|
||||||
|
|
||||||
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const loadingTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.race([
|
Promise.race([
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
loadingTimerRef.current = setTimeout(() => {
|
loadingTimerRef.current = window.setTimeout(() => {
|
||||||
resolve("loading");
|
resolve("loading");
|
||||||
}, 100);
|
}, 100);
|
||||||
}),
|
}),
|
||||||
@ -324,6 +332,12 @@ const LibraryMenu = ({
|
|||||||
|
|
||||||
const addToLibrary = useCallback(
|
const addToLibrary = useCallback(
|
||||||
async (elements: LibraryItem) => {
|
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 items = await library.loadLibrary();
|
||||||
const nextItems = [...items, elements];
|
const nextItems = [...items, elements];
|
||||||
onAddToLibrary();
|
onAddToLibrary();
|
||||||
@ -355,6 +369,7 @@ const LibraryMenu = ({
|
|||||||
focusContainer={focusContainer}
|
focusContainer={focusContainer}
|
||||||
library={library}
|
library={library}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
files={files}
|
||||||
id={id}
|
id={id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -365,6 +380,7 @@ const LibraryMenu = ({
|
|||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
setAppState,
|
setAppState,
|
||||||
canvas,
|
canvas,
|
||||||
elements,
|
elements,
|
||||||
@ -384,6 +400,7 @@ const LayerUI = ({
|
|||||||
focusContainer,
|
focusContainer,
|
||||||
library,
|
library,
|
||||||
id,
|
id,
|
||||||
|
onImageAction,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@ -396,6 +413,7 @@ const LayerUI = ({
|
|||||||
<JSONExportDialog
|
<JSONExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
exportOpts={UIOptions.canvasActions.export}
|
exportOpts={UIOptions.canvasActions.export}
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
@ -411,11 +429,17 @@ const LayerUI = ({
|
|||||||
const createExporter = (type: ExportType): ExportCB => async (
|
const createExporter = (type: ExportType): ExportCB => async (
|
||||||
exportedElements,
|
exportedElements,
|
||||||
) => {
|
) => {
|
||||||
const fileHandle = await exportCanvas(type, exportedElements, appState, {
|
const fileHandle = await exportCanvas(
|
||||||
exportBackground: appState.exportBackground,
|
type,
|
||||||
name: appState.name,
|
exportedElements,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
appState,
|
||||||
})
|
files,
|
||||||
|
{
|
||||||
|
exportBackground: appState.exportBackground,
|
||||||
|
name: appState.name,
|
||||||
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
},
|
||||||
|
)
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -435,6 +459,7 @@ const LayerUI = ({
|
|||||||
<ImageExportDialog
|
<ImageExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportToPng={createExporter("png")}
|
onExportToPng={createExporter("png")}
|
||||||
onExportToSvg={createExporter("svg")}
|
onExportToSvg={createExporter("svg")}
|
||||||
@ -561,6 +586,7 @@ const LayerUI = ({
|
|||||||
focusContainer={focusContainer}
|
focusContainer={focusContainer}
|
||||||
library={library}
|
library={library}
|
||||||
theme={appState.theme}
|
theme={appState.theme}
|
||||||
|
files={files}
|
||||||
id={id}
|
id={id}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
@ -605,6 +631,11 @@ const LayerUI = ({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
onImageAction={({ pointerType }) => {
|
||||||
|
onImageAction({
|
||||||
|
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
@ -765,6 +796,7 @@ const LayerUI = ({
|
|||||||
renderCustomFooter={renderCustomFooter}
|
renderCustomFooter={renderCustomFooter}
|
||||||
viewModeEnabled={viewModeEnabled}
|
viewModeEnabled={viewModeEnabled}
|
||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
|
onImageAction={onImageAction}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -26,7 +26,7 @@ export const LibraryButton: React.FC<{
|
|||||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
||||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
style={{ marginInlineStart: "var(--space-factor)" }}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@ -38,7 +38,7 @@ export const LibraryButton: React.FC<{
|
|||||||
}}
|
}}
|
||||||
checked={appState.isLibraryOpen}
|
checked={appState.isLibraryOpen}
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
aria-label={capitalizeString(t("toolBar.library"))}
|
||||||
aria-keyshortcuts="9"
|
aria-keyshortcuts="0"
|
||||||
/>
|
/>
|
||||||
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
||||||
</label>
|
</label>
|
||||||
|
@ -6,7 +6,7 @@ import { MIME_TYPES } from "../constants";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useIsMobile } from "../components/App";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { LibraryItem } from "../types";
|
import { BinaryFiles, LibraryItem } from "../types";
|
||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
|
|
||||||
// fa-plus
|
// fa-plus
|
||||||
@ -21,44 +21,37 @@ const PLUS_ICON = (
|
|||||||
|
|
||||||
export const LibraryUnit = ({
|
export const LibraryUnit = ({
|
||||||
elements,
|
elements,
|
||||||
|
files,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
onRemoveFromLibrary,
|
onRemoveFromLibrary,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
elements?: LibraryItem;
|
elements?: LibraryItem;
|
||||||
|
files: BinaryFiles;
|
||||||
pendingElements?: LibraryItem;
|
pendingElements?: LibraryItem;
|
||||||
onRemoveFromLibrary: () => void;
|
onRemoveFromLibrary: () => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const elementsToRender = elements || pendingElements;
|
|
||||||
if (!elementsToRender) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let svg: SVGSVGElement;
|
|
||||||
const current = ref.current!;
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
svg = await exportToSvg(elementsToRender, {
|
const elementsToRender = elements || pendingElements;
|
||||||
exportBackground: false,
|
if (!elementsToRender) {
|
||||||
viewBackgroundColor: oc.white,
|
return;
|
||||||
});
|
}
|
||||||
for (const child of ref.current!.children) {
|
const svg = await exportToSvg(
|
||||||
if (child.tagName !== "svg") {
|
elementsToRender,
|
||||||
continue;
|
{
|
||||||
}
|
exportBackground: false,
|
||||||
current!.removeChild(child);
|
viewBackgroundColor: oc.white,
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
);
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.innerHTML = svg.outerHTML;
|
||||||
}
|
}
|
||||||
current!.appendChild(svg);
|
|
||||||
})();
|
})();
|
||||||
|
}, [elements, pendingElements, files]);
|
||||||
return () => {
|
|
||||||
if (svg) {
|
|
||||||
current.removeChild(svg);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [elements, pendingElements]);
|
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
@ -33,6 +33,7 @@ type MobileMenuProps = {
|
|||||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
renderTopRightUI?: (
|
renderTopRightUI?: (
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@ -54,6 +55,7 @@ export const MobileMenu = ({
|
|||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
|
onImageAction,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
}: MobileMenuProps) => {
|
}: MobileMenuProps) => {
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
@ -70,6 +72,11 @@ export const MobileMenu = ({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
onImageAction={({ pointerType }) => {
|
||||||
|
onImageAction({
|
||||||
|
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
|
@ -38,10 +38,14 @@ const ChartPreviewBtn = (props: {
|
|||||||
const previewNode = previewRef.current!;
|
const previewNode = previewRef.current!;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
svg = await exportToSvg(elements, {
|
svg = await exportToSvg(
|
||||||
exportBackground: false,
|
elements,
|
||||||
viewBackgroundColor: oc.white,
|
{
|
||||||
});
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
},
|
||||||
|
null, // files
|
||||||
|
);
|
||||||
|
|
||||||
previewNode.appendChild(svg);
|
previewNode.appendChild(svg);
|
||||||
|
|
||||||
|
48
src/components/Spinner.scss
Normal file
48
src/components/Spinner.scss
Normal 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
src/components/Spinner.tsx
Normal file
28
src/components/Spinner.tsx
Normal 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,8 +1,11 @@
|
|||||||
import "./ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useExcalidrawContainer } from "./App";
|
import { useExcalidrawContainer } from "./App";
|
||||||
|
import { AbortError } from "../errors";
|
||||||
|
import Spinner from "./Spinner";
|
||||||
|
import { PointerType } from "../element/types";
|
||||||
|
|
||||||
export type ToolButtonSize = "small" | "medium";
|
export type ToolButtonSize = "small" | "medium";
|
||||||
|
|
||||||
@ -28,7 +31,7 @@ type ToolButtonProps =
|
|||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "button";
|
type: "button";
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?(): void;
|
onClick?(event: React.MouseEvent): void;
|
||||||
})
|
})
|
||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "icon";
|
type: "icon";
|
||||||
@ -38,7 +41,7 @@ type ToolButtonProps =
|
|||||||
| (ToolButtonBaseProps & {
|
| (ToolButtonBaseProps & {
|
||||||
type: "radio";
|
type: "radio";
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?(): void;
|
onChange?(data: { pointerType: PointerType | null }): void;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
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);
|
React.useImperativeHandle(ref, () => innerRef.current);
|
||||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
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") {
|
if (props.type === "button" || props.type === "icon") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -68,8 +103,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
title={props.title}
|
title={props.title}
|
||||||
aria-label={props["aria-label"]}
|
aria-label={props["aria-label"]}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={props.onClick}
|
onClick={onClick}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{(props.icon || props.label) && (
|
{(props.icon || props.label) && (
|
||||||
<div className="ToolIcon__icon" aria-hidden="true">
|
<div className="ToolIcon__icon" aria-hidden="true">
|
||||||
@ -82,7 +118,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{props.showAriaLabel && (
|
{props.showAriaLabel && (
|
||||||
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
<div className="ToolIcon__label">
|
||||||
|
{props["aria-label"]} {isLoading && <Spinner />}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{props.children}
|
{props.children}
|
||||||
</button>
|
</button>
|
||||||
@ -90,7 +128,18 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<input
|
||||||
className={`ToolIcon_type_radio ${sizeCn}`}
|
className={`ToolIcon_type_radio ${sizeCn}`}
|
||||||
type="radio"
|
type="radio"
|
||||||
@ -99,7 +148,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||||
data-testid={props["data-testid"]}
|
data-testid={props["data-testid"]}
|
||||||
id={`${excalId}-${props.id}`}
|
id={`${excalId}-${props.id}`}
|
||||||
onChange={props.onChange}
|
onChange={() => {
|
||||||
|
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
||||||
|
}}
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
/>
|
/>
|
||||||
|
@ -54,10 +54,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__label {
|
.ToolIcon__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
color: var(--icon-fill-color);
|
color: var(--icon-fill-color);
|
||||||
font-family: var(--ui-font);
|
font-family: var(--ui-font);
|
||||||
margin: 0 0.8em;
|
margin: 0 0.8em;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
margin-left: 0.6em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon_size_small .ToolIcon__icon {
|
.ToolIcon_size_small .ToolIcon__icon {
|
||||||
|
@ -90,6 +90,12 @@ export const GRID_SIZE = 20; // TODO make it configurable?
|
|||||||
export const MIME_TYPES = {
|
export const MIME_TYPES = {
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+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;
|
} as const;
|
||||||
|
|
||||||
export const EXPORT_DATA_TYPES = {
|
export const EXPORT_DATA_TYPES = {
|
||||||
@ -105,6 +111,7 @@ export const STORAGE_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
|
export const IMAGE_RENDER_TIMEOUT = 500;
|
||||||
export const TAP_TWICE_TIMEOUT = 300;
|
export const TAP_TWICE_TIMEOUT = 300;
|
||||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||||
export const TITLE_TIMEOUT = 10000;
|
export const TITLE_TIMEOUT = 10000;
|
||||||
@ -154,3 +161,16 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
|||||||
|
|
||||||
export const EXPORT_SCALES = [1, 2, 3];
|
export const EXPORT_SCALES = [1, 2, 3];
|
||||||
export const DEFAULT_EXPORT_PADDING = 10; // px
|
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";
|
||||||
|
143
src/data/blob.ts
143
src/data/blob.ts
@ -1,11 +1,16 @@
|
|||||||
|
import { nanoid } from "nanoid";
|
||||||
import { cleanAppStateForExport } from "../appState";
|
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 { clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement, FileId } from "../element/types";
|
||||||
import { CanvasError } from "../errors";
|
import { CanvasError } from "../errors";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState, DataURL } from "../types";
|
||||||
import { FileSystemHandle } from "./filesystem";
|
import { FileSystemHandle } from "./filesystem";
|
||||||
import { isValidExcalidrawData } from "./json";
|
import { isValidExcalidrawData } from "./json";
|
||||||
import { restore } from "./restore";
|
import { restore } from "./restore";
|
||||||
@ -14,16 +19,22 @@ import { ImportedLibraryData } from "./types";
|
|||||||
const parseFileContents = async (blob: Blob | File) => {
|
const parseFileContents = async (blob: Blob | File) => {
|
||||||
let contents: string;
|
let contents: string;
|
||||||
|
|
||||||
if (blob.type === "image/png") {
|
if (blob.type === MIME_TYPES.png) {
|
||||||
try {
|
try {
|
||||||
return await (
|
return await (
|
||||||
await import(/* webpackChunkName: "image" */ "./image")
|
await import(/* webpackChunkName: "image" */ "./image")
|
||||||
).decodePngMetadata(blob);
|
).decodePngMetadata(blob);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "INVALID") {
|
if (error.message === "INVALID") {
|
||||||
throw new Error(t("alerts.imageDoesNotContainScene"));
|
throw new DOMException(
|
||||||
|
t("alerts.imageDoesNotContainScene"),
|
||||||
|
"EncodingError",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(t("alerts.cannotRestoreFromImage"));
|
throw new DOMException(
|
||||||
|
t("alerts.cannotRestoreFromImage"),
|
||||||
|
"EncodingError",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -40,7 +51,7 @@ const parseFileContents = async (blob: Blob | File) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (blob.type === "image/svg+xml") {
|
if (blob.type === MIME_TYPES.svg) {
|
||||||
try {
|
try {
|
||||||
return await (
|
return await (
|
||||||
await import(/* webpackChunkName: "image" */ "./image")
|
await import(/* webpackChunkName: "image" */ "./image")
|
||||||
@ -49,9 +60,15 @@ const parseFileContents = async (blob: Blob | File) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "INVALID") {
|
if (error.message === "INVALID") {
|
||||||
throw new Error(t("alerts.imageDoesNotContainScene"));
|
throw new DOMException(
|
||||||
|
t("alerts.imageDoesNotContainScene"),
|
||||||
|
"EncodingError",
|
||||||
|
);
|
||||||
} else {
|
} 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 || "";
|
name = blob.name || "";
|
||||||
}
|
}
|
||||||
if (/\.(excalidraw|json)$/.test(name)) {
|
if (/\.(excalidraw|json)$/.test(name)) {
|
||||||
return "application/json";
|
return MIME_TYPES.json;
|
||||||
} else if (/\.png$/.test(name)) {
|
} else if (/\.png$/.test(name)) {
|
||||||
return "image/png";
|
return MIME_TYPES.png;
|
||||||
} else if (/\.jpe?g$/.test(name)) {
|
} else if (/\.jpe?g$/.test(name)) {
|
||||||
return "image/jpeg";
|
return MIME_TYPES.jpg;
|
||||||
} else if (/\.svg$/.test(name)) {
|
} else if (/\.svg$/.test(name)) {
|
||||||
return "image/svg+xml";
|
return MIME_TYPES.svg;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
@ -100,6 +117,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
|||||||
return type === "png" || type === "svg";
|
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 (
|
export const loadFromBlob = async (
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
/** @see restore.localAppState */
|
/** @see restore.localAppState */
|
||||||
@ -123,6 +149,7 @@ export const loadFromBlob = async (
|
|||||||
? calculateScrollCenter(data.elements || [], localAppState, null)
|
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
files: data.files,
|
||||||
},
|
},
|
||||||
localAppState,
|
localAppState,
|
||||||
localElements,
|
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 };
|
||||||
|
};
|
||||||
|
@ -1,16 +1,19 @@
|
|||||||
import { deflate, inflate } from "pako";
|
import { deflate, inflate } from "pako";
|
||||||
|
import { encryptData, decryptData } from "./encryption";
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// byte (binary) strings
|
// byte (binary) strings
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// fast, Buffer-compatible implem
|
// 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const blob =
|
const blob =
|
||||||
typeof data === "string"
|
typeof data === "string"
|
||||||
? new Blob([new TextEncoder().encode(data)])
|
? new Blob([new TextEncoder().encode(data)])
|
||||||
: new Blob([data]);
|
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
if (!event.target || typeof event.target.result !== "string") {
|
if (!event.target || typeof event.target.result !== "string") {
|
||||||
@ -44,12 +47,14 @@ const byteStringToString = (byteString: string) => {
|
|||||||
* due to reencoding
|
* due to reencoding
|
||||||
*/
|
*/
|
||||||
export const stringToBase64 = async (str: string, isByteString = false) => {
|
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
|
// async to align with stringToBase64
|
||||||
export const base64ToString = async (base64: string, isByteString = false) => {
|
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;
|
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
src/data/encryption.ts
Normal file
79
src/data/encryption.ts
Normal 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,
|
||||||
|
);
|
||||||
|
};
|
@ -10,6 +10,7 @@ import { AbortError } from "../errors";
|
|||||||
import { debounce } from "../utils";
|
import { debounce } from "../utils";
|
||||||
|
|
||||||
type FILE_EXTENSION =
|
type FILE_EXTENSION =
|
||||||
|
| "gif"
|
||||||
| "jpg"
|
| "jpg"
|
||||||
| "png"
|
| "png"
|
||||||
| "svg"
|
| "svg"
|
||||||
@ -17,15 +18,6 @@ type FILE_EXTENSION =
|
|||||||
| "excalidraw"
|
| "excalidraw"
|
||||||
| "excalidrawlib";
|
| "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;
|
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||||
|
|
||||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||||
@ -41,7 +33,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
: FileWithHandle[];
|
: FileWithHandle[];
|
||||||
|
|
||||||
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||||
mimeTypes.push(FILE_TYPE_TO_MIME_TYPE[type]);
|
mimeTypes.push(MIME_TYPES[type]);
|
||||||
|
|
||||||
return mimeTypes;
|
return mimeTypes;
|
||||||
}, [] as string[]);
|
}, [] as string[]);
|
||||||
|
@ -57,7 +57,7 @@ export const encodePngMetadata = async ({
|
|||||||
// insert metadata before last chunk (iEND)
|
// insert metadata before last chunk (iEND)
|
||||||
chunks.splice(-1, 0, metadataChunk);
|
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) => {
|
export const decodePngMetadata = async (blob: Blob) => {
|
||||||
|
@ -2,12 +2,12 @@ import {
|
|||||||
copyBlobToClipboardAsPng,
|
copyBlobToClipboardAsPng,
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppState } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { canvasToBlob } from "./blob";
|
import { canvasToBlob } from "./blob";
|
||||||
import { fileSave, FileSystemHandle } from "./filesystem";
|
import { fileSave, FileSystemHandle } from "./filesystem";
|
||||||
import { serializeAsJSON } from "./json";
|
import { serializeAsJSON } from "./json";
|
||||||
@ -19,6 +19,7 @@ export const exportCanvas = async (
|
|||||||
type: ExportType,
|
type: ExportType,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
{
|
{
|
||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
@ -37,17 +38,21 @@ export const exportCanvas = async (
|
|||||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||||
}
|
}
|
||||||
if (type === "svg" || type === "clipboard-svg") {
|
if (type === "svg" || type === "clipboard-svg") {
|
||||||
const tempSvg = await exportToSvg(elements, {
|
const tempSvg = await exportToSvg(
|
||||||
exportBackground,
|
elements,
|
||||||
exportWithDarkMode: appState.exportWithDarkMode,
|
{
|
||||||
viewBackgroundColor,
|
exportBackground,
|
||||||
exportPadding,
|
exportWithDarkMode: appState.exportWithDarkMode,
|
||||||
exportScale: appState.exportScale,
|
viewBackgroundColor,
|
||||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
exportPadding,
|
||||||
});
|
exportScale: appState.exportScale,
|
||||||
|
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
);
|
||||||
if (type === "svg") {
|
if (type === "svg") {
|
||||||
return await fileSave(
|
return await fileSave(
|
||||||
new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }),
|
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
extension: "svg",
|
extension: "svg",
|
||||||
@ -60,7 +65,7 @@ export const exportCanvas = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempCanvas = exportToCanvas(elements, appState, {
|
const tempCanvas = await exportToCanvas(elements, appState, files, {
|
||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
@ -76,7 +81,7 @@ export const exportCanvas = async (
|
|||||||
await import(/* webpackChunkName: "image" */ "./image")
|
await import(/* webpackChunkName: "image" */ "./image")
|
||||||
).encodePngMetadata({
|
).encodePngMetadata({
|
||||||
blob,
|
blob,
|
||||||
metadata: serializeAsJSON(elements, appState),
|
metadata: serializeAsJSON(elements, appState, files, "local"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { fileOpen, fileSave } from "./filesystem";
|
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 { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
import { isImageFileHandle, loadFromBlob } from "./blob";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -13,16 +13,50 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
import Library from "./library";
|
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 = (
|
export const serializeAsJSON = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
|
files: BinaryFiles,
|
||||||
|
type: "local" | "database",
|
||||||
): string => {
|
): string => {
|
||||||
const data: ExportedDataState = {
|
const data: ExportedDataState = {
|
||||||
type: EXPORT_DATA_TYPES.excalidraw,
|
type: EXPORT_DATA_TYPES.excalidraw,
|
||||||
version: 2,
|
version: 2,
|
||||||
source: EXPORT_SOURCE,
|
source: EXPORT_SOURCE,
|
||||||
elements: clearElementsForExport(elements),
|
elements:
|
||||||
appState: cleanAppStateForExport(appState),
|
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);
|
return JSON.stringify(data, null, 2);
|
||||||
@ -31,8 +65,9 @@ export const serializeAsJSON = (
|
|||||||
export const saveAsJSON = async (
|
export const saveAsJSON = async (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
const serialized = serializeAsJSON(elements, appState);
|
const serialized = serializeAsJSON(elements, appState, files, "local");
|
||||||
const blob = new Blob([serialized], {
|
const blob = new Blob([serialized], {
|
||||||
type: MIME_TYPES.excalidraw,
|
type: MIME_TYPES.excalidraw,
|
||||||
});
|
});
|
||||||
@ -56,15 +91,7 @@ export const loadFromJSON = async (
|
|||||||
description: "Excalidraw files",
|
description: "Excalidraw files",
|
||||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||||
/*
|
// extensions: ["json", "excalidraw", "png", "svg"],
|
||||||
extensions: [".json", ".excalidraw", ".png", ".svg"],
|
|
||||||
mimeTypes: [
|
|
||||||
MIME_TYPES.excalidraw,
|
|
||||||
"application/json",
|
|
||||||
"image/png",
|
|
||||||
"image/svg+xml",
|
|
||||||
],
|
|
||||||
*/
|
|
||||||
});
|
});
|
||||||
return loadFromBlob(blob, localAppState, localElements);
|
return loadFromBlob(blob, localAppState, localElements);
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { exportCanvas } from ".";
|
import { exportCanvas } from ".";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||||
@ -7,6 +7,7 @@ import { getFileHandleType, isImageFileHandleType } from "./blob";
|
|||||||
export const resaveAsImageWithScene = async (
|
export const resaveAsImageWithScene = async (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
|
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ export const resaveAsImageWithScene = async (
|
|||||||
fileHandleType,
|
fileHandleType,
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
{
|
{
|
||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
|
@ -3,7 +3,7 @@ import {
|
|||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
|
||||||
import { ImportedDataState } from "./types";
|
import { ImportedDataState } from "./types";
|
||||||
import {
|
import {
|
||||||
getElementMap,
|
getElementMap,
|
||||||
@ -37,6 +37,7 @@ export const AllowedExcalidrawElementTypes: Record<
|
|||||||
diamond: true,
|
diamond: true,
|
||||||
ellipse: true,
|
ellipse: true,
|
||||||
line: true,
|
line: true,
|
||||||
|
image: true,
|
||||||
arrow: true,
|
arrow: true,
|
||||||
freedraw: true,
|
freedraw: true,
|
||||||
};
|
};
|
||||||
@ -44,6 +45,7 @@ export const AllowedExcalidrawElementTypes: Record<
|
|||||||
export type RestoredDataState = {
|
export type RestoredDataState = {
|
||||||
elements: ExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
appState: RestoredAppState;
|
appState: RestoredAppState;
|
||||||
|
files: BinaryFiles;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||||
@ -57,16 +59,19 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||||||
|
|
||||||
const restoreElementWithProperties = <
|
const restoreElementWithProperties = <
|
||||||
T extends ExcalidrawElement,
|
T extends ExcalidrawElement,
|
||||||
K extends keyof Omit<
|
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>
|
||||||
Required<T>,
|
|
||||||
Exclude<keyof ExcalidrawElement, "type" | "x" | "y">
|
|
||||||
>
|
|
||||||
>(
|
>(
|
||||||
element: Required<T>,
|
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 => {
|
): T => {
|
||||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
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
|
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||||
// newly added elements
|
// newly added elements
|
||||||
version: element.version || 1,
|
version: element.version || 1,
|
||||||
@ -79,8 +84,8 @@ const restoreElementWithProperties = <
|
|||||||
roughness: element.roughness ?? 1,
|
roughness: element.roughness ?? 1,
|
||||||
opacity: element.opacity == null ? 100 : element.opacity,
|
opacity: element.opacity == null ? 100 : element.opacity,
|
||||||
angle: element.angle || 0,
|
angle: element.angle || 0,
|
||||||
x: (extra as Partial<T>).x ?? element.x ?? 0,
|
x: extra.x ?? element.x ?? 0,
|
||||||
y: (extra as Partial<T>).y ?? element.y ?? 0,
|
y: extra.y ?? element.y ?? 0,
|
||||||
strokeColor: element.strokeColor,
|
strokeColor: element.strokeColor,
|
||||||
backgroundColor: element.backgroundColor,
|
backgroundColor: element.backgroundColor,
|
||||||
width: element.width || 0,
|
width: element.width || 0,
|
||||||
@ -102,7 +107,7 @@ const restoreElementWithProperties = <
|
|||||||
|
|
||||||
const restoreElement = (
|
const restoreElement = (
|
||||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
): typeof element => {
|
): typeof element | null => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "text":
|
case "text":
|
||||||
let fontSize = element.fontSize;
|
let fontSize = element.fontSize;
|
||||||
@ -131,6 +136,12 @@ const restoreElement = (
|
|||||||
pressures: element.pressures,
|
pressures: element.pressures,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
case "image":
|
||||||
|
return restoreElementWithProperties(element, {
|
||||||
|
status: element.status || "pending",
|
||||||
|
fileId: element.fileId,
|
||||||
|
scale: element.scale || [1, 1],
|
||||||
|
});
|
||||||
case "line":
|
case "line":
|
||||||
// @ts-ignore LEGACY type
|
// @ts-ignore LEGACY type
|
||||||
// eslint-disable-next-line no-fallthrough
|
// eslint-disable-next-line no-fallthrough
|
||||||
@ -194,7 +205,7 @@ export const restoreElements = (
|
|||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
// and causing issues if retained
|
// and causing issues if retained
|
||||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||||
let migratedElement: ExcalidrawElement = restoreElement(element);
|
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||||
if (migratedElement) {
|
if (migratedElement) {
|
||||||
const localElement = localElementsMap?.[element.id];
|
const localElement = localElementsMap?.[element.id];
|
||||||
if (localElement && localElement.version > migratedElement.version) {
|
if (localElement && localElement.version > migratedElement.version) {
|
||||||
@ -260,5 +271,6 @@ export const restore = (
|
|||||||
return {
|
return {
|
||||||
elements: restoreElements(data?.elements, localElements),
|
elements: restoreElements(data?.elements, localElements),
|
||||||
appState: restoreAppState(data?.appState, localAppState || null),
|
appState: restoreAppState(data?.appState, localAppState || null),
|
||||||
|
files: data?.files || {},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, LibraryItems } from "../types";
|
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||||
import type { cleanAppStateForExport } from "../appState";
|
import type { cleanAppStateForExport } from "../appState";
|
||||||
|
|
||||||
export interface ExportedDataState {
|
export interface ExportedDataState {
|
||||||
@ -8,6 +8,7 @@ export interface ExportedDataState {
|
|||||||
source: string;
|
source: string;
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: ReturnType<typeof cleanAppStateForExport>;
|
appState: ReturnType<typeof cleanAppStateForExport>;
|
||||||
|
files: BinaryFiles | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportedDataState {
|
export interface ImportedDataState {
|
||||||
@ -18,6 +19,7 @@ export interface ImportedDataState {
|
|||||||
appState?: Readonly<Partial<AppState>> | null;
|
appState?: Readonly<Partial<AppState>> | null;
|
||||||
scrollToContent?: boolean;
|
scrollToContent?: boolean;
|
||||||
libraryItems?: LibraryItems;
|
libraryItems?: LibraryItems;
|
||||||
|
files?: BinaryFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportedLibraryData {
|
export interface ExportedLibraryData {
|
||||||
|
@ -23,6 +23,7 @@ import {
|
|||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||||
@ -30,6 +31,7 @@ import { Point } from "../types";
|
|||||||
import { Drawable } from "roughjs/bin/core";
|
import { Drawable } from "roughjs/bin/core";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShapeForElement } from "../renderer/renderElement";
|
import { getShapeForElement } from "../renderer/renderElement";
|
||||||
|
import { isImageElement } from "./typeChecks";
|
||||||
|
|
||||||
const isElementDraggableFromInside = (
|
const isElementDraggableFromInside = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
@ -47,8 +49,7 @@ const isElementDraggableFromInside = (
|
|||||||
if (element.type === "line") {
|
if (element.type === "line") {
|
||||||
return isDraggableFromInside && isPathALoop(element.points);
|
return isDraggableFromInside && isPathALoop(element.points);
|
||||||
}
|
}
|
||||||
|
return isDraggableFromInside || isImageElement(element);
|
||||||
return isDraggableFromInside;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitTest = (
|
export const hitTest = (
|
||||||
@ -161,6 +162,7 @@ type HitTestArgs = {
|
|||||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||||
switch (args.element.type) {
|
switch (args.element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
@ -195,6 +197,7 @@ export const distanceToBindableElement = (
|
|||||||
): number => {
|
): number => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
return distanceToRectangle(element, point);
|
return distanceToRectangle(element, point);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
@ -224,7 +227,8 @@ const distanceToRectangle = (
|
|||||||
element:
|
element:
|
||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawFreeDrawElement,
|
| ExcalidrawFreeDrawElement
|
||||||
|
| ExcalidrawImageElement,
|
||||||
point: Point,
|
point: Point,
|
||||||
): number => {
|
): number => {
|
||||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||||
@ -486,6 +490,7 @@ export const determineFocusDistance = (
|
|||||||
const nabs = Math.abs(n);
|
const nabs = Math.abs(n);
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
return c / (hwidth * (nabs + q * mabs));
|
return c / (hwidth * (nabs + q * mabs));
|
||||||
case "diamond":
|
case "diamond":
|
||||||
@ -516,6 +521,7 @@ export const determineFocusPoint = (
|
|||||||
let point;
|
let point;
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||||
@ -565,6 +571,7 @@ const getSortedElementLineIntersections = (
|
|||||||
let intersections: GA.Point[];
|
let intersections: GA.Point[];
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
const corners = getCorners(element);
|
const corners = getCorners(element);
|
||||||
@ -598,6 +605,7 @@ const getSortedElementLineIntersections = (
|
|||||||
const getCorners = (
|
const getCorners = (
|
||||||
element:
|
element:
|
||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawImageElement
|
||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawTextElement,
|
| ExcalidrawTextElement,
|
||||||
scale: number = 1,
|
scale: number = 1,
|
||||||
@ -606,6 +614,7 @@ const getCorners = (
|
|||||||
const hy = (scale * element.height) / 2;
|
const hy = (scale * element.height) / 2;
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
return [
|
return [
|
||||||
GA.point(hx, hy),
|
GA.point(hx, hy),
|
||||||
@ -747,6 +756,7 @@ export const findFocusPointForEllipse = (
|
|||||||
export const findFocusPointForRectangulars = (
|
export const findFocusPointForRectangulars = (
|
||||||
element:
|
element:
|
||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawImageElement
|
||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawTextElement,
|
| ExcalidrawTextElement,
|
||||||
// Between -1 and 1 for how far away should the focus point be relative
|
// Between -1 and 1 for how far away should the focus point be relative
|
||||||
|
@ -62,25 +62,32 @@ export const dragNewElement = (
|
|||||||
y: number,
|
y: number,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
isResizeWithSidesSameLength: boolean,
|
shouldMaintainAspectRatio: boolean,
|
||||||
isResizeCenterPoint: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
|
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||||
|
true */
|
||||||
|
widthAspectRatio?: number | null,
|
||||||
) => {
|
) => {
|
||||||
if (isResizeWithSidesSameLength) {
|
if (shouldMaintainAspectRatio) {
|
||||||
({ width, height } = getPerfectElementSize(
|
if (widthAspectRatio) {
|
||||||
elementType,
|
height = width / widthAspectRatio;
|
||||||
width,
|
} else {
|
||||||
y < originY ? -height : height,
|
({ width, height } = getPerfectElementSize(
|
||||||
));
|
elementType,
|
||||||
|
width,
|
||||||
|
y < originY ? -height : height,
|
||||||
|
));
|
||||||
|
|
||||||
if (height < 0) {
|
if (height < 0) {
|
||||||
height = -height;
|
height = -height;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let newX = x < originX ? originX - width : originX;
|
let newX = x < originX ? originX - width : originX;
|
||||||
let newY = y < originY ? originY - height : originY;
|
let newY = y < originY ? originY - height : originY;
|
||||||
|
|
||||||
if (isResizeCenterPoint) {
|
if (shouldResizeFromCenter) {
|
||||||
width += width;
|
width += width;
|
||||||
height += height;
|
height += height;
|
||||||
newX = originX - width / 2;
|
newX = originX - width / 2;
|
||||||
|
111
src/element/image.ts
Normal file
111
src/element/image.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
@ -11,6 +11,7 @@ export {
|
|||||||
newTextElement,
|
newTextElement,
|
||||||
updateTextElement,
|
updateTextElement,
|
||||||
newLinearElement,
|
newLinearElement,
|
||||||
|
newImageElement,
|
||||||
duplicateElement,
|
duplicateElement,
|
||||||
} from "./newElement";
|
} from "./newElement";
|
||||||
export {
|
export {
|
||||||
@ -93,6 +94,10 @@ const _clearElements = (
|
|||||||
: element,
|
: element,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const clearElementsForDatabase = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
) => _clearElements(elements);
|
||||||
|
|
||||||
export const clearElementsForExport = (
|
export const clearElementsForExport = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => _clearElements(elements);
|
) => _clearElements(elements);
|
||||||
|
@ -17,12 +17,13 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
|||||||
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||||
element: TElement,
|
element: TElement,
|
||||||
updates: ElementUpdate<TElement>,
|
updates: ElementUpdate<TElement>,
|
||||||
) => {
|
informMutation = true,
|
||||||
|
): TElement => {
|
||||||
let didChange = false;
|
let didChange = false;
|
||||||
|
|
||||||
// casting to any because can't use `in` operator
|
// casting to any because can't use `in` operator
|
||||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||||
const { points } = updates as any;
|
const { points, fileId } = updates as any;
|
||||||
|
|
||||||
if (typeof points !== "undefined") {
|
if (typeof points !== "undefined") {
|
||||||
updates = { ...getSizeFromPoints(points), ...updates };
|
updates = { ...getSizeFromPoints(points), ...updates };
|
||||||
@ -33,13 +34,23 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
if (typeof value !== "undefined") {
|
if (typeof value !== "undefined") {
|
||||||
if (
|
if (
|
||||||
(element as any)[key] === value &&
|
(element as any)[key] === value &&
|
||||||
// if object, always update in case its deep prop was mutated
|
// if object, always update because its attrs could have changed
|
||||||
(typeof value !== "object" || value === null || key === "groupIds")
|
// (except for specific keys we handle below)
|
||||||
|
(typeof value !== "object" ||
|
||||||
|
value === null ||
|
||||||
|
key === "groupIds" ||
|
||||||
|
key === "scale")
|
||||||
) {
|
) {
|
||||||
continue;
|
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 prevPoints = (element as any)[key];
|
||||||
const nextPoints = value;
|
const nextPoints = value;
|
||||||
if (prevPoints.length === nextPoints.length) {
|
if (prevPoints.length === nextPoints.length) {
|
||||||
@ -66,14 +77,14 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
didChange = true;
|
didChange = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!didChange) {
|
if (!didChange) {
|
||||||
return;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof updates.height !== "undefined" ||
|
typeof updates.height !== "undefined" ||
|
||||||
typeof updates.width !== "undefined" ||
|
typeof updates.width !== "undefined" ||
|
||||||
|
typeof fileId != "undefined" ||
|
||||||
typeof points !== "undefined"
|
typeof points !== "undefined"
|
||||||
) {
|
) {
|
||||||
invalidateShapeForElement(element);
|
invalidateShapeForElement(element);
|
||||||
@ -81,7 +92,12 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
|
|
||||||
element.version++;
|
element.version++;
|
||||||
element.versionNonce = randomInteger();
|
element.versionNonce = randomInteger();
|
||||||
Scene.getScene(element)?.informMutation();
|
|
||||||
|
if (informMutation) {
|
||||||
|
Scene.getScene(element)?.informMutation();
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||||
@ -94,8 +110,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
|||||||
if (typeof value !== "undefined") {
|
if (typeof value !== "undefined") {
|
||||||
if (
|
if (
|
||||||
(element as any)[key] === value &&
|
(element as any)[key] === value &&
|
||||||
// if object, always update in case its deep prop was mutated
|
// if object, always update because its attrs could have changed
|
||||||
(typeof value !== "object" || value === null || key === "groupIds")
|
(typeof value !== "object" || value === null)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawGenericElement,
|
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
|
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
|
||||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
||||||
//
|
//
|
||||||
|
@ -47,9 +47,9 @@ export const transformElements = (
|
|||||||
transformHandleType: MaybeTransformHandleType,
|
transformHandleType: MaybeTransformHandleType,
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
resizeArrowDirection: "origin" | "end",
|
resizeArrowDirection: "origin" | "end",
|
||||||
isRotateWithDiscreteAngle: boolean,
|
shouldRotateWithDiscreteAngle: boolean,
|
||||||
isResizeCenterPoint: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
shouldKeepSidesRatio: boolean,
|
shouldMaintainAspectRatio: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
centerX: number,
|
centerX: number,
|
||||||
@ -62,7 +62,7 @@ export const transformElements = (
|
|||||||
element,
|
element,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
isRotateWithDiscreteAngle,
|
shouldRotateWithDiscreteAngle,
|
||||||
);
|
);
|
||||||
updateBoundElements(element);
|
updateBoundElements(element);
|
||||||
} else if (
|
} else if (
|
||||||
@ -76,7 +76,7 @@ export const transformElements = (
|
|||||||
reshapeSingleTwoPointElement(
|
reshapeSingleTwoPointElement(
|
||||||
element,
|
element,
|
||||||
resizeArrowDirection,
|
resizeArrowDirection,
|
||||||
isRotateWithDiscreteAngle,
|
shouldRotateWithDiscreteAngle,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
);
|
);
|
||||||
@ -90,7 +90,7 @@ export const transformElements = (
|
|||||||
resizeSingleTextElement(
|
resizeSingleTextElement(
|
||||||
element,
|
element,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
isResizeCenterPoint,
|
shouldResizeFromCenter,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
);
|
);
|
||||||
@ -98,10 +98,10 @@ export const transformElements = (
|
|||||||
} else if (transformHandleType) {
|
} else if (transformHandleType) {
|
||||||
resizeSingleElement(
|
resizeSingleElement(
|
||||||
pointerDownState.originalElements.get(element.id) as typeof element,
|
pointerDownState.originalElements.get(element.id) as typeof element,
|
||||||
shouldKeepSidesRatio,
|
shouldMaintainAspectRatio,
|
||||||
element,
|
element,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
isResizeCenterPoint,
|
shouldResizeFromCenter,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
);
|
);
|
||||||
@ -115,7 +115,7 @@ export const transformElements = (
|
|||||||
selectedElements,
|
selectedElements,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
isRotateWithDiscreteAngle,
|
shouldRotateWithDiscreteAngle,
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
);
|
);
|
||||||
@ -142,13 +142,13 @@ const rotateSingleElement = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
isRotateWithDiscreteAngle: boolean,
|
shouldRotateWithDiscreteAngle: boolean,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||||
if (isRotateWithDiscreteAngle) {
|
if (shouldRotateWithDiscreteAngle) {
|
||||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||||
}
|
}
|
||||||
@ -187,7 +187,7 @@ const getPerfectElementSizeWithRotation = (
|
|||||||
export const reshapeSingleTwoPointElement = (
|
export const reshapeSingleTwoPointElement = (
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
resizeArrowDirection: "origin" | "end",
|
resizeArrowDirection: "origin" | "end",
|
||||||
isRotateWithDiscreteAngle: boolean,
|
shouldRotateWithDiscreteAngle: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
) => {
|
) => {
|
||||||
@ -212,7 +212,7 @@ export const reshapeSingleTwoPointElement = (
|
|||||||
element.x + element.points[1][0] - rotatedX,
|
element.x + element.points[1][0] - rotatedX,
|
||||||
element.y + element.points[1][1] - rotatedY,
|
element.y + element.points[1][1] - rotatedY,
|
||||||
];
|
];
|
||||||
if (isRotateWithDiscreteAngle) {
|
if (shouldRotateWithDiscreteAngle) {
|
||||||
[width, height] = getPerfectElementSizeWithRotation(
|
[width, height] = getPerfectElementSizeWithRotation(
|
||||||
element.type,
|
element.type,
|
||||||
width,
|
width,
|
||||||
@ -281,28 +281,28 @@ const measureFontSizeFromWH = (
|
|||||||
|
|
||||||
const getSidesForTransformHandle = (
|
const getSidesForTransformHandle = (
|
||||||
transformHandleType: TransformHandleType,
|
transformHandleType: TransformHandleType,
|
||||||
isResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
n:
|
n:
|
||||||
/^(n|ne|nw)$/.test(transformHandleType) ||
|
/^(n|ne|nw)$/.test(transformHandleType) ||
|
||||||
(isResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
|
(shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
|
||||||
s:
|
s:
|
||||||
/^(s|se|sw)$/.test(transformHandleType) ||
|
/^(s|se|sw)$/.test(transformHandleType) ||
|
||||||
(isResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
|
(shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
|
||||||
w:
|
w:
|
||||||
/^(w|nw|sw)$/.test(transformHandleType) ||
|
/^(w|nw|sw)$/.test(transformHandleType) ||
|
||||||
(isResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
|
(shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
|
||||||
e:
|
e:
|
||||||
/^(e|ne|se)$/.test(transformHandleType) ||
|
/^(e|ne|se)$/.test(transformHandleType) ||
|
||||||
(isResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
|
(shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const resizeSingleTextElement = (
|
const resizeSingleTextElement = (
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||||
isResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
) => {
|
) => {
|
||||||
@ -361,7 +361,7 @@ const resizeSingleTextElement = (
|
|||||||
const deltaX2 = (x2 - nextX2) / 2;
|
const deltaX2 = (x2 - nextX2) / 2;
|
||||||
const deltaY2 = (y2 - nextY2) / 2;
|
const deltaY2 = (y2 - nextY2) / 2;
|
||||||
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
||||||
getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
|
getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
|
||||||
element.x,
|
element.x,
|
||||||
element.y,
|
element.y,
|
||||||
element.angle,
|
element.angle,
|
||||||
@ -383,10 +383,10 @@ const resizeSingleTextElement = (
|
|||||||
|
|
||||||
export const resizeSingleElement = (
|
export const resizeSingleElement = (
|
||||||
stateAtResizeStart: NonDeletedExcalidrawElement,
|
stateAtResizeStart: NonDeletedExcalidrawElement,
|
||||||
shouldKeepSidesRatio: boolean,
|
shouldMaintainAspectRatio: boolean,
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
transformHandleDirection: TransformHandleDirection,
|
transformHandleDirection: TransformHandleDirection,
|
||||||
isResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
) => {
|
) => {
|
||||||
@ -444,13 +444,13 @@ export const resizeSingleElement = (
|
|||||||
let eleNewHeight = element.height * scaleY;
|
let eleNewHeight = element.height * scaleY;
|
||||||
|
|
||||||
// adjust dimensions for resizing from center
|
// adjust dimensions for resizing from center
|
||||||
if (isResizeFromCenter) {
|
if (shouldResizeFromCenter) {
|
||||||
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
|
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
|
||||||
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
|
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// adjust dimensions to keep sides ratio
|
// adjust dimensions to keep sides ratio
|
||||||
if (shouldKeepSidesRatio) {
|
if (shouldMaintainAspectRatio) {
|
||||||
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
|
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
|
||||||
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
|
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
|
||||||
if (transformHandleDirection.length === 1) {
|
if (transformHandleDirection.length === 1) {
|
||||||
@ -495,7 +495,7 @@ export const resizeSingleElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keeps opposite handle fixed during resize
|
// Keeps opposite handle fixed during resize
|
||||||
if (shouldKeepSidesRatio) {
|
if (shouldMaintainAspectRatio) {
|
||||||
if (["s", "n"].includes(transformHandleDirection)) {
|
if (["s", "n"].includes(transformHandleDirection)) {
|
||||||
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
|
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[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
|
||||||
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
|
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
|
||||||
}
|
}
|
||||||
@ -558,6 +558,18 @@ export const resizeSingleElement = (
|
|||||||
...rescaledPoints,
|
...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 (
|
if (
|
||||||
resizedElement.width !== 0 &&
|
resizedElement.width !== 0 &&
|
||||||
resizedElement.height !== 0 &&
|
resizedElement.height !== 0 &&
|
||||||
@ -692,13 +704,13 @@ const rotateMultipleElements = (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
isRotateWithDiscreteAngle: boolean,
|
shouldRotateWithDiscreteAngle: boolean,
|
||||||
centerX: number,
|
centerX: number,
|
||||||
centerY: number,
|
centerY: number,
|
||||||
) => {
|
) => {
|
||||||
let centerAngle =
|
let centerAngle =
|
||||||
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
||||||
if (isRotateWithDiscreteAngle) {
|
if (shouldRotateWithDiscreteAngle) {
|
||||||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,8 @@ import {
|
|||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawGenericElement,
|
ExcalidrawGenericElement,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
|
InitializedExcalidrawImageElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const isGenericElement = (
|
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 = (
|
export const isTextElement = (
|
||||||
element: ExcalidrawElement | null,
|
element: ExcalidrawElement | null,
|
||||||
): element is ExcalidrawTextElement => {
|
): element is ExcalidrawTextElement => {
|
||||||
|
@ -63,6 +63,21 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
|||||||
type: "ellipse";
|
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.
|
* These are elements that don't have any additional properties.
|
||||||
*/
|
*/
|
||||||
@ -81,10 +96,11 @@ export type ExcalidrawElement =
|
|||||||
| ExcalidrawGenericElement
|
| ExcalidrawGenericElement
|
||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawLinearElement
|
| ExcalidrawLinearElement
|
||||||
| ExcalidrawFreeDrawElement;
|
| ExcalidrawFreeDrawElement
|
||||||
|
| ExcalidrawImageElement;
|
||||||
|
|
||||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||||
isDeleted: false;
|
isDeleted: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
|
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
|
||||||
@ -104,7 +120,8 @@ export type ExcalidrawBindableElement =
|
|||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawEllipseElement
|
| ExcalidrawEllipseElement
|
||||||
| ExcalidrawTextElement;
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawImageElement;
|
||||||
|
|
||||||
export type PointBinding = {
|
export type PointBinding = {
|
||||||
elementId: ExcalidrawBindableElement["id"];
|
elementId: ExcalidrawBindableElement["id"];
|
||||||
@ -133,3 +150,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
|||||||
simulatePressure: boolean;
|
simulatePressure: boolean;
|
||||||
lastCommittedPoint: Point | null;
|
lastCommittedPoint: Point | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type FileId = string & { _brand: "FileId" };
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
// time constants (ms)
|
// time constants (ms)
|
||||||
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
|
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
|
||||||
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
|
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 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 = {
|
export const BROADCAST = {
|
||||||
SERVER_VOLATILE: "server-volatile-broadcast",
|
SERVER_VOLATILE: "server-volatile-broadcast",
|
||||||
SERVER: "server-broadcast",
|
SERVER: "server-broadcast",
|
||||||
@ -12,3 +18,8 @@ export enum SCENE {
|
|||||||
INIT = "SCENE_INIT",
|
INIT = "SCENE_INIT",
|
||||||
UPDATE = "SCENE_UPDATE",
|
UPDATE = "SCENE_UPDATE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const FIREBASE_STORAGE_PREFIXES = {
|
||||||
|
shareLinkFiles: `/files/shareLinks`,
|
||||||
|
collabFiles: `/files/rooms`,
|
||||||
|
};
|
||||||
|
@ -4,15 +4,25 @@ import { ExcalidrawImperativeAPI } from "../../types";
|
|||||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||||
import { ImportedDataState } from "../../data/types";
|
import { ImportedDataState } from "../../data/types";
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
InitializedExcalidrawImageElement,
|
||||||
|
} from "../../element/types";
|
||||||
import {
|
import {
|
||||||
getElementMap,
|
getElementMap,
|
||||||
getSceneVersion,
|
getSceneVersion,
|
||||||
} from "../../packages/excalidraw/index";
|
} from "../../packages/excalidraw/index";
|
||||||
import { Collaborator, Gesture } from "../../types";
|
import { Collaborator, Gesture } from "../../types";
|
||||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
|
||||||
import {
|
import {
|
||||||
|
preventUnload,
|
||||||
|
resolvablePromise,
|
||||||
|
withBatchedUpdates,
|
||||||
|
} from "../../utils";
|
||||||
|
import {
|
||||||
|
FILE_UPLOAD_MAX_BYTES,
|
||||||
|
FIREBASE_STORAGE_PREFIXES,
|
||||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||||
|
LOAD_IMAGES_TIMEOUT,
|
||||||
SCENE,
|
SCENE,
|
||||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||||
} from "../app_constants";
|
} from "../app_constants";
|
||||||
@ -25,7 +35,9 @@ import {
|
|||||||
} from "../data";
|
} from "../data";
|
||||||
import {
|
import {
|
||||||
isSavedToFirebase,
|
isSavedToFirebase,
|
||||||
|
loadFilesFromFirebase,
|
||||||
loadFromFirebase,
|
loadFromFirebase,
|
||||||
|
saveFilesToFirebase,
|
||||||
saveToFirebase,
|
saveToFirebase,
|
||||||
} from "../data/firebase";
|
} from "../data/firebase";
|
||||||
import {
|
import {
|
||||||
@ -41,6 +53,17 @@ import { UserIdleState } from "../../types";
|
|||||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { isInvisiblySmallElement } from "../../element";
|
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 {
|
interface CollabState {
|
||||||
modalIsShown: boolean;
|
modalIsShown: boolean;
|
||||||
@ -61,6 +84,7 @@ export interface CollabAPI {
|
|||||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||||
broadcastElements: CollabInstance["broadcastElements"];
|
broadcastElements: CollabInstance["broadcastElements"];
|
||||||
|
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReconciledElements = readonly ExcalidrawElement[] & {
|
type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||||
@ -69,6 +93,7 @@ type ReconciledElements = readonly ExcalidrawElement[] & {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
|
onRoomClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -81,12 +106,13 @@ export { CollabContext, CollabContextConsumer };
|
|||||||
|
|
||||||
class CollabWrapper extends PureComponent<Props, CollabState> {
|
class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||||
portal: Portal;
|
portal: Portal;
|
||||||
|
fileManager: FileManager;
|
||||||
excalidrawAPI: Props["excalidrawAPI"];
|
excalidrawAPI: Props["excalidrawAPI"];
|
||||||
isCollaborating: boolean = false;
|
isCollaborating: boolean = false;
|
||||||
activeIntervalId: number | null;
|
activeIntervalId: number | null;
|
||||||
idleTimeoutId: number | null;
|
idleTimeoutId: number | null;
|
||||||
|
|
||||||
private socketInitializationTimer?: NodeJS.Timeout;
|
private socketInitializationTimer?: number;
|
||||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||||
private collaborators = new Map<string, Collaborator>();
|
private collaborators = new Map<string, Collaborator>();
|
||||||
|
|
||||||
@ -100,6 +126,31 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
activeRoomLink: "",
|
activeRoomLink: "",
|
||||||
};
|
};
|
||||||
this.portal = new Portal(this);
|
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.excalidrawAPI = props.excalidrawAPI;
|
||||||
this.activeIntervalId = null;
|
this.activeIntervalId = null;
|
||||||
this.idleTimeoutId = null;
|
this.idleTimeoutId = null;
|
||||||
@ -152,15 +203,14 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.isCollaborating &&
|
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
|
// 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
|
// the purpose is to run in immediately after user decides to stay
|
||||||
this.saveCollabRoomToFirebase(syncableElements);
|
this.saveCollabRoomToFirebase(syncableElements);
|
||||||
|
|
||||||
event.preventDefault();
|
preventUnload(event);
|
||||||
// NOTE: modern browsers no longer allow showing a custom message here
|
|
||||||
event.returnValue = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isCollaborating || this.portal.roomId) {
|
if (this.isCollaborating || this.portal.roomId) {
|
||||||
@ -199,6 +249,22 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||||
this.destroySocketClient();
|
this.destroySocketClient();
|
||||||
trackEvent("share", "room closed");
|
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.isCollaborating = false;
|
||||||
}
|
}
|
||||||
|
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||||
this.portal.close();
|
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 (
|
private initializeSocketClient = async (
|
||||||
@ -267,7 +352,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
// remove deleted elements from elements array & history to ensure we don't
|
||||||
// expose potentially sensitive user data in case user manually deletes
|
// expose potentially sensitive user data in case user manually deletes
|
||||||
// existing elements (or clears scene), which would otherwise be persisted
|
// existing elements (or clears scene), which would otherwise be persisted
|
||||||
@ -277,11 +371,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
elements,
|
elements,
|
||||||
commitToHistory: true,
|
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
|
// fallback in case you're not alone in the room but still don't receive
|
||||||
// initial SCENE_UPDATE message
|
// initial SCENE_UPDATE message
|
||||||
this.socketInitializationTimer = setTimeout(() => {
|
this.socketInitializationTimer = window.setTimeout(() => {
|
||||||
this.initializeSocket();
|
this.initializeSocket();
|
||||||
scenePromise.resolve(null);
|
scenePromise.resolve(null);
|
||||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
||||||
@ -446,6 +545,23 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
return newElements as ReconciledElements;
|
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 = (
|
private handleRemoteSceneUpdate = (
|
||||||
elements: ReconciledElements,
|
elements: ReconciledElements,
|
||||||
{ init = false }: { init?: boolean } = {},
|
{ 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,
|
// 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.
|
// right now we think this is the right tradeoff.
|
||||||
this.excalidrawAPI.history.clear();
|
this.excalidrawAPI.history.clear();
|
||||||
|
|
||||||
|
this.loadImageFiles();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onPointerMove = () => {
|
private onPointerMove = () => {
|
||||||
@ -622,6 +740,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
||||||
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
||||||
this.contextValue.broadcastElements = this.broadcastElements;
|
this.contextValue.broadcastElements = this.broadcastElements;
|
||||||
|
this.contextValue.fetchImageFilesFromFirebase = this.fetchImageFilesFromFirebase;
|
||||||
return this.contextValue;
|
return this.contextValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -7,9 +7,11 @@ import {
|
|||||||
import CollabWrapper from "./CollabWrapper";
|
import CollabWrapper from "./CollabWrapper";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
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 { UserIdleState } from "../../types";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
|
import { throttle } from "lodash";
|
||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
collab: CollabWrapper;
|
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 (
|
broadcastScene = async (
|
||||||
sceneType: SCENE.INIT | SCENE.UPDATE,
|
sceneType: SCENE.INIT | SCENE.UPDATE,
|
||||||
syncableElements: ExcalidrawElement[],
|
syncableElements: ExcalidrawElement[],
|
||||||
@ -126,6 +161,8 @@ class Portal {
|
|||||||
data as SocketUpdateData,
|
data as SocketUpdateData,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.queueFileUpload();
|
||||||
|
|
||||||
if (syncAll && this.collab.isCollaborating) {
|
if (syncAll && this.collab.isCollaborating) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
broadcastPromise,
|
broadcastPromise,
|
||||||
|
@ -2,69 +2,81 @@ import React from "react";
|
|||||||
import { Card } from "../../components/Card";
|
import { Card } from "../../components/Card";
|
||||||
import { ToolButton } from "../../components/ToolButton";
|
import { ToolButton } from "../../components/ToolButton";
|
||||||
import { serializeAsJSON } from "../../data/json";
|
import { serializeAsJSON } from "../../data/json";
|
||||||
import { getImportedKey, createIV, generateEncryptionKey } from "../data";
|
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||||
import { loadFirebaseStorage } from "../data/firebase";
|
import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
import { NonDeletedExcalidrawElement } from "../../element/types";
|
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
|
||||||
import { AppState } from "../../types";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { excalidrawPlusIcon } from "./icons";
|
import { excalidrawPlusIcon } from "./icons";
|
||||||
|
import { encryptData, generateEncryptionKey } from "../../data/encryption";
|
||||||
const encryptData = async (
|
import { isInitializedImageElement } from "../../element/typeChecks";
|
||||||
key: string,
|
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
|
||||||
json: string,
|
import { encodeFilesForUpload } from "../data/FileManager";
|
||||||
): Promise<{ blob: Blob; iv: Uint8Array }> => {
|
import { MIME_TYPES } from "../../constants";
|
||||||
const importedKey = await getImportedKey(key, "encrypt");
|
|
||||||
const iv = createIV();
|
|
||||||
const encoded = new TextEncoder().encode(json);
|
|
||||||
const ciphertext = await window.crypto.subtle.encrypt(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
iv,
|
|
||||||
},
|
|
||||||
importedKey,
|
|
||||||
encoded,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { blob: new Blob([new Uint8Array(ciphertext)]), iv };
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToExcalidrawPlus = async (
|
const exportToExcalidrawPlus = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
const id = `${nanoid(12)}`;
|
const id = `${nanoid(12)}`;
|
||||||
|
|
||||||
const key = (await generateEncryptionKey())!;
|
const encryptionKey = (await generateEncryptionKey())!;
|
||||||
const encryptedData = await encryptData(
|
const encryptedData = await encryptData(
|
||||||
key,
|
encryptionKey,
|
||||||
serializeAsJSON(elements, appState),
|
serializeAsJSON(elements, appState, files, "database"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const blob = new Blob([encryptedData.iv, encryptedData.blob], {
|
const blob = new Blob(
|
||||||
type: "application/octet-stream",
|
[encryptedData.iv, new Uint8Array(encryptedData.encryptedBuffer)],
|
||||||
});
|
{
|
||||||
|
type: MIME_TYPES.binary,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await firebase
|
await firebase
|
||||||
.storage()
|
.storage()
|
||||||
.ref(`/migrations/scenes/${id}`)
|
.ref(`/migrations/scenes/${id}`)
|
||||||
.put(blob, {
|
.put(blob, {
|
||||||
customMetadata: {
|
customMetadata: {
|
||||||
data: JSON.stringify({ version: 1, name: appState.name }),
|
data: JSON.stringify({ version: 2, name: appState.name }),
|
||||||
created: Date.now().toString(),
|
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<{
|
export const ExportToExcalidrawPlus: React.FC<{
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
}> = ({ elements, appState, onError }) => {
|
}> = ({ elements, appState, files, onError }) => {
|
||||||
return (
|
return (
|
||||||
<Card color="indigo">
|
<Card color="indigo">
|
||||||
<div className="Card-icon">{excalidrawPlusIcon}</div>
|
<div className="Card-icon">{excalidrawPlusIcon}</div>
|
||||||
@ -80,7 +92,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
|||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await exportToExcalidrawPlus(elements, appState);
|
await exportToExcalidrawPlus(elements, appState, files);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
onError(new Error(t("exportDialog.excalidrawplus_exportError")));
|
onError(new Error(t("exportDialog.excalidrawplus_exportError")));
|
||||||
|
249
src/excalidraw-app/data/FileManager.ts
Normal file
249
src/excalidraw-app/data/FileManager.ts
Normal 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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
@ -1,26 +1,45 @@
|
|||||||
import { getImportedKey } from "../data";
|
import { ExcalidrawElement, FileId } from "../../element/types";
|
||||||
import { createIV } from "./index";
|
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
|
||||||
import { getSceneVersion } from "../../element";
|
import { getSceneVersion } from "../../element";
|
||||||
import Portal from "../collab/Portal";
|
import Portal from "../collab/Portal";
|
||||||
import { restoreElements } from "../../data/restore";
|
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
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
||||||
|
|
||||||
let firebasePromise: Promise<
|
let firebasePromise: Promise<
|
||||||
typeof import("firebase/app").default
|
typeof import("firebase/app").default
|
||||||
> | null = null;
|
> | null = null;
|
||||||
let firestorePromise: Promise<any> | null = null;
|
let firestorePromise: Promise<any> | null | true = null;
|
||||||
let firebseStoragePromise: Promise<any> | null = null;
|
let firebaseStoragePromise: Promise<any> | null | true = null;
|
||||||
|
|
||||||
|
let isFirebaseInitialized = false;
|
||||||
|
|
||||||
const _loadFirebase = async () => {
|
const _loadFirebase = async () => {
|
||||||
const firebase = (
|
const firebase = (
|
||||||
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
||||||
).default;
|
).default;
|
||||||
|
|
||||||
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
if (!isFirebaseInitialized) {
|
||||||
firebase.initializeApp(firebaseConfig);
|
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;
|
return firebase;
|
||||||
};
|
};
|
||||||
@ -42,18 +61,24 @@ const loadFirestore = async () => {
|
|||||||
firestorePromise = import(
|
firestorePromise = import(
|
||||||
/* webpackChunkName: "firestore" */ "firebase/firestore"
|
/* webpackChunkName: "firestore" */ "firebase/firestore"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (firestorePromise !== true) {
|
||||||
await firestorePromise;
|
await firestorePromise;
|
||||||
|
firestorePromise = true;
|
||||||
}
|
}
|
||||||
return firebase;
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFirebaseStorage = async () => {
|
export const loadFirebaseStorage = async () => {
|
||||||
const firebase = await _getFirebase();
|
const firebase = await _getFirebase();
|
||||||
if (!firebseStoragePromise) {
|
if (!firebaseStoragePromise) {
|
||||||
firebseStoragePromise = import(
|
firebaseStoragePromise = import(
|
||||||
/* webpackChunkName: "storage" */ "firebase/storage"
|
/* webpackChunkName: "storage" */ "firebase/storage"
|
||||||
);
|
);
|
||||||
await firebseStoragePromise;
|
}
|
||||||
|
if (firebaseStoragePromise !== true) {
|
||||||
|
await firebaseStoragePromise;
|
||||||
|
firebaseStoragePromise = true;
|
||||||
}
|
}
|
||||||
return firebase;
|
return firebase;
|
||||||
};
|
};
|
||||||
@ -87,7 +112,7 @@ const encryptElements = async (
|
|||||||
const decryptElements = async (
|
const decryptElements = async (
|
||||||
key: string,
|
key: string,
|
||||||
iv: Uint8Array,
|
iv: Uint8Array,
|
||||||
ciphertext: ArrayBuffer,
|
ciphertext: ArrayBuffer | Uint8Array,
|
||||||
): Promise<readonly ExcalidrawElement[]> => {
|
): Promise<readonly ExcalidrawElement[]> => {
|
||||||
const importedKey = await getImportedKey(key, "decrypt");
|
const importedKey = await getImportedKey(key, "decrypt");
|
||||||
const decrypted = await window.crypto.subtle.decrypt(
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
@ -100,7 +125,7 @@ const decryptElements = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const decodedData = new TextDecoder("utf-8").decode(
|
const decodedData = new TextDecoder("utf-8").decode(
|
||||||
new Uint8Array(decrypted) as any,
|
new Uint8Array(decrypted),
|
||||||
);
|
);
|
||||||
return JSON.parse(decodedData);
|
return JSON.parse(decodedData);
|
||||||
};
|
};
|
||||||
@ -113,6 +138,7 @@ export const isSavedToFirebase = (
|
|||||||
): boolean => {
|
): boolean => {
|
||||||
if (portal.socket && portal.roomId && portal.roomKey) {
|
if (portal.socket && portal.roomId && portal.roomKey) {
|
||||||
const sceneVersion = getSceneVersion(elements);
|
const sceneVersion = getSceneVersion(elements);
|
||||||
|
|
||||||
return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
|
return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
|
||||||
}
|
}
|
||||||
// if no room exists, consider the room saved so that we don't unnecessarily
|
// if no room exists, consider the room saved so that we don't unnecessarily
|
||||||
@ -120,6 +146,42 @@ export const isSavedToFirebase = (
|
|||||||
return true;
|
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 (
|
export const saveToFirebase = async (
|
||||||
portal: Portal,
|
portal: Portal,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -198,3 +260,47 @@ export const loadFromFirebase = async (
|
|||||||
|
|
||||||
return restoreElements(elements, null);
|
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 };
|
||||||
|
};
|
||||||
|
@ -1,9 +1,24 @@
|
|||||||
|
import {
|
||||||
|
createIV,
|
||||||
|
generateEncryptionKey,
|
||||||
|
getImportedKey,
|
||||||
|
IV_LENGTH_BYTES,
|
||||||
|
} from "../../data/encryption";
|
||||||
import { serializeAsJSON } from "../../data/json";
|
import { serializeAsJSON } from "../../data/json";
|
||||||
import { restore } from "../../data/restore";
|
import { restore } from "../../data/restore";
|
||||||
import { ImportedDataState } from "../../data/types";
|
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 { 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);
|
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
||||||
|
|
||||||
@ -17,18 +32,6 @@ const generateRandomID = async () => {
|
|||||||
return Array.from(arr, byteToHex).join("");
|
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 const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
|
||||||
|
|
||||||
export type EncryptedData = {
|
export type EncryptedData = {
|
||||||
@ -79,13 +82,6 @@ export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSour
|
|||||||
_brand: "socketUpdateData";
|
_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 (
|
export const encryptAESGEM = async (
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
key: string,
|
key: string,
|
||||||
@ -122,7 +118,7 @@ export const decryptAESGEM = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const decodedData = new TextDecoder("utf-8").decode(
|
const decodedData = new TextDecoder("utf-8").decode(
|
||||||
new Uint8Array(decrypted) as any,
|
new Uint8Array(decrypted),
|
||||||
);
|
);
|
||||||
return JSON.parse(decodedData);
|
return JSON.parse(decodedData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -162,26 +158,8 @@ export const getCollaborationLink = (data: {
|
|||||||
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
|
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 (
|
export const decryptImported = async (
|
||||||
iv: ArrayBuffer,
|
iv: ArrayBuffer | Uint8Array,
|
||||||
encrypted: ArrayBuffer,
|
encrypted: ArrayBuffer,
|
||||||
privateKey: string,
|
privateKey: string,
|
||||||
): Promise<ArrayBuffer> => {
|
): Promise<ArrayBuffer> => {
|
||||||
@ -227,7 +205,7 @@ const importFromBackend = async (
|
|||||||
|
|
||||||
// We need to convert the decrypted array buffer to a string
|
// We need to convert the decrypted array buffer to a string
|
||||||
const string = new window.TextDecoder("utf-8").decode(
|
const string = new window.TextDecoder("utf-8").decode(
|
||||||
new Uint8Array(decrypted) as any,
|
new Uint8Array(decrypted),
|
||||||
);
|
);
|
||||||
data = JSON.parse(string);
|
data = JSON.parse(string);
|
||||||
} else {
|
} else {
|
||||||
@ -270,6 +248,10 @@ export const loadScene = async (
|
|||||||
return {
|
return {
|
||||||
elements: data.elements,
|
elements: data.elements,
|
||||||
appState: data.appState,
|
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,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -277,11 +259,12 @@ export const loadScene = async (
|
|||||||
export const exportToBackend = async (
|
export const exportToBackend = async (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
const json = serializeAsJSON(elements, appState);
|
const json = serializeAsJSON(elements, appState, files, "database");
|
||||||
const encoded = new TextEncoder().encode(json);
|
const encoded = new TextEncoder().encode(json);
|
||||||
|
|
||||||
const key = await window.crypto.subtle.generateKey(
|
const cryptoKey = await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
length: 128,
|
length: 128,
|
||||||
@ -298,7 +281,7 @@ export const exportToBackend = async (
|
|||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
iv,
|
iv,
|
||||||
},
|
},
|
||||||
key,
|
cryptoKey,
|
||||||
encoded,
|
encoded,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -308,9 +291,24 @@ export const exportToBackend = async (
|
|||||||
|
|
||||||
// We use jwk encoding to be able to extract just the base64 encoded key.
|
// 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.
|
// 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 {
|
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, {
|
const response = await fetch(BACKEND_V2_POST, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: payload,
|
body: payload,
|
||||||
@ -320,8 +318,14 @@ export const exportToBackend = async (
|
|||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
// We need to store the key (and less importantly the id) as hash instead
|
// 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
|
// 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();
|
const urlString = url.toString();
|
||||||
|
|
||||||
|
await saveFilesToFirebase({
|
||||||
|
prefix: `/files/shareLinks/${json.id}`,
|
||||||
|
files: filesToUpload,
|
||||||
|
});
|
||||||
|
|
||||||
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
|
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
|
||||||
} else if (json.error_class === "RequestTooLargeError") {
|
} else if (json.error_class === "RequestTooLargeError") {
|
||||||
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
|
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
|
||||||
|
@ -16,6 +16,7 @@ import { loadFromBlob } from "../data/blob";
|
|||||||
import { ImportedDataState } from "../data/types";
|
import { ImportedDataState } from "../data/types";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
FileId,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
@ -24,14 +25,24 @@ import Excalidraw, {
|
|||||||
defaultLang,
|
defaultLang,
|
||||||
languages,
|
languages,
|
||||||
} from "../packages/excalidraw/index";
|
} from "../packages/excalidraw/index";
|
||||||
import { AppState, LibraryItems, ExcalidrawImperativeAPI } from "../types";
|
import {
|
||||||
|
AppState,
|
||||||
|
LibraryItems,
|
||||||
|
ExcalidrawImperativeAPI,
|
||||||
|
BinaryFileData,
|
||||||
|
BinaryFiles,
|
||||||
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
|
preventUnload,
|
||||||
ResolvablePromise,
|
ResolvablePromise,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
} from "../utils";
|
} 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, {
|
import CollabWrapper, {
|
||||||
CollabAPI,
|
CollabAPI,
|
||||||
CollabContext,
|
CollabContext,
|
||||||
@ -51,6 +62,64 @@ import { shield } from "../components/icons";
|
|||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
|
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();
|
const languageDetector = new LanguageDetector();
|
||||||
languageDetector.init({
|
languageDetector.init({
|
||||||
languageUtils: {
|
languageUtils: {
|
||||||
@ -61,8 +130,20 @@ languageDetector.init({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saveDebounced = debounce(
|
const saveDebounced = debounce(
|
||||||
(elements: readonly ExcalidrawElement[], state: AppState) => {
|
async (
|
||||||
saveToLocalStorage(elements, state);
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
|
onFilesSaved: () => void,
|
||||||
|
) => {
|
||||||
|
saveToLocalStorage(elements, appState);
|
||||||
|
|
||||||
|
await localFileStorage.saveFiles({
|
||||||
|
elements,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
|
||||||
|
onFilesSaved();
|
||||||
},
|
},
|
||||||
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
||||||
);
|
);
|
||||||
@ -73,7 +154,12 @@ const onBlur = () => {
|
|||||||
|
|
||||||
const initializeScene = async (opts: {
|
const initializeScene = async (opts: {
|
||||||
collabAPI: CollabAPI;
|
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 searchParams = new URLSearchParams(window.location.search);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
const jsonBackendMatch = window.location.hash.match(
|
const jsonBackendMatch = window.location.hash.match(
|
||||||
@ -140,23 +226,38 @@ const initializeScene = async (opts: {
|
|||||||
!scene.elements.length ||
|
!scene.elements.length ||
|
||||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||||
) {
|
) {
|
||||||
return data;
|
return { scene: data, isExternalScene };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
appState: {
|
scene: {
|
||||||
errorMessage: t("alerts.invalidSceneUrl"),
|
appState: {
|
||||||
|
errorMessage: t("alerts.invalidSceneUrl"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
isExternalScene,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roomLinkData) {
|
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) {
|
} 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 = (
|
const PlusLinkJSX = (
|
||||||
@ -207,20 +308,84 @@ const ExcalidrawWrapper = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeScene({ collabAPI }).then((scene) => {
|
const loadImages = (
|
||||||
if (scene) {
|
data: ResolutionType<typeof initializeScene>,
|
||||||
try {
|
isInitialLoad = false,
|
||||||
scene.libraryItems =
|
) => {
|
||||||
JSON.parse(
|
if (!data.scene) {
|
||||||
localStorage.getItem(
|
return;
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
|
}
|
||||||
) as string,
|
if (collabAPI.isCollaborating()) {
|
||||||
) || [];
|
if (data.scene.elements) {
|
||||||
} catch (e) {
|
collabAPI
|
||||||
console.error(e);
|
.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) => {
|
const onHashChange = (event: HashChangeEvent) => {
|
||||||
@ -235,11 +400,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
window.history.replaceState({}, "", event.oldURL);
|
window.history.replaceState({}, "", event.oldURL);
|
||||||
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
|
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
|
||||||
} else {
|
} else {
|
||||||
initializeScene({ collabAPI }).then((scene) => {
|
initializeScene({ collabAPI }).then((data) => {
|
||||||
if (scene) {
|
loadImages(data);
|
||||||
|
if (data.scene) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...scene,
|
...data.scene,
|
||||||
appState: restoreAppState(scene.appState, null),
|
appState: restoreAppState(data.scene.appState, null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -261,6 +427,23 @@ const ExcalidrawWrapper = () => {
|
|||||||
};
|
};
|
||||||
}, [collabAPI, excalidrawAPI]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
languageDetector.cacheUserLanguage(langCode);
|
languageDetector.cacheUserLanguage(langCode);
|
||||||
}, [langCode]);
|
}, [langCode]);
|
||||||
@ -268,20 +451,43 @@ const ExcalidrawWrapper = () => {
|
|||||||
const onChange = (
|
const onChange = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
if (collabAPI?.isCollaborating()) {
|
if (collabAPI?.isCollaborating()) {
|
||||||
collabAPI.broadcastElements(elements);
|
collabAPI.broadcastElements(elements);
|
||||||
} else {
|
} else {
|
||||||
// collab scenes are persisted to the server, so we don't have to persist
|
saveDebounced(elements, appState, files, () => {
|
||||||
// them locally, which has the added benefit of not overwriting whatever
|
if (excalidrawAPI) {
|
||||||
// the user was working on before joining
|
let didChange = false;
|
||||||
saveDebounced(elements, appState);
|
|
||||||
|
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 (
|
const onExportToBackend = async (
|
||||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
canvas: HTMLCanvasElement | null,
|
canvas: HTMLCanvasElement | null,
|
||||||
) => {
|
) => {
|
||||||
if (exportedElements.length === 0) {
|
if (exportedElements.length === 0) {
|
||||||
@ -289,12 +495,16 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
try {
|
try {
|
||||||
await exportToBackend(exportedElements, {
|
await exportToBackend(
|
||||||
...appState,
|
exportedElements,
|
||||||
viewBackgroundColor: appState.exportBackground
|
{
|
||||||
? appState.viewBackgroundColor
|
...appState,
|
||||||
: getDefaultAppState().viewBackgroundColor,
|
viewBackgroundColor: appState.exportBackground
|
||||||
});
|
? appState.viewBackgroundColor
|
||||||
|
: getDefaultAppState().viewBackgroundColor,
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name !== "AbortError") {
|
if (error.name !== "AbortError") {
|
||||||
const { width, height } = canvas;
|
const { width, height } = canvas;
|
||||||
@ -409,6 +619,10 @@ const ExcalidrawWrapper = () => {
|
|||||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onRoomClose = useCallback(() => {
|
||||||
|
localFileStorage.reset();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
@ -422,11 +636,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
canvasActions: {
|
canvasActions: {
|
||||||
export: {
|
export: {
|
||||||
onExportToBackend,
|
onExportToBackend,
|
||||||
renderCustomUI: (elements, appState) => {
|
renderCustomUI: (elements, appState, files) => {
|
||||||
return (
|
return (
|
||||||
<ExportToExcalidrawPlus
|
<ExportToExcalidrawPlus
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
onError={(error) => {
|
onError={(error) => {
|
||||||
excalidrawAPI?.updateScene({
|
excalidrawAPI?.updateScene({
|
||||||
appState: {
|
appState: {
|
||||||
@ -449,7 +664,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
onLibraryChange={onLibraryChange}
|
onLibraryChange={onLibraryChange}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
/>
|
/>
|
||||||
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
|
{excalidrawAPI && (
|
||||||
|
<CollabWrapper
|
||||||
|
excalidrawAPI={excalidrawAPI}
|
||||||
|
onRoomClose={onRoomClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
|
39
src/global.d.ts
vendored
39
src/global.d.ts
vendored
@ -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> &
|
type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
|
||||||
Required<Pick<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
|
// PNG encoding/decoding
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
|
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
|
||||||
@ -91,3 +96,37 @@ interface Blob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare module "*.scss";
|
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;
|
||||||
|
}
|
||||||
|
@ -66,6 +66,7 @@ const canvas = exportToCanvas(
|
|||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
},
|
},
|
||||||
|
{}, // files
|
||||||
{
|
{
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
|
10
src/keys.ts
10
src/keys.ts
@ -45,6 +45,7 @@ export const KEYS = {
|
|||||||
D: "d",
|
D: "d",
|
||||||
E: "e",
|
E: "e",
|
||||||
G: "g",
|
G: "g",
|
||||||
|
I: "i",
|
||||||
L: "l",
|
L: "l",
|
||||||
O: "o",
|
O: "o",
|
||||||
P: "p",
|
P: "p",
|
||||||
@ -66,13 +67,12 @@ export const isArrowKey = (key: string) =>
|
|||||||
key === KEYS.ARROW_DOWN ||
|
key === KEYS.ARROW_DOWN ||
|
||||||
key === KEYS.ARROW_UP;
|
key === KEYS.ARROW_UP;
|
||||||
|
|
||||||
export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
|
export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) =>
|
||||||
event.altKey;
|
event.altKey;
|
||||||
|
|
||||||
export const getResizeWithSidesSameLengthKey = (
|
export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
|
||||||
event: MouseEvent | KeyboardEvent,
|
event.shiftKey;
|
||||||
) => event.shiftKey;
|
|
||||||
|
|
||||||
export const getRotateWithDiscreteAngleKey = (
|
export const shouldRotateWithDiscreteAngle = (
|
||||||
event: MouseEvent | KeyboardEvent,
|
event: MouseEvent | KeyboardEvent,
|
||||||
) => event.shiftKey;
|
) => event.shiftKey;
|
||||||
|
@ -156,14 +156,22 @@
|
|||||||
"errorAddingToLibrary": "Couldn't add item to the library",
|
"errorAddingToLibrary": "Couldn't add item to the library",
|
||||||
"errorRemovingFromLibrary": "Couldn't remove item from the library",
|
"errorRemovingFromLibrary": "Couldn't remove item from the library",
|
||||||
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
|
"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",
|
"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.",
|
"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?",
|
"resetLibrary": "This will clear your library. Are you sure?",
|
||||||
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
|
"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": {
|
"toolBar": {
|
||||||
"selection": "Selection",
|
"selection": "Selection",
|
||||||
|
"image": "Insert image",
|
||||||
"rectangle": "Rectangle",
|
"rectangle": "Rectangle",
|
||||||
"diamond": "Diamond",
|
"diamond": "Diamond",
|
||||||
"ellipse": "Ellipse",
|
"ellipse": "Ellipse",
|
||||||
@ -188,10 +196,12 @@
|
|||||||
"linearElementMulti": "Click on last point or press Escape or Enter to finish",
|
"linearElementMulti": "Click on last point or press Escape or Enter to finish",
|
||||||
"lockAngle": "You can constrain angle by holding SHIFT",
|
"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",
|
"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",
|
"rotate": "You can constrain angles by holding SHIFT while rotating",
|
||||||
"lineEditor_info": "Double-click or press Enter to edit points",
|
"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_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": {
|
"canvasError": {
|
||||||
"cannotShowPreview": "Cannot show preview",
|
"cannotShowPreview": "Cannot show preview",
|
||||||
|
@ -13,6 +13,30 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
## Unreleased
|
## 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#onLibraryChange):
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Excalidraw API
|
## Excalidraw API
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@ -380,6 +404,7 @@ Please add the latest change on the top under the correct section.
|
|||||||
- #### BREAKING CHANGE
|
- #### 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.
|
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).
|
- 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
|
### Build
|
||||||
|
|
||||||
|
@ -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. |
|
| [`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 | Promise<any> </pre> | | The callback if supplied is triggered when the library is updated and receives the library items. |
|
| [`onLibraryChange`](#onLibraryChange) | <pre>(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void | Promise<any> </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 |
|
| [`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
|
### Dimensions of Excalidraw
|
||||||
|
|
||||||
@ -448,7 +449,8 @@ 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 |
|
| 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) |
|
| 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 |
|
| [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. |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@ -471,7 +473,7 @@ Since plain object is passed as a `ref`, the `readyPromise` is resolved as soon
|
|||||||
### `updateScene`
|
### `updateScene`
|
||||||
|
|
||||||
<pre>
|
<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>
|
</pre>
|
||||||
|
|
||||||
You can use this function to update the scene with the sceneData. It accepts the below attributes.
|
You can use this function to update the scene with the sceneData. It accepts the below attributes.
|
||||||
@ -483,6 +485,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. |
|
| `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`. |
|
| `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`
|
#### `onCollabButtonClick`
|
||||||
|
|
||||||
This callback is triggered when clicked on the collab button in excalidraw. If not supplied, the collab dialog button is not rendered.
|
This callback is triggered when clicked on the collab button in excalidraw. If not supplied, the collab dialog button is not rendered.
|
||||||
@ -662,6 +670,14 @@ The unique id of the excalidraw component. This can be used to identify the exca
|
|||||||
|
|
||||||
This prop implies whether to focus the Excalidraw component on page load. Defaults to false.
|
This prop implies whether to focus the Excalidraw component on page load. Defaults to false.
|
||||||
|
|
||||||
|
#### `generateIdForFile`
|
||||||
|
|
||||||
|
Allows you to override `id` generation for files added on canvas (images). By default, an SHA-1 digest of the file is used.
|
||||||
|
|
||||||
|
```
|
||||||
|
(file: File) => string | Promise<string>
|
||||||
|
```
|
||||||
|
|
||||||
### Does it support collaboration ?
|
### 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).
|
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).
|
||||||
|
@ -34,6 +34,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
|||||||
handleKeyboardGlobally = false,
|
handleKeyboardGlobally = false,
|
||||||
onLibraryChange,
|
onLibraryChange,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
|
generateIdForFile,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const canvasActions = props.UIOptions?.canvasActions;
|
const canvasActions = props.UIOptions?.canvasActions;
|
||||||
@ -94,6 +95,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
|||||||
handleKeyboardGlobally={handleKeyboardGlobally}
|
handleKeyboardGlobally={handleKeyboardGlobally}
|
||||||
onLibraryChange={onLibraryChange}
|
onLibraryChange={onLibraryChange}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
|
generateIdForFile={generateIdForFile}
|
||||||
/>
|
/>
|
||||||
</InitializeApp>
|
</InitializeApp>
|
||||||
);
|
);
|
||||||
@ -187,3 +189,9 @@ export {
|
|||||||
export { isLinearElement } from "../../element/typeChecks";
|
export { isLinearElement } from "../../element/typeChecks";
|
||||||
|
|
||||||
export { FONT_FAMILY, THEME } from "../../constants";
|
export { FONT_FAMILY, THEME } from "../../constants";
|
||||||
|
|
||||||
|
export {
|
||||||
|
mutateElement,
|
||||||
|
newElementWith,
|
||||||
|
bumpVersion,
|
||||||
|
} from "../../element/mutateElement";
|
||||||
|
@ -3,14 +3,16 @@ import {
|
|||||||
exportToSvg as _exportToSvg,
|
exportToSvg as _exportToSvg,
|
||||||
} from "../scene/export";
|
} from "../scene/export";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { AppState } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { restore } from "../data/restore";
|
import { restore } from "../data/restore";
|
||||||
|
import { MIME_TYPES } from "../constants";
|
||||||
|
|
||||||
type ExportOpts = {
|
type ExportOpts = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
||||||
|
files: BinaryFiles | null;
|
||||||
getDimensions?: (
|
getDimensions?: (
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
@ -20,6 +22,7 @@ type ExportOpts = {
|
|||||||
export const exportToCanvas = ({
|
export const exportToCanvas = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
getDimensions = (width, height) => ({ width, height, scale: 1 }),
|
getDimensions = (width, height) => ({ width, height, scale: 1 }),
|
||||||
}: ExportOpts) => {
|
}: ExportOpts) => {
|
||||||
const { elements: restoredElements, appState: restoredAppState } = restore(
|
const { elements: restoredElements, appState: restoredAppState } = restore(
|
||||||
@ -31,6 +34,7 @@ export const exportToCanvas = ({
|
|||||||
return _exportToCanvas(
|
return _exportToCanvas(
|
||||||
getNonDeletedElements(restoredElements),
|
getNonDeletedElements(restoredElements),
|
||||||
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
|
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
|
||||||
|
files || {},
|
||||||
{ exportBackground, viewBackgroundColor },
|
{ exportBackground, viewBackgroundColor },
|
||||||
(width: number, height: number) => {
|
(width: number, height: number) => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
@ -44,22 +48,23 @@ export const exportToCanvas = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const exportToBlob = (
|
export const exportToBlob = async (
|
||||||
opts: ExportOpts & {
|
opts: ExportOpts & {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
quality?: number;
|
quality?: number;
|
||||||
},
|
},
|
||||||
): Promise<Blob | null> => {
|
): 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") {
|
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
|
||||||
console.warn(`"quality" will be ignored for "image/png" mimeType`);
|
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// typo in MIME type (should be "jpeg")
|
||||||
if (mimeType === "image/jpg") {
|
if (mimeType === "image/jpg") {
|
||||||
mimeType = "image/jpeg";
|
mimeType = MIME_TYPES.jpg;
|
||||||
}
|
}
|
||||||
|
|
||||||
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
|
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
|
||||||
@ -78,6 +83,7 @@ export const exportToBlob = (
|
|||||||
export const exportToSvg = async ({
|
export const exportToSvg = async ({
|
||||||
elements,
|
elements,
|
||||||
appState = getDefaultAppState(),
|
appState = getDefaultAppState(),
|
||||||
|
files = {},
|
||||||
exportPadding,
|
exportPadding,
|
||||||
}: Omit<ExportOpts, "getDimensions"> & {
|
}: Omit<ExportOpts, "getDimensions"> & {
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
@ -87,10 +93,14 @@ export const exportToSvg = async ({
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
return _exportToSvg(getNonDeletedElements(restoredElements), {
|
return _exportToSvg(
|
||||||
...restoredAppState,
|
getNonDeletedElements(restoredElements),
|
||||||
exportPadding,
|
{
|
||||||
});
|
...restoredAppState,
|
||||||
|
exportPadding,
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { serializeAsJSON } from "../data/json";
|
export { serializeAsJSON } from "../data/json";
|
||||||
|
@ -5,11 +5,13 @@ import {
|
|||||||
Arrowhead,
|
Arrowhead,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import {
|
import {
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
|
isInitializedImageElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
getDiamondPoints,
|
getDiamondPoints,
|
||||||
@ -21,22 +23,23 @@ import { Drawable, Options } from "roughjs/bin/core";
|
|||||||
import { RoughSVG } from "roughjs/bin/svg";
|
import { RoughSVG } from "roughjs/bin/svg";
|
||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
import { SceneState } from "../scene/types";
|
import { SceneState } from "../scene/types";
|
||||||
import {
|
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
|
||||||
SVG_NS,
|
|
||||||
distance,
|
|
||||||
getFontString,
|
|
||||||
getFontFamilyString,
|
|
||||||
isRTL,
|
|
||||||
} from "../utils";
|
|
||||||
import { isPathALoop } from "../math";
|
import { isPathALoop } from "../math";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { Zoom } from "../types";
|
import { AppState, BinaryFiles, Zoom } from "../types";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS } from "../constants";
|
||||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||||
import { MAX_DECIMALS_FOR_SVG_EXPORT } from "../constants";
|
|
||||||
|
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
|
|
||||||
|
const isPendingImageElement = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
sceneState: SceneState,
|
||||||
|
) =>
|
||||||
|
isInitializedImageElement(element) &&
|
||||||
|
!sceneState.imageCache.has(element.fileId);
|
||||||
|
|
||||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||||
|
|
||||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||||
@ -47,6 +50,7 @@ const getCanvasPadding = (element: ExcalidrawElement) =>
|
|||||||
export interface ExcalidrawElementWithCanvas {
|
export interface ExcalidrawElementWithCanvas {
|
||||||
element: ExcalidrawElement | ExcalidrawTextElement;
|
element: ExcalidrawElement | ExcalidrawTextElement;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
|
theme: SceneState["theme"];
|
||||||
canvasZoom: Zoom["value"];
|
canvasZoom: Zoom["value"];
|
||||||
canvasOffsetX: number;
|
canvasOffsetX: number;
|
||||||
canvasOffsetY: number;
|
canvasOffsetY: number;
|
||||||
@ -55,6 +59,7 @@ export interface ExcalidrawElementWithCanvas {
|
|||||||
const generateElementCanvas = (
|
const generateElementCanvas = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
|
sceneState: SceneState,
|
||||||
): ExcalidrawElementWithCanvas => {
|
): ExcalidrawElementWithCanvas => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
@ -111,21 +116,73 @@ const generateElementCanvas = (
|
|||||||
|
|
||||||
const rc = rough.canvas(canvas);
|
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();
|
context.restore();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
element,
|
element,
|
||||||
canvas,
|
canvas,
|
||||||
|
theme: sceneState.theme,
|
||||||
canvasZoom: zoom.value,
|
canvasZoom: zoom.value,
|
||||||
canvasOffsetX,
|
canvasOffsetX,
|
||||||
canvasOffsetY,
|
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 = (
|
const drawElementOnCanvas = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
rc: RoughCanvas,
|
rc: RoughCanvas,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
|
sceneState: SceneState,
|
||||||
) => {
|
) => {
|
||||||
context.globalAlpha = element.opacity / 100;
|
context.globalAlpha = element.opacity / 100;
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
@ -160,6 +217,23 @@ const drawElementOnCanvas = (
|
|||||||
context.restore();
|
context.restore();
|
||||||
break;
|
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: {
|
default: {
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
const rtl = isRTL(element.text);
|
const rtl = isRTL(element.text);
|
||||||
@ -254,6 +328,7 @@ export const generateRoughOptions = (
|
|||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
|
case "image":
|
||||||
case "ellipse": {
|
case "ellipse": {
|
||||||
options.fillStyle = element.fillStyle;
|
options.fillStyle = element.fillStyle;
|
||||||
options.fill =
|
options.fill =
|
||||||
@ -459,7 +534,8 @@ const generateElementShape = (
|
|||||||
shape = [];
|
shape = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "text": {
|
case "text":
|
||||||
|
case "image": {
|
||||||
// just to ensure we don't regenerate element.canvas on rerenders
|
// just to ensure we don't regenerate element.canvas on rerenders
|
||||||
shape = [];
|
shape = [];
|
||||||
break;
|
break;
|
||||||
@ -471,7 +547,7 @@ const generateElementShape = (
|
|||||||
|
|
||||||
const generateElementWithCanvas = (
|
const generateElementWithCanvas = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
sceneState?: SceneState,
|
sceneState: SceneState,
|
||||||
) => {
|
) => {
|
||||||
const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
|
const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
|
||||||
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
||||||
@ -479,8 +555,13 @@ const generateElementWithCanvas = (
|
|||||||
prevElementWithCanvas &&
|
prevElementWithCanvas &&
|
||||||
prevElementWithCanvas.canvasZoom !== zoom.value &&
|
prevElementWithCanvas.canvasZoom !== zoom.value &&
|
||||||
!sceneState?.shouldCacheIgnoreZoom;
|
!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);
|
elementWithCanvasCache.set(element, elementWithCanvas);
|
||||||
|
|
||||||
@ -509,10 +590,25 @@ const drawElementFromCanvas = (
|
|||||||
|
|
||||||
const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
|
const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
|
||||||
const cy = ((y1 + y2) / 2 + sceneState.scrollY) * 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.save();
|
||||||
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
|
context.scale(
|
||||||
context.translate(cx, cy);
|
(1 / window.devicePixelRatio) * scaleXFactor,
|
||||||
context.rotate(element.angle);
|
(1 / window.devicePixelRatio) * scaleYFactor,
|
||||||
|
);
|
||||||
|
context.translate(cx * scaleXFactor, cy * scaleYFactor);
|
||||||
|
context.rotate(element.angle * scaleXFactor * scaleYFactor);
|
||||||
|
|
||||||
context.drawImage(
|
context.drawImage(
|
||||||
elementWithCanvas.canvas!,
|
elementWithCanvas.canvas!,
|
||||||
@ -567,7 +663,7 @@ export const renderElement = (
|
|||||||
context.translate(cx, cy);
|
context.translate(cx, cy);
|
||||||
context.rotate(element.angle);
|
context.rotate(element.angle);
|
||||||
context.translate(-shiftX, -shiftY);
|
context.translate(-shiftX, -shiftY);
|
||||||
drawElementOnCanvas(element, rc, context);
|
drawElementOnCanvas(element, rc, context, sceneState);
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -578,6 +674,7 @@ export const renderElement = (
|
|||||||
case "ellipse":
|
case "ellipse":
|
||||||
case "line":
|
case "line":
|
||||||
case "arrow":
|
case "arrow":
|
||||||
|
case "image":
|
||||||
case "text": {
|
case "text": {
|
||||||
generateElementShape(element, generator);
|
generateElementShape(element, generator);
|
||||||
if (renderOptimizations) {
|
if (renderOptimizations) {
|
||||||
@ -596,7 +693,7 @@ export const renderElement = (
|
|||||||
context.translate(cx, cy);
|
context.translate(cx, cy);
|
||||||
context.rotate(element.angle);
|
context.rotate(element.angle);
|
||||||
context.translate(-shiftX, -shiftY);
|
context.translate(-shiftX, -shiftY);
|
||||||
drawElementOnCanvas(element, rc, context);
|
drawElementOnCanvas(element, rc, context, sceneState);
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -628,6 +725,7 @@ export const renderElementToSvg = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
rsvg: RoughSVG,
|
rsvg: RoughSVG,
|
||||||
svgRoot: SVGElement,
|
svgRoot: SVGElement,
|
||||||
|
files: BinaryFiles,
|
||||||
offsetX?: number,
|
offsetX?: number,
|
||||||
offsetY?: number,
|
offsetY?: number,
|
||||||
) => {
|
) => {
|
||||||
@ -723,6 +821,44 @@ export const renderElementToSvg = (
|
|||||||
svgRoot.appendChild(node);
|
svgRoot.appendChild(node);
|
||||||
break;
|
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: {
|
default: {
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
const opacity = element.opacity / 100;
|
const opacity = element.opacity / 100;
|
||||||
|
@ -2,7 +2,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
|
|||||||
import { RoughSVG } from "roughjs/bin/svg";
|
import { RoughSVG } from "roughjs/bin/svg";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
|
|
||||||
import { AppState, Zoom } from "../types";
|
import { AppState, BinaryFiles, Zoom } from "../types";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
@ -181,7 +181,7 @@ export const renderScene = (
|
|||||||
rc: RoughCanvas,
|
rc: RoughCanvas,
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
sceneState: SceneState,
|
sceneState: SceneState,
|
||||||
// extra options, currently passed by export helper
|
// extra options passed to the renderer
|
||||||
{
|
{
|
||||||
renderScrollbars = true,
|
renderScrollbars = true,
|
||||||
renderSelection = true,
|
renderSelection = true,
|
||||||
@ -190,11 +190,15 @@ export const renderScene = (
|
|||||||
// doesn't guarantee pixel-perfect output.
|
// doesn't guarantee pixel-perfect output.
|
||||||
renderOptimizations = false,
|
renderOptimizations = false,
|
||||||
renderGrid = true,
|
renderGrid = true,
|
||||||
|
/** when exporting the behavior is slightly different (e.g. we can't use
|
||||||
|
CSS filters) */
|
||||||
|
isExport = false,
|
||||||
}: {
|
}: {
|
||||||
renderScrollbars?: boolean;
|
renderScrollbars?: boolean;
|
||||||
renderSelection?: boolean;
|
renderSelection?: boolean;
|
||||||
renderOptimizations?: boolean;
|
renderOptimizations?: boolean;
|
||||||
renderGrid?: boolean;
|
renderGrid?: boolean;
|
||||||
|
isExport?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
if (canvas === null) {
|
if (canvas === null) {
|
||||||
@ -211,7 +215,7 @@ export const renderScene = (
|
|||||||
const normalizedCanvasWidth = canvas.width / scale;
|
const normalizedCanvasWidth = canvas.width / scale;
|
||||||
const normalizedCanvasHeight = canvas.height / scale;
|
const normalizedCanvasHeight = canvas.height / scale;
|
||||||
|
|
||||||
if (sceneState.exportWithDarkMode) {
|
if (isExport && sceneState.theme === "dark") {
|
||||||
context.filter = THEME_FILTER;
|
context.filter = THEME_FILTER;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -805,6 +809,7 @@ export const renderSceneToSvg = (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
rsvg: RoughSVG,
|
rsvg: RoughSVG,
|
||||||
svgRoot: SVGElement,
|
svgRoot: SVGElement,
|
||||||
|
files: BinaryFiles,
|
||||||
{
|
{
|
||||||
offsetX = 0,
|
offsetX = 0,
|
||||||
offsetY = 0,
|
offsetY = 0,
|
||||||
@ -824,6 +829,7 @@ export const renderSceneToSvg = (
|
|||||||
element,
|
element,
|
||||||
rsvg,
|
rsvg,
|
||||||
svgRoot,
|
svgRoot,
|
||||||
|
files,
|
||||||
element.x + offsetX,
|
element.x + offsetX,
|
||||||
element.y + offsetY,
|
element.y + offsetY,
|
||||||
);
|
);
|
||||||
|
@ -11,6 +11,8 @@ export const hasBackground = (type: string) =>
|
|||||||
type === "diamond" ||
|
type === "diamond" ||
|
||||||
type === "line";
|
type === "line";
|
||||||
|
|
||||||
|
export const hasStrokeColor = (type: string) => type !== "image";
|
||||||
|
|
||||||
export const hasStrokeWidth = (type: string) =>
|
export const hasStrokeWidth = (type: string) =>
|
||||||
type === "rectangle" ||
|
type === "rectangle" ||
|
||||||
type === "ellipse" ||
|
type === "ellipse" ||
|
||||||
|
@ -2,17 +2,22 @@ import rough from "roughjs/bin/rough";
|
|||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { getCommonBounds } from "../element/bounds";
|
import { getCommonBounds } from "../element/bounds";
|
||||||
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
||||||
import { distance, SVG_NS } from "../utils";
|
import { distance } from "../utils";
|
||||||
import { AppState } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { DEFAULT_EXPORT_PADDING, THEME_FILTER } from "../constants";
|
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { serializeAsJSON } from "../data/json";
|
import { serializeAsJSON } from "../data/json";
|
||||||
|
import {
|
||||||
|
getInitializedImageElements,
|
||||||
|
updateImageCache,
|
||||||
|
} from "../element/image";
|
||||||
|
|
||||||
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||||
|
|
||||||
export const exportToCanvas = (
|
export const exportToCanvas = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
{
|
{
|
||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
@ -36,6 +41,16 @@ export const exportToCanvas = (
|
|||||||
|
|
||||||
const { canvas, scale = 1 } = createCanvas(width, height);
|
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(
|
renderScene(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
@ -45,21 +60,23 @@ export const exportToCanvas = (
|
|||||||
canvas,
|
canvas,
|
||||||
{
|
{
|
||||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||||
exportWithDarkMode: appState.exportWithDarkMode,
|
|
||||||
scrollX: -minX + exportPadding,
|
scrollX: -minX + exportPadding,
|
||||||
scrollY: -minY + exportPadding,
|
scrollY: -minY + exportPadding,
|
||||||
zoom: getDefaultAppState().zoom,
|
zoom: defaultAppState.zoom,
|
||||||
remotePointerViewportCoords: {},
|
remotePointerViewportCoords: {},
|
||||||
remoteSelectedElementIds: {},
|
remoteSelectedElementIds: {},
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
remotePointerUsernames: {},
|
remotePointerUsernames: {},
|
||||||
remotePointerUserStates: {},
|
remotePointerUserStates: {},
|
||||||
|
theme: appState.exportWithDarkMode ? "dark" : "light",
|
||||||
|
imageCache,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
renderScrollbars: false,
|
renderScrollbars: false,
|
||||||
renderSelection: false,
|
renderSelection: false,
|
||||||
renderOptimizations: false,
|
renderOptimizations: true,
|
||||||
renderGrid: false,
|
renderGrid: false,
|
||||||
|
isExport: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -76,6 +93,7 @@ export const exportToSvg = async (
|
|||||||
exportWithDarkMode?: boolean;
|
exportWithDarkMode?: boolean;
|
||||||
exportEmbedScene?: boolean;
|
exportEmbedScene?: boolean;
|
||||||
},
|
},
|
||||||
|
files: BinaryFiles | null,
|
||||||
): Promise<SVGSVGElement> => {
|
): Promise<SVGSVGElement> => {
|
||||||
const {
|
const {
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
@ -89,7 +107,7 @@ export const exportToSvg = async (
|
|||||||
metadata = await (
|
metadata = await (
|
||||||
await import(/* webpackChunkName: "image" */ "../../src/data/image")
|
await import(/* webpackChunkName: "image" */ "../../src/data/image")
|
||||||
).encodeSvgMetadata({
|
).encodeSvgMetadata({
|
||||||
text: serializeAsJSON(elements, appState),
|
text: serializeAsJSON(elements, appState, files || {}, "local"),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -137,7 +155,7 @@ export const exportToSvg = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rsvg = rough.svg(svgRoot);
|
const rsvg = rough.svg(svgRoot);
|
||||||
renderSceneToSvg(elements, rsvg, svgRoot, {
|
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
|
||||||
offsetX: -minX + exportPadding,
|
offsetX: -minX + exportPadding,
|
||||||
offsetY: -minY + exportPadding,
|
offsetY: -minY + exportPadding,
|
||||||
});
|
});
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { ExcalidrawTextElement } from "../element/types";
|
import { ExcalidrawTextElement } from "../element/types";
|
||||||
import { Zoom } from "../types";
|
import { AppClassProperties, AppState, Zoom } from "../types";
|
||||||
|
|
||||||
export type SceneState = {
|
export type SceneState = {
|
||||||
scrollX: number;
|
scrollX: number;
|
||||||
scrollY: number;
|
scrollY: number;
|
||||||
// null indicates transparent bg
|
// null indicates transparent bg
|
||||||
viewBackgroundColor: string | null;
|
viewBackgroundColor: string | null;
|
||||||
exportWithDarkMode?: boolean;
|
|
||||||
zoom: Zoom;
|
zoom: Zoom;
|
||||||
shouldCacheIgnoreZoom: boolean;
|
shouldCacheIgnoreZoom: boolean;
|
||||||
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
|
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
|
||||||
@ -14,6 +13,8 @@ export type SceneState = {
|
|||||||
remoteSelectedElementIds: { [elementId: string]: string[] };
|
remoteSelectedElementIds: { [elementId: string]: string[] };
|
||||||
remotePointerUsernames: { [id: string]: string };
|
remotePointerUsernames: { [id: string]: string };
|
||||||
remotePointerUserStates: { [id: string]: string };
|
remotePointerUserStates: { [id: string]: string };
|
||||||
|
theme: AppState["theme"];
|
||||||
|
imageCache: AppClassProperties["imageCache"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SceneScroll = {
|
export type SceneScroll = {
|
||||||
|
@ -92,15 +92,29 @@ export const SHAPES = [
|
|||||||
value: "text",
|
value: "text",
|
||||||
key: KEYS.T,
|
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;
|
] as const;
|
||||||
|
|
||||||
export const findShapeByKey = (key: string) => {
|
export const findShapeByKey = (key: string) => {
|
||||||
const shape = SHAPES.find((shape, index) => {
|
const shape = SHAPES.find((shape, index) => {
|
||||||
return (
|
return (
|
||||||
key === (index + 1).toString() ||
|
key === (index + 1).toString() ||
|
||||||
(typeof shape.key === "string"
|
(shape.key &&
|
||||||
? shape.key === key
|
(typeof shape.key === "string"
|
||||||
: (shape.key as readonly string[]).includes(key))
|
? shape.key === key
|
||||||
|
: (shape.key as readonly string[]).includes(key)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return shape?.value || null;
|
return shape?.value || null;
|
||||||
|
@ -49,6 +49,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -216,6 +217,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -529,6 +531,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -842,6 +845,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -1009,6 +1013,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -1209,6 +1214,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -1462,6 +1468,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id1": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
@ -1793,6 +1800,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -2526,6 +2534,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -2839,6 +2848,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -3152,6 +3162,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id1": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
@ -3539,6 +3550,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id2": true,
|
"id2": true,
|
||||||
@ -3798,6 +3810,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id2": true,
|
"id2": true,
|
||||||
@ -4132,6 +4145,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -4234,6 +4248,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -4314,6 +4329,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
|
@ -49,6 +49,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id1": true,
|
"id1": true,
|
||||||
@ -518,6 +519,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id1": true,
|
"id1": true,
|
||||||
@ -993,6 +995,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -1783,6 +1786,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
@ -1991,6 +1995,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id3": true,
|
"id3": true,
|
||||||
@ -2457,6 +2462,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
@ -2714,6 +2720,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -2881,6 +2888,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id2": true,
|
"id2": true,
|
||||||
},
|
},
|
||||||
@ -3330,6 +3338,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -3571,6 +3580,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
@ -3779,6 +3789,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id1": true,
|
"id1": true,
|
||||||
@ -4028,6 +4039,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id1": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
@ -4284,6 +4296,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id2": true,
|
"id2": true,
|
||||||
},
|
},
|
||||||
@ -4672,6 +4685,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id1": true,
|
"id1": true,
|
||||||
@ -4971,6 +4985,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id1": true,
|
"id1": true,
|
||||||
@ -5248,6 +5263,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
@ -5459,6 +5475,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
@ -5626,6 +5643,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -6087,6 +6105,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id1": true,
|
"id1": true,
|
||||||
@ -6410,6 +6429,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -8474,6 +8494,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id2": true,
|
"id2": true,
|
||||||
@ -8841,6 +8862,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id2": true,
|
"id2": true,
|
||||||
@ -9098,6 +9120,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id2": true,
|
"id2": true,
|
||||||
@ -9319,6 +9342,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id2": true,
|
"id2": true,
|
||||||
@ -9603,6 +9627,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -9770,6 +9795,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -9937,6 +9963,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -10104,6 +10131,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -10301,6 +10329,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -10498,6 +10527,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -10713,6 +10743,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -10910,6 +10941,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -11077,6 +11109,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -11244,6 +11277,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -11441,6 +11475,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -11608,6 +11643,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -11823,6 +11859,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id1": true,
|
"id1": true,
|
||||||
@ -12550,6 +12587,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id3": true,
|
"id3": true,
|
||||||
@ -12807,6 +12845,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": -5.416666666666667,
|
"scrollX": -5.416666666666667,
|
||||||
@ -12911,6 +12950,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -13013,6 +13053,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
@ -13183,6 +13224,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id1": true,
|
"id1": true,
|
||||||
@ -13509,6 +13551,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -13713,6 +13756,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
"id1": true,
|
"id1": true,
|
||||||
@ -14549,6 +14593,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 60,
|
"scrollX": 60,
|
||||||
@ -14651,6 +14696,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
@ -15420,6 +15466,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id1": true,
|
"id1": true,
|
||||||
"id2": true,
|
"id2": true,
|
||||||
@ -15830,6 +15877,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {
|
"previousSelectedElementIds": Object {
|
||||||
"id1": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
@ -16107,6 +16155,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 11.046099290780141,
|
"scrollX": 11.046099290780141,
|
||||||
@ -16211,6 +16260,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -16715,6 +16765,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
@ -16817,6 +16868,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
|
@ -2,7 +2,7 @@ import { render, waitFor } from "./test-utils";
|
|||||||
import ExcalidrawApp from "../excalidraw-app";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { EXPORT_DATA_TYPES } from "../constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ describe("appState", () => {
|
|||||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{ type: "application/json" },
|
{ type: MIME_TYPES.json },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -19,11 +19,21 @@ jest.mock("../excalidraw-app/data/firebase.ts", () => {
|
|||||||
const loadFromFirebase = async () => null;
|
const loadFromFirebase = async () => null;
|
||||||
const saveToFirebase = () => {};
|
const saveToFirebase = () => {};
|
||||||
const isSavedToFirebase = () => true;
|
const isSavedToFirebase = () => true;
|
||||||
|
const loadFilesFromFirebase = async () => ({
|
||||||
|
loadedFiles: [],
|
||||||
|
erroredFiles: [],
|
||||||
|
});
|
||||||
|
const saveFilesToFirebase = async () => ({
|
||||||
|
savedFiles: new Map(),
|
||||||
|
erroredFiles: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadFromFirebase,
|
loadFromFirebase,
|
||||||
saveToFirebase,
|
saveToFirebase,
|
||||||
isSavedToFirebase,
|
isSavedToFirebase,
|
||||||
|
loadFilesFromFirebase,
|
||||||
|
saveFilesToFirebase,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -45,7 +45,7 @@ describe("export", () => {
|
|||||||
const pngBlob = await API.loadFile("./fixtures/smiley.png");
|
const pngBlob = await API.loadFile("./fixtures/smiley.png");
|
||||||
const pngBlobEmbedded = await encodePngMetadata({
|
const pngBlobEmbedded = await encodePngMetadata({
|
||||||
blob: pngBlob,
|
blob: pngBlob,
|
||||||
metadata: serializeAsJSON(testElements, h.state),
|
metadata: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||||
});
|
});
|
||||||
API.drop(pngBlobEmbedded);
|
API.drop(pngBlobEmbedded);
|
||||||
|
|
||||||
@ -58,7 +58,7 @@ describe("export", () => {
|
|||||||
|
|
||||||
it("test encoding/decoding scene for SVG export", async () => {
|
it("test encoding/decoding scene for SVG export", async () => {
|
||||||
const encoded = await encodeSvgMetadata({
|
const encoded = await encodeSvgMetadata({
|
||||||
text: serializeAsJSON(testElements, h.state),
|
text: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||||
});
|
});
|
||||||
const decoded = JSON.parse(await decodeSvgMetadata({ svg: encoded }));
|
const decoded = JSON.parse(await decodeSvgMetadata({ svg: encoded }));
|
||||||
expect(decoded.elements).toEqual([
|
expect(decoded.elements).toEqual([
|
||||||
|
1
src/tests/fixtures/diagramFixture.ts
vendored
1
src/tests/fixtures/diagramFixture.ts
vendored
@ -13,6 +13,7 @@ export const diagramFixture = {
|
|||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
gridSize: null,
|
gridSize: null,
|
||||||
},
|
},
|
||||||
|
files: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const diagramFactory = ({
|
export const diagramFactory = ({
|
||||||
|
@ -5,7 +5,7 @@ import { API } from "./helpers/api";
|
|||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { waitFor } from "@testing-library/react";
|
import { waitFor } from "@testing-library/react";
|
||||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||||
import { EXPORT_DATA_TYPES } from "../constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ describe("history", () => {
|
|||||||
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{ type: "application/json" },
|
{ type: MIME_TYPES.json },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -47,6 +47,7 @@ Object {
|
|||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
},
|
},
|
||||||
|
"pendingImageElement": null,
|
||||||
"previousSelectedElementIds": Object {},
|
"previousSelectedElementIds": Object {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import * as utils from "../../packages/utils";
|
import * as utils from "../../packages/utils";
|
||||||
import { diagramFactory } from "../fixtures/diagramFixture";
|
import { diagramFactory } from "../fixtures/diagramFixture";
|
||||||
import * as mockedSceneExportUtils from "../../scene/export";
|
import * as mockedSceneExportUtils from "../../scene/export";
|
||||||
|
import { MIME_TYPES } from "../../constants";
|
||||||
|
|
||||||
jest.mock("../../scene/export", () => ({
|
jest.mock("../../scene/export", () => ({
|
||||||
__esmodule: true,
|
__esmodule: true,
|
||||||
@ -11,8 +12,8 @@ jest.mock("../../scene/export", () => ({
|
|||||||
describe("exportToCanvas", () => {
|
describe("exportToCanvas", () => {
|
||||||
const EXPORT_PADDING = 10;
|
const EXPORT_PADDING = 10;
|
||||||
|
|
||||||
it("with default arguments", () => {
|
it("with default arguments", async () => {
|
||||||
const canvas = utils.exportToCanvas({
|
const canvas = await utils.exportToCanvas({
|
||||||
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -20,8 +21,8 @@ describe("exportToCanvas", () => {
|
|||||||
expect(canvas.height).toBe(100 + 2 * EXPORT_PADDING);
|
expect(canvas.height).toBe(100 + 2 * EXPORT_PADDING);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("when custom width and height", () => {
|
it("when custom width and height", async () => {
|
||||||
const canvas = utils.exportToCanvas({
|
const canvas = await utils.exportToCanvas({
|
||||||
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
||||||
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
|
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
|
||||||
});
|
});
|
||||||
@ -39,16 +40,17 @@ describe("exportToBlob", () => {
|
|||||||
const blob = await utils.exportToBlob({
|
const blob = await utils.exportToBlob({
|
||||||
...diagramFactory(),
|
...diagramFactory(),
|
||||||
getDimensions: (width, height) => ({ width, height, scale: 1 }),
|
getDimensions: (width, height) => ({ width, height, scale: 1 }),
|
||||||
|
// testing typo in MIME type (jpg → jpeg)
|
||||||
mimeType: "image/jpg",
|
mimeType: "image/jpg",
|
||||||
});
|
});
|
||||||
expect(blob?.type).toBe("image/jpeg");
|
expect(blob?.type).toBe(MIME_TYPES.jpg);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should default to image/png", async () => {
|
it("should default to image/png", async () => {
|
||||||
const blob = await utils.exportToBlob({
|
const blob = await utils.exportToBlob({
|
||||||
...diagramFactory(),
|
...diagramFactory(),
|
||||||
});
|
});
|
||||||
expect(blob?.type).toBe("image/png");
|
expect(blob?.type).toBe(MIME_TYPES.png);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should warn when using quality with image/png", async () => {
|
it("should warn when using quality with image/png", async () => {
|
||||||
@ -58,12 +60,12 @@ describe("exportToBlob", () => {
|
|||||||
|
|
||||||
await utils.exportToBlob({
|
await utils.exportToBlob({
|
||||||
...diagramFactory(),
|
...diagramFactory(),
|
||||||
mimeType: "image/png",
|
mimeType: MIME_TYPES.png,
|
||||||
quality: 1,
|
quality: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
'"quality" will be ignored for "image/png" mimeType',
|
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -74,7 +74,7 @@ exports[`exportToSvg with default arguments 1`] = `
|
|||||||
exports[`exportToSvg with exportEmbedScene 1`] = `
|
exports[`exportToSvg with exportEmbedScene 1`] = `
|
||||||
"
|
"
|
||||||
<!-- svg-source:excalidraw -->
|
<!-- svg-source:excalidraw -->
|
||||||
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1SQW7CMFx1MDAxMLzzisi9XCKRpIFQbrRUVaWqPXBAatWDiTexhbGD7Vx1MDAwMFx1MDAxMeLvtVx1MDAxZEjaqP1BfbC0szO76/WcXHUwMDA2QYBMXVx1MDAwMppcdTAwMDVcYo5cdTAwMTnmjCh8QEOH70FpJoVNxT7WslKZZ1JjytloxKVcdTAwMTVQqU3DXHUwMDA3XHUwMDBlW1x1MDAxMEZbxoeNg+Dkb5thxKn2K7V7m+dcdTAwMWImSLzLtunLYv707qWedLScJErauHaNb9M2PjBiqMWiMGwxXG6soKZcdTAwMDdiUXA3Zodoo+RcdTAwMDZcdTAwMWUkl8pccnJcdTAwMTP607Ve42xTKFlcdNJxojHG67zj5Izzpal5s1x1MDAwMJzRSlx1MDAwMep1WF1H7OGtTku74E5lW1x1MDAxNlSA1j80ssRcdTAwMTkzde9Vbr7ymfjtfvbrU6zKS1x1MDAxZKRd8G0yXHUwMDAw4ksl0WSc3oXTNtP9b1x1MDAxNId99FVcbv/XUTSdhmFcdTAwMTKnk5bB9MJ+tfFlc8w1dHt0K3xsbNCMKirO2/TVaIThrVx1MDAxNFx1MDAwNHn8PPz3yr9X/vRcbnDOSlxyXHUwMDE3r9jbv1x1MDAwN+GyXFxcdTAwMWFsXHUwMDFjpXFcdTAwMGXaMzjc//I3uT9Of1x1MDAxZZy/XHUwMDAw6cxEtiJ9<!-- payload-end -->
|
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1SQW7CMFx1MDAxMLzzisi9XCKRpIFQbrRUVaWqPXBAatWDiTexhbGD7Vx1MDAwMFx1MDAxMeLvtVx1MDAxZEjaqP1BfbC045nd9e6cXHUwMDA2QYBMXVx1MDAwMppcdTAwMDVcYo5cdTAwMTnmjCh8QEOH70FpJoV9in2sZaUyz6TGlLPRiEsroFKbhlx1MDAwZlx1MDAxY7YgjLaMXHUwMDBmXHUwMDFiXHUwMDA3wcnf9oVcdTAwMTGn2q/U7m2eb5gg8S7bpi+L+dO7l3rS0XKSKGnj2lx1MDAxNb5N2/jAiKFcdTAwMTaLwrDFKLCCmlx1MDAxZYhFwV2bXHUwMDFkoo2SXHUwMDFieJBcXCrXyE3oT1d6jbNNoWQlSMeJxlx1MDAxOK/zjpMzzpem5s1cdTAwMDBwRitcdTAwMDWoV2F1bbGHtzot7YA7lS1ZUFx1MDAwMVr/0MhcdTAwMTJnzNS9X7n+ymfip/vZz0+xKi95kHbBt85cdTAwMDCIT5VEk3F6XHUwMDE3TtuXbr9RXHUwMDFj9tFXKfyuo2g6XHLDJE4nLYPphV218WlzzDV0c3QjfGxs0LQqKs7b56vRXGLDWylcYvL4efjvlX+v/OlcdTAwMTXgnJVcdTAwMWEuXrG3/1x1MDAwZsJluTTYOErjXHUwMDFjtGdwuP9lN7k/Tu+d5nZcdTAwMDOu2uk8OH9cdTAwMDFcZrNI1SJ9<!-- payload-end -->
|
||||||
<defs>
|
<defs>
|
||||||
<style>
|
<style>
|
||||||
@font-face {
|
@font-face {
|
||||||
|
@ -13,10 +13,15 @@ describe("exportToSvg", () => {
|
|||||||
const DEFAULT_OPTIONS = {
|
const DEFAULT_OPTIONS = {
|
||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
|
files: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
it("with default arguments", async () => {
|
it("with default arguments", async () => {
|
||||||
const svgElement = await exportUtils.exportToSvg(ELEMENTS, DEFAULT_OPTIONS);
|
const svgElement = await exportUtils.exportToSvg(
|
||||||
|
ELEMENTS,
|
||||||
|
DEFAULT_OPTIONS,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
expect(svgElement).toMatchSnapshot();
|
expect(svgElement).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
@ -24,11 +29,15 @@ describe("exportToSvg", () => {
|
|||||||
it("with background color", async () => {
|
it("with background color", async () => {
|
||||||
const BACKGROUND_COLOR = "#abcdef";
|
const BACKGROUND_COLOR = "#abcdef";
|
||||||
|
|
||||||
const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
|
const svgElement = await exportUtils.exportToSvg(
|
||||||
...DEFAULT_OPTIONS,
|
ELEMENTS,
|
||||||
exportBackground: true,
|
{
|
||||||
viewBackgroundColor: BACKGROUND_COLOR,
|
...DEFAULT_OPTIONS,
|
||||||
});
|
exportBackground: true,
|
||||||
|
viewBackgroundColor: BACKGROUND_COLOR,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
expect(svgElement.querySelector("rect")).toHaveAttribute(
|
expect(svgElement.querySelector("rect")).toHaveAttribute(
|
||||||
"fill",
|
"fill",
|
||||||
@ -37,10 +46,14 @@ describe("exportToSvg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("with dark mode", async () => {
|
it("with dark mode", async () => {
|
||||||
const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
|
const svgElement = await exportUtils.exportToSvg(
|
||||||
...DEFAULT_OPTIONS,
|
ELEMENTS,
|
||||||
exportWithDarkMode: true,
|
{
|
||||||
});
|
...DEFAULT_OPTIONS,
|
||||||
|
exportWithDarkMode: true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
|
expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
|
||||||
`"themeFilter"`,
|
`"themeFilter"`,
|
||||||
@ -48,10 +61,14 @@ describe("exportToSvg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("with exportPadding", async () => {
|
it("with exportPadding", async () => {
|
||||||
const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
|
const svgElement = await exportUtils.exportToSvg(
|
||||||
...DEFAULT_OPTIONS,
|
ELEMENTS,
|
||||||
exportPadding: 0,
|
{
|
||||||
});
|
...DEFAULT_OPTIONS,
|
||||||
|
exportPadding: 0,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
|
expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
|
||||||
expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
|
expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
|
||||||
@ -64,11 +81,15 @@ describe("exportToSvg", () => {
|
|||||||
it("with scale", async () => {
|
it("with scale", async () => {
|
||||||
const SCALE = 2;
|
const SCALE = 2;
|
||||||
|
|
||||||
const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
|
const svgElement = await exportUtils.exportToSvg(
|
||||||
...DEFAULT_OPTIONS,
|
ELEMENTS,
|
||||||
exportPadding: 0,
|
{
|
||||||
exportScale: SCALE,
|
...DEFAULT_OPTIONS,
|
||||||
});
|
exportPadding: 0,
|
||||||
|
exportScale: SCALE,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
expect(svgElement).toHaveAttribute(
|
expect(svgElement).toHaveAttribute(
|
||||||
"height",
|
"height",
|
||||||
@ -81,10 +102,14 @@ describe("exportToSvg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("with exportEmbedScene", async () => {
|
it("with exportEmbedScene", async () => {
|
||||||
const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
|
const svgElement = await exportUtils.exportToSvg(
|
||||||
...DEFAULT_OPTIONS,
|
ELEMENTS,
|
||||||
exportEmbedScene: true,
|
{
|
||||||
});
|
...DEFAULT_OPTIONS,
|
||||||
|
exportEmbedScene: true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
expect(svgElement.innerHTML).toMatchSnapshot();
|
expect(svgElement.innerHTML).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -16,6 +16,8 @@ import { SceneData } from "../types";
|
|||||||
import { getSelectedElements } from "../scene/selection";
|
import { getSelectedElements } from "../scene/selection";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
|
||||||
|
require("fake-indexeddb/auto");
|
||||||
|
|
||||||
const customQueries = {
|
const customQueries = {
|
||||||
...queries,
|
...queries,
|
||||||
...toolQueries,
|
...toolQueries,
|
||||||
|
47
src/types.ts
47
src/types.ts
@ -10,6 +10,8 @@ import {
|
|||||||
Arrowhead,
|
Arrowhead,
|
||||||
ChartType,
|
ChartType,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
|
FileId,
|
||||||
|
ExcalidrawImageElement,
|
||||||
Theme,
|
Theme,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { SHAPES } from "./shapes";
|
import { SHAPES } from "./shapes";
|
||||||
@ -24,7 +26,9 @@ import { Language } from "./i18n";
|
|||||||
import { ClipboardData } from "./clipboard";
|
import { ClipboardData } from "./clipboard";
|
||||||
import { isOverScrollBars } from "./scene";
|
import { isOverScrollBars } from "./scene";
|
||||||
import { MaybeTransformHandleType } from "./element/transformHandles";
|
import { MaybeTransformHandleType } from "./element/transformHandles";
|
||||||
import { FileSystemHandle } from "./data/filesystem";
|
import Library from "./data/library";
|
||||||
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
|
import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
||||||
|
|
||||||
export type Point = Readonly<RoughPoint>;
|
export type Point = Readonly<RoughPoint>;
|
||||||
|
|
||||||
@ -43,6 +47,22 @@ export type Collaborator = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataURL = string & { _brand: "DataURL" };
|
||||||
|
|
||||||
|
export type BinaryFileData = {
|
||||||
|
mimeType:
|
||||||
|
| typeof ALLOWED_IMAGE_MIME_TYPES[number]
|
||||||
|
// future user or unknown file type
|
||||||
|
| typeof MIME_TYPES.binary;
|
||||||
|
id: FileId;
|
||||||
|
dataURL: DataURL;
|
||||||
|
created: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
|
||||||
|
|
||||||
|
export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
@ -127,6 +147,8 @@ export type AppState = {
|
|||||||
shown: true;
|
shown: true;
|
||||||
data: Spreadsheet;
|
data: Spreadsheet;
|
||||||
};
|
};
|
||||||
|
/** imageElement waiting to be placed on canvas */
|
||||||
|
pendingImageElement: NonDeleted<ExcalidrawImageElement> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
|
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
|
||||||
@ -172,6 +194,7 @@ export interface ExcalidrawProps {
|
|||||||
onChange?: (
|
onChange?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
) => void;
|
) => void;
|
||||||
initialData?: ImportedDataState | null | Promise<ImportedDataState | null>;
|
initialData?: ImportedDataState | null | Promise<ImportedDataState | null>;
|
||||||
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
|
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
|
||||||
@ -207,6 +230,7 @@ export interface ExcalidrawProps {
|
|||||||
handleKeyboardGlobally?: boolean;
|
handleKeyboardGlobally?: boolean;
|
||||||
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
|
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
generateIdForFile?: (file: File) => string | Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SceneData = {
|
export type SceneData = {
|
||||||
@ -227,11 +251,13 @@ export type ExportOpts = {
|
|||||||
onExportToBackend?: (
|
onExportToBackend?: (
|
||||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
canvas: HTMLCanvasElement | null,
|
canvas: HTMLCanvasElement | null,
|
||||||
) => void;
|
) => void;
|
||||||
renderCustomUI?: (
|
renderCustomUI?: (
|
||||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
canvas: HTMLCanvasElement | null,
|
canvas: HTMLCanvasElement | null,
|
||||||
) => JSX.Element;
|
) => JSX.Element;
|
||||||
};
|
};
|
||||||
@ -258,6 +284,23 @@ export type AppProps = ExcalidrawProps & {
|
|||||||
handleKeyboardGlobally: boolean;
|
handleKeyboardGlobally: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A subset of App class properties that we need to use elsewhere
|
||||||
|
* in the app, eg Manager. Factored out into a separate type to keep DRY. */
|
||||||
|
export type AppClassProperties = {
|
||||||
|
props: AppProps;
|
||||||
|
canvas: HTMLCanvasElement | null;
|
||||||
|
focusContainer(): void;
|
||||||
|
library: Library;
|
||||||
|
imageCache: Map<
|
||||||
|
FileId,
|
||||||
|
{
|
||||||
|
image: HTMLImageElement | Promise<HTMLImageElement>;
|
||||||
|
mimeType: typeof ALLOWED_IMAGE_MIME_TYPES[number];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
files: BinaryFiles;
|
||||||
|
};
|
||||||
|
|
||||||
export type PointerDownState = Readonly<{
|
export type PointerDownState = Readonly<{
|
||||||
// The first position at which pointerDown happened
|
// The first position at which pointerDown happened
|
||||||
origin: Readonly<{ x: number; y: number }>;
|
origin: Readonly<{ x: number; y: number }>;
|
||||||
@ -327,9 +370,11 @@ export type ExcalidrawImperativeAPI = {
|
|||||||
scrollToContent: InstanceType<typeof App>["scrollToContent"];
|
scrollToContent: InstanceType<typeof App>["scrollToContent"];
|
||||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||||
getAppState: () => InstanceType<typeof App>["state"];
|
getAppState: () => InstanceType<typeof App>["state"];
|
||||||
|
getFiles: () => InstanceType<typeof App>["files"];
|
||||||
refresh: InstanceType<typeof App>["refresh"];
|
refresh: InstanceType<typeof App>["refresh"];
|
||||||
importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
|
importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
|
||||||
setToastMessage: InstanceType<typeof App>["setToastMessage"];
|
setToastMessage: InstanceType<typeof App>["setToastMessage"];
|
||||||
|
addFiles: (data: BinaryFileData[]) => void;
|
||||||
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
||||||
ready: true;
|
ready: true;
|
||||||
id: string;
|
id: string;
|
||||||
|
12
src/utils.ts
12
src/utils.ts
@ -10,8 +10,6 @@ import { Zoom } from "./types";
|
|||||||
import { unstable_batchedUpdates } from "react-dom";
|
import { unstable_batchedUpdates } from "react-dom";
|
||||||
import { isDarwin } from "./keys";
|
import { isDarwin } from "./keys";
|
||||||
|
|
||||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
|
||||||
|
|
||||||
let mockDateTime: string | null = null;
|
let mockDateTime: string | null = null;
|
||||||
|
|
||||||
export const setDateTimeForTests = (dateTime: string) => {
|
export const setDateTimeForTests = (dateTime: string) => {
|
||||||
@ -192,7 +190,9 @@ export const setCursorForShape = (
|
|||||||
}
|
}
|
||||||
if (shape === "selection") {
|
if (shape === "selection") {
|
||||||
resetCursor(canvas);
|
resetCursor(canvas);
|
||||||
} else {
|
// do nothing if image tool is selected which suggests there's
|
||||||
|
// a image-preview set as the cursor
|
||||||
|
} else if (shape !== "image") {
|
||||||
canvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
canvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -443,3 +443,9 @@ export const focusNearestParent = (element: HTMLInputElement) => {
|
|||||||
parent = parent.parentElement;
|
parent = parent.parentElement;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const preventUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
// NOTE: modern browsers no longer allow showing a custom message here
|
||||||
|
event.returnValue = "";
|
||||||
|
};
|
||||||
|
124
yarn.lock
124
yarn.lock
@ -2075,6 +2075,11 @@
|
|||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz"
|
resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz"
|
||||||
|
|
||||||
|
"@types/pica@5.1.3":
|
||||||
|
version "5.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/pica/-/pica-5.1.3.tgz#5ef64529a1f83f7d6586a8bf75a8a00be32aca02"
|
||||||
|
integrity sha512-13SEyETRE5psd9bE0AmN+0M1tannde2fwHfLVaVIljkbL9V0OfFvKwCicyeDvVYLkmjQWEydbAlsDsmjrdyTOg==
|
||||||
|
|
||||||
"@types/prettier@^2.0.0":
|
"@types/prettier@^2.0.0":
|
||||||
version "2.2.3"
|
version "2.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0"
|
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0"
|
||||||
@ -3035,6 +3040,11 @@ balanced-match@^1.0.0:
|
|||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz"
|
||||||
|
|
||||||
|
base64-arraybuffer-es6@^0.7.0:
|
||||||
|
version "0.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86"
|
||||||
|
integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw==
|
||||||
|
|
||||||
base64-arraybuffer@0.1.4:
|
base64-arraybuffer@0.1.4:
|
||||||
version "0.1.4"
|
version "0.1.4"
|
||||||
resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz"
|
resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz"
|
||||||
@ -4023,7 +4033,7 @@ core-js@3.6.5, core-js@^3.6.5:
|
|||||||
version "3.6.5"
|
version "3.6.5"
|
||||||
resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz"
|
resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz"
|
||||||
|
|
||||||
core-js@^2.4.0:
|
core-js@^2.4.0, core-js@^2.5.3:
|
||||||
version "2.6.12"
|
version "2.6.12"
|
||||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
|
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
|
||||||
|
|
||||||
@ -4667,6 +4677,13 @@ domelementtype@^2.0.1:
|
|||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e"
|
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e"
|
||||||
|
|
||||||
|
domexception@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
|
||||||
|
integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==
|
||||||
|
dependencies:
|
||||||
|
webidl-conversions "^4.0.2"
|
||||||
|
|
||||||
domexception@^2.0.1:
|
domexception@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz"
|
resolved "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz"
|
||||||
@ -5480,6 +5497,14 @@ extsprintf@1.3.0, extsprintf@^1.2.0:
|
|||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz"
|
resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz"
|
||||||
|
|
||||||
|
fake-indexeddb@3.1.3:
|
||||||
|
version "3.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-3.1.3.tgz#76d59146a6b994b9bb50ac9949cbd96ad6cca760"
|
||||||
|
integrity sha512-kpWYPIUGmxW8Q7xG7ampGL63fU/kYNukrIyy9KFj3+KVlFbE/SmvWebzWXBiCMeR0cPK6ufDoGC7MFkPhPLH9w==
|
||||||
|
dependencies:
|
||||||
|
realistic-structured-clone "^2.0.1"
|
||||||
|
setimmediate "^1.0.5"
|
||||||
|
|
||||||
fast-deep-equal@^3.1.1:
|
fast-deep-equal@^3.1.1:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||||
@ -6127,6 +6152,11 @@ globby@^6.1.0:
|
|||||||
pify "^2.0.0"
|
pify "^2.0.0"
|
||||||
pinkie-promise "^2.0.0"
|
pinkie-promise "^2.0.0"
|
||||||
|
|
||||||
|
glur@^1.1.2:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/glur/-/glur-1.1.2.tgz#f20ea36db103bfc292343921f1f91e83c3467689"
|
||||||
|
integrity sha1-8g6jbbEDv8KSNDkh8fkeg8NGdok=
|
||||||
|
|
||||||
google-auth-library@^6.1.1, google-auth-library@^6.1.2, google-auth-library@^6.1.3:
|
google-auth-library@^6.1.1, google-auth-library@^6.1.2, google-auth-library@^6.1.3:
|
||||||
version "6.1.6"
|
version "6.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-6.1.6.tgz#deacdcdb883d9ed6bac78bb5d79a078877fdf572"
|
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-6.1.6.tgz#deacdcdb883d9ed6bac78bb5d79a078877fdf572"
|
||||||
@ -6545,6 +6575,13 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss "^7.0.14"
|
postcss "^7.0.14"
|
||||||
|
|
||||||
|
idb-keyval@5.1.3:
|
||||||
|
version "5.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-5.1.3.tgz#6ef5dff371897c23f144322dc6374eadd6a345d9"
|
||||||
|
integrity sha512-N9HbCK/FaXSRVI+k6Xq4QgWxbcZRUv+SfG1y7HJ28JdV8yEJu6k+C/YLea7npGckX2DQJeEVuMc4bKOBeU/2LQ==
|
||||||
|
dependencies:
|
||||||
|
safari-14-idb-fix "^1.0.4"
|
||||||
|
|
||||||
idb@3.0.2:
|
idb@3.0.2:
|
||||||
version "3.0.2"
|
version "3.0.2"
|
||||||
resolved "https://registry.npmjs.org/idb/-/idb-3.0.2.tgz"
|
resolved "https://registry.npmjs.org/idb/-/idb-3.0.2.tgz"
|
||||||
@ -6571,6 +6608,13 @@ ignore@^5.1.4:
|
|||||||
version "5.1.8"
|
version "5.1.8"
|
||||||
resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz"
|
resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz"
|
||||||
|
|
||||||
|
image-blob-reduce@3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz#812be7655a552031635799ae64e846b106f7a489"
|
||||||
|
integrity sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==
|
||||||
|
dependencies:
|
||||||
|
pica "^7.1.0"
|
||||||
|
|
||||||
immediate@~3.0.5:
|
immediate@~3.0.5:
|
||||||
version "3.0.6"
|
version "3.0.6"
|
||||||
resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz"
|
resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz"
|
||||||
@ -8112,7 +8156,7 @@ lodash.values@^2.4.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
lodash.keys "~2.4.1"
|
lodash.keys "~2.4.1"
|
||||||
|
|
||||||
"lodash@>=3.5 <5", lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5:
|
"lodash@>=3.5 <5", lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@^4.7.0:
|
||||||
version "4.17.21"
|
version "4.17.21"
|
||||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||||
|
|
||||||
@ -8536,6 +8580,14 @@ multicast-dns@^6.0.1:
|
|||||||
dns-packet "^1.3.1"
|
dns-packet "^1.3.1"
|
||||||
thunky "^1.0.2"
|
thunky "^1.0.2"
|
||||||
|
|
||||||
|
multimath@^2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/multimath/-/multimath-2.0.0.tgz#0d37acf67c328f30e3d8c6b0d3209e6082710302"
|
||||||
|
integrity sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==
|
||||||
|
dependencies:
|
||||||
|
glur "^1.1.2"
|
||||||
|
object-assign "^4.1.1"
|
||||||
|
|
||||||
mute-stream@0.0.7:
|
mute-stream@0.0.7:
|
||||||
version "0.0.7"
|
version "0.0.7"
|
||||||
resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz"
|
resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz"
|
||||||
@ -9274,6 +9326,17 @@ performance-now@^2.1.0:
|
|||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz"
|
resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz"
|
||||||
|
|
||||||
|
pica@^7.1.0:
|
||||||
|
version "7.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/pica/-/pica-7.1.0.tgz#eb0b11abb3f2234ba8bdd0a839460f8fcd20e32a"
|
||||||
|
integrity sha512-4D1E1lssL/yJD4La23Kbh5CSJPeXMO8NgJTsR/VWtG7aV92fD2g2t/PABg/Abp8Ug3yJNw7y7x1ftkJuIPLpEw==
|
||||||
|
dependencies:
|
||||||
|
glur "^1.1.2"
|
||||||
|
inherits "^2.0.3"
|
||||||
|
multimath "^2.0.0"
|
||||||
|
object-assign "^4.1.1"
|
||||||
|
webworkify "^1.5.0"
|
||||||
|
|
||||||
picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.2.2:
|
picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.2.2:
|
||||||
version "2.2.2"
|
version "2.2.2"
|
||||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz"
|
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz"
|
||||||
@ -10502,6 +10565,16 @@ readdirp@~3.5.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch "^2.2.1"
|
picomatch "^2.2.1"
|
||||||
|
|
||||||
|
realistic-structured-clone@^2.0.1:
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-2.0.3.tgz#8a252a87db8278d92267ad7a168c4f43fa485795"
|
||||||
|
integrity sha512-XYTwWZi5+lU4Wf+rnsQ7pukN9hF2cbJJf/yruBr1w23WhGflM6WoTBkdMVAun+oHFW2mV7UquyYo5oOI7YLJrQ==
|
||||||
|
dependencies:
|
||||||
|
core-js "^2.5.3"
|
||||||
|
domexception "^1.0.1"
|
||||||
|
typeson "^6.1.0"
|
||||||
|
typeson-registry "^1.0.0-alpha.20"
|
||||||
|
|
||||||
recursive-readdir@2.2.2:
|
recursive-readdir@2.2.2:
|
||||||
version "2.2.2"
|
version "2.2.2"
|
||||||
resolved "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz"
|
resolved "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz"
|
||||||
@ -10916,6 +10989,11 @@ rxjs@^6.4.0, rxjs@^6.6.0, rxjs@^6.6.6:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^1.9.0"
|
tslib "^1.9.0"
|
||||||
|
|
||||||
|
safari-14-idb-fix@^1.0.4:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/safari-14-idb-fix/-/safari-14-idb-fix-1.0.4.tgz#5c68ba63e2a8ae0d89a0aa1e13fe89e3aef7da19"
|
||||||
|
integrity sha512-4+Y2baQdgJpzu84d0QjySl70Kyygzf0pepVg8NVg4NnQEPpfC91fAn0baNvtStlCjUUxxiu0BOMiafa98fRRuA==
|
||||||
|
|
||||||
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
||||||
version "5.1.2"
|
version "5.1.2"
|
||||||
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz"
|
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz"
|
||||||
@ -11122,7 +11200,7 @@ set-value@^2.0.0, set-value@^2.0.1:
|
|||||||
is-plain-object "^2.0.3"
|
is-plain-object "^2.0.3"
|
||||||
split-string "^3.0.1"
|
split-string "^3.0.1"
|
||||||
|
|
||||||
setimmediate@^1.0.4, setimmediate@~1.0.4:
|
setimmediate@^1.0.4, setimmediate@^1.0.5, setimmediate@~1.0.4:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
|
resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
|
||||||
|
|
||||||
@ -12062,6 +12140,13 @@ tr46@^2.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode "^2.1.1"
|
punycode "^2.1.1"
|
||||||
|
|
||||||
|
tr46@^2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240"
|
||||||
|
integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==
|
||||||
|
dependencies:
|
||||||
|
punycode "^2.1.1"
|
||||||
|
|
||||||
"traverse@>=0.3.0 <0.4":
|
"traverse@>=0.3.0 <0.4":
|
||||||
version "0.3.9"
|
version "0.3.9"
|
||||||
resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz"
|
resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz"
|
||||||
@ -12196,6 +12281,20 @@ typescript@4.2.4:
|
|||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
|
||||||
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
|
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
|
||||||
|
|
||||||
|
typeson-registry@^1.0.0-alpha.20:
|
||||||
|
version "1.0.0-alpha.39"
|
||||||
|
resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211"
|
||||||
|
integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw==
|
||||||
|
dependencies:
|
||||||
|
base64-arraybuffer-es6 "^0.7.0"
|
||||||
|
typeson "^6.0.0"
|
||||||
|
whatwg-url "^8.4.0"
|
||||||
|
|
||||||
|
typeson@^6.0.0, typeson@^6.1.0:
|
||||||
|
version "6.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b"
|
||||||
|
integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA==
|
||||||
|
|
||||||
unbox-primitive@^1.0.0:
|
unbox-primitive@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
|
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
|
||||||
@ -12517,6 +12616,11 @@ wcwidth@^1.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
defaults "^1.0.3"
|
defaults "^1.0.3"
|
||||||
|
|
||||||
|
webidl-conversions@^4.0.2:
|
||||||
|
version "4.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
|
||||||
|
integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
|
||||||
|
|
||||||
webidl-conversions@^5.0.0:
|
webidl-conversions@^5.0.0:
|
||||||
version "5.0.0"
|
version "5.0.0"
|
||||||
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz"
|
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz"
|
||||||
@ -12636,6 +12740,11 @@ websocket-extensions@>=0.1.1:
|
|||||||
version "0.1.4"
|
version "0.1.4"
|
||||||
resolved "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz"
|
resolved "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz"
|
||||||
|
|
||||||
|
webworkify@^1.5.0:
|
||||||
|
version "1.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/webworkify/-/webworkify-1.5.0.tgz#734ad87a774de6ebdd546e1d3e027da5b8f4a42c"
|
||||||
|
integrity sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==
|
||||||
|
|
||||||
whatwg-encoding@^1.0.5:
|
whatwg-encoding@^1.0.5:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz"
|
resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz"
|
||||||
@ -12662,6 +12771,15 @@ whatwg-url@^8.0.0:
|
|||||||
tr46 "^2.0.2"
|
tr46 "^2.0.2"
|
||||||
webidl-conversions "^6.1.0"
|
webidl-conversions "^6.1.0"
|
||||||
|
|
||||||
|
whatwg-url@^8.4.0:
|
||||||
|
version "8.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77"
|
||||||
|
integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==
|
||||||
|
dependencies:
|
||||||
|
lodash "^4.7.0"
|
||||||
|
tr46 "^2.1.0"
|
||||||
|
webidl-conversions "^6.1.0"
|
||||||
|
|
||||||
which-boxed-primitive@^1.0.2:
|
which-boxed-primitive@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
|
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user