diff --git a/.eslintignore b/.eslintignore index 5a5d1f17..b238ce5f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,3 +5,4 @@ package-lock.json firebase/ dist/ public/workbox +src/packages/excalidraw/types diff --git a/package.json b/package.json index d250f215..45375a0c 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,16 @@ "@testing-library/react": "11.2.6", "@tldraw/vec": "0.0.106", "@types/jest": "26.0.22", + "@types/pica": "5.1.3", "@types/react": "17.0.3", "@types/react-dom": "17.0.3", "@types/socket.io-client": "1.4.36", "clsx": "1.1.1", + "fake-indexeddb": "3.1.3", "firebase": "8.3.3", "i18next-browser-languagedetector": "6.1.0", + "idb-keyval": "5.1.3", + "image-blob-reduce": "3.0.1", "lodash.throttle": "4.1.1", "nanoid": "3.1.22", "open-color": "1.8.0", diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index e29a3d84..126fd24b 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -47,13 +47,15 @@ export const actionChangeViewBackgroundColor = register({ export const actionClearCanvas = register({ name: "clearCanvas", - perform: (elements, appState) => { + perform: (elements, appState, _, app) => { + app.imageCache.clear(); return { elements: elements.map((element) => newElementWith(element, { isDeleted: true }), ), appState: { ...getDefaultAppState(), + files: {}, theme: appState.theme, elementLocked: appState.elementLocked, exportBackground: appState.exportBackground, diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx index 13dd6bf2..217ee150 100644 --- a/src/actions/actionClipboard.tsx +++ b/src/actions/actionClipboard.tsx @@ -9,8 +9,8 @@ import { t } from "../i18n"; export const actionCopy = register({ name: "copy", - perform: (elements, appState) => { - copyToClipboard(getNonDeletedElements(elements), appState); + perform: (elements, appState, _, app) => { + copyToClipboard(getNonDeletedElements(elements), appState, app.files); return { commitToHistory: false, @@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({ ? selectedElements : getNonDeletedElements(elements), appState, + app.files, appState, ); return { @@ -88,6 +89,7 @@ export const actionCopyAsPng = register({ ? selectedElements : getNonDeletedElements(elements), appState, + app.files, appState, ); return { diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index db8a8e27..d080b81c 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -128,13 +128,13 @@ export const actionChangeExportEmbedScene = register({ export const actionSaveToActiveFile = register({ name: "saveToActiveFile", - perform: async (elements, appState, value) => { + perform: async (elements, appState, value, app) => { const fileHandleExists = !!appState.fileHandle; try { const { fileHandle } = isImageFileHandle(appState.fileHandle) - ? await resaveAsImageWithScene(elements, appState) - : await saveAsJSON(elements, appState); + ? await resaveAsImageWithScene(elements, appState, app.files) + : await saveAsJSON(elements, appState, app.files); return { commitToHistory: false, @@ -170,12 +170,16 @@ export const actionSaveToActiveFile = register({ export const actionSaveFileToDisk = register({ name: "saveFileToDisk", - perform: async (elements, appState, value) => { + perform: async (elements, appState, value, app) => { try { - const { fileHandle } = await saveAsJSON(elements, { - ...appState, - fileHandle: null, - }); + const { fileHandle } = await saveAsJSON( + elements, + { + ...appState, + fileHandle: null, + }, + app.files, + ); return { commitToHistory: false, appState: { ...appState, fileHandle } }; } catch (error) { if (error?.name !== "AbortError") { @@ -202,15 +206,17 @@ export const actionSaveFileToDisk = register({ export const actionLoadScene = register({ name: "loadScene", - perform: async (elements, appState) => { + perform: async (elements, appState, _, app) => { try { const { elements: loadedElements, appState: loadedAppState, + files, } = await loadFromJSON(appState, elements); return { elements: loadedElements, appState: loadedAppState, + files, commitToHistory: true, }; } catch (error) { @@ -220,6 +226,7 @@ export const actionLoadScene = register({ return { elements, appState: { ...appState, errorMessage: error.message }, + files: app.files, commitToHistory: false, }; } diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index 988e3402..cc91a3fc 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -49,6 +49,11 @@ export const actionFinalize = register({ } let newElements = elements; + + if (appState.pendingImageElement) { + mutateElement(appState.pendingImageElement, { isDeleted: true }, false); + } + if (window.document.activeElement instanceof HTMLElement) { focusContainer(); } @@ -152,6 +157,7 @@ export const actionFinalize = register({ [multiPointElement.id]: true, } : appState.selectedElementIds, + pendingImageElement: null, }, commitToHistory: appState.elementType === "freedraw", }; diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts index a9c200a0..4a4e4bd1 100644 --- a/src/actions/actionFlip.ts +++ b/src/actions/actionFlip.ts @@ -93,13 +93,13 @@ const flipElements = ( appState: AppState, flipDirection: "horizontal" | "vertical", ): ExcalidrawElement[] => { - for (let i = 0; i < elements.length; i++) { - flipElement(elements[i], appState); + elements.forEach((element) => { + flipElement(element, appState); // If vertical flip, rotate an extra 180 if (flipDirection === "vertical") { - rotateElement(elements[i], Math.PI); + rotateElement(element, Math.PI); } - } + }); return elements; }; diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 48973ed9..667a68fc 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -59,6 +59,7 @@ import { getTargetElements, isSomeElementSelected, } from "../scene"; +import { hasStrokeColor } from "../scene/comparisons"; import { register } from "./register"; const changeProperty = ( @@ -103,11 +104,13 @@ export const actionChangeStrokeColor = register({ perform: (elements, appState, value) => { return { ...(value.currentItemStrokeColor && { - elements: changeProperty(elements, appState, (el) => - newElementWith(el, { - strokeColor: value.currentItemStrokeColor, - }), - ), + elements: changeProperty(elements, appState, (el) => { + return hasStrokeColor(el.type) + ? newElementWith(el, { + strokeColor: value.currentItemStrokeColor, + }) + : el; + }), }), appState: { ...appState, diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 7b83ae59..94b36e87 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -8,18 +8,8 @@ import { PanelComponentProps, } from "./types"; import { ExcalidrawElement } from "../element/types"; -import { AppProps, AppState } from "../types"; +import { AppClassProperties, AppState } from "../types"; import { MODES } from "../constants"; -import Library from "../data/library"; - -// This is the component, but for now we don't care about anything but its -// `canvas` state. -type App = { - canvas: HTMLCanvasElement | null; - focusContainer: () => void; - props: AppProps; - library: Library; -}; export class ActionManager implements ActionsManagerInterface { actions = {} as ActionsManagerInterface["actions"]; @@ -28,13 +18,13 @@ export class ActionManager implements ActionsManagerInterface { getAppState: () => Readonly; getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; - app: App; + app: AppClassProperties; constructor( updater: UpdaterFn, getAppState: () => AppState, getElementsIncludingDeleted: () => readonly ExcalidrawElement[], - app: App, + app: AppClassProperties, ) { this.updater = (actionResult) => { if (actionResult && "then" in actionResult) { diff --git a/src/actions/types.ts b/src/actions/types.ts index 674a9a3f..672fb91b 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -1,7 +1,11 @@ import React from "react"; import { ExcalidrawElement } from "../element/types"; -import { AppState, ExcalidrawProps } from "../types"; -import Library from "../data/library"; +import { + AppClassProperties, + AppState, + ExcalidrawProps, + BinaryFiles, +} from "../types"; import { ToolButtonSize } from "../components/ToolButton"; /** if false, the action should be prevented */ @@ -12,22 +16,18 @@ export type ActionResult = AppState, "offsetTop" | "offsetLeft" | "width" | "height" > | null; + files?: BinaryFiles | null; commitToHistory: boolean; syncHistory?: boolean; + replaceFiles?: boolean; } | false; -type AppAPI = { - canvas: HTMLCanvasElement | null; - focusContainer(): void; - library: Library; -}; - type ActionFn = ( elements: readonly ExcalidrawElement[], appState: Readonly, formData: any, - app: AppAPI, + app: AppClassProperties, ) => ActionResult | Promise; export type UpdaterFn = (res: ActionResult) => void; diff --git a/src/appState.ts b/src/appState.ts index 42e708d2..f4daf771 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -79,6 +79,7 @@ export const getDefaultAppState = (): Omit< zenModeEnabled: false, zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } }, viewModeEnabled: false, + pendingImageElement: null, }; }; @@ -92,78 +93,87 @@ const APP_STATE_STORAGE_CONF = (< browser: boolean; /** whether to keep when exporting to file/database */ export: boolean; + /** server (shareLink/collab/...) */ + server: boolean; }, T extends Record >( config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }, ) => config)({ - theme: { browser: true, export: false }, - collaborators: { browser: false, export: false }, - currentChartType: { browser: true, export: false }, - currentItemBackgroundColor: { browser: true, export: false }, - currentItemEndArrowhead: { browser: true, export: false }, - currentItemFillStyle: { browser: true, export: false }, - currentItemFontFamily: { browser: true, export: false }, - currentItemFontSize: { browser: true, export: false }, - currentItemLinearStrokeSharpness: { browser: true, export: false }, - currentItemOpacity: { browser: true, export: false }, - currentItemRoughness: { browser: true, export: false }, - currentItemStartArrowhead: { browser: true, export: false }, - currentItemStrokeColor: { browser: true, export: false }, - currentItemStrokeSharpness: { browser: true, export: false }, - currentItemStrokeStyle: { browser: true, export: false }, - currentItemStrokeWidth: { browser: true, export: false }, - currentItemTextAlign: { browser: true, export: false }, - cursorButton: { browser: true, export: false }, - draggingElement: { browser: false, export: false }, - editingElement: { browser: false, export: false }, - editingGroupId: { browser: true, export: false }, - editingLinearElement: { browser: false, export: false }, - elementLocked: { browser: true, export: false }, - elementType: { browser: true, export: false }, - errorMessage: { browser: false, export: false }, - exportBackground: { browser: true, export: false }, - exportEmbedScene: { browser: true, export: false }, - exportScale: { browser: true, export: false }, - exportWithDarkMode: { browser: true, export: false }, - fileHandle: { browser: false, export: false }, - gridSize: { browser: true, export: true }, - height: { browser: false, export: false }, - isBindingEnabled: { browser: false, export: false }, - isLibraryOpen: { browser: false, export: false }, - isLoading: { browser: false, export: false }, - isResizing: { browser: false, export: false }, - isRotating: { browser: false, export: false }, - lastPointerDownWith: { browser: true, export: false }, - multiElement: { browser: false, export: false }, - name: { browser: true, export: false }, - offsetLeft: { browser: false, export: false }, - offsetTop: { browser: false, export: false }, - openMenu: { browser: true, export: false }, - openPopup: { browser: false, export: false }, - pasteDialog: { browser: false, export: false }, - previousSelectedElementIds: { browser: true, export: false }, - resizingElement: { browser: false, export: false }, - scrolledOutside: { browser: true, export: false }, - scrollX: { browser: true, export: false }, - scrollY: { browser: true, export: false }, - selectedElementIds: { browser: true, export: false }, - selectedGroupIds: { browser: true, export: false }, - selectionElement: { browser: false, export: false }, - shouldCacheIgnoreZoom: { browser: true, export: false }, - showHelpDialog: { browser: false, export: false }, - showStats: { browser: true, export: false }, - startBoundElement: { browser: false, export: false }, - suggestedBindings: { browser: false, export: false }, - toastMessage: { browser: false, export: false }, - viewBackgroundColor: { browser: true, export: true }, - width: { browser: false, export: false }, - zenModeEnabled: { browser: true, export: false }, - zoom: { browser: true, export: false }, - viewModeEnabled: { browser: false, export: false }, + theme: { browser: true, export: false, server: false }, + collaborators: { browser: false, export: false, server: false }, + currentChartType: { browser: true, export: false, server: false }, + currentItemBackgroundColor: { browser: true, export: false, server: false }, + currentItemEndArrowhead: { browser: true, export: false, server: false }, + currentItemFillStyle: { browser: true, export: false, server: false }, + currentItemFontFamily: { browser: true, export: false, server: false }, + currentItemFontSize: { browser: true, export: false, server: false }, + currentItemLinearStrokeSharpness: { + browser: true, + export: false, + server: false, + }, + currentItemOpacity: { browser: true, export: false, server: false }, + currentItemRoughness: { browser: true, export: false, server: false }, + currentItemStartArrowhead: { browser: true, export: false, server: false }, + currentItemStrokeColor: { browser: true, export: false, server: false }, + currentItemStrokeSharpness: { browser: true, export: false, server: false }, + currentItemStrokeStyle: { browser: true, export: false, server: false }, + currentItemStrokeWidth: { browser: true, export: false, server: false }, + currentItemTextAlign: { browser: true, export: false, server: false }, + cursorButton: { browser: true, export: false, server: false }, + draggingElement: { browser: false, export: false, server: false }, + editingElement: { browser: false, export: false, server: false }, + editingGroupId: { browser: true, export: false, server: false }, + editingLinearElement: { browser: false, export: false, server: false }, + elementLocked: { browser: true, export: false, server: false }, + elementType: { browser: true, export: false, server: false }, + errorMessage: { browser: false, export: false, server: false }, + exportBackground: { browser: true, export: false, server: false }, + exportEmbedScene: { browser: true, export: false, server: false }, + exportScale: { browser: true, export: false, server: false }, + exportWithDarkMode: { browser: true, export: false, server: false }, + fileHandle: { browser: false, export: false, server: false }, + gridSize: { browser: true, export: true, server: true }, + height: { browser: false, export: false, server: false }, + isBindingEnabled: { browser: false, export: false, server: false }, + isLibraryOpen: { browser: false, export: false, server: false }, + isLoading: { browser: false, export: false, server: false }, + isResizing: { browser: false, export: false, server: false }, + isRotating: { browser: false, export: false, server: false }, + lastPointerDownWith: { browser: true, export: false, server: false }, + multiElement: { browser: false, export: false, server: false }, + name: { browser: true, export: false, server: false }, + offsetLeft: { browser: false, export: false, server: false }, + offsetTop: { browser: false, export: false, server: false }, + openMenu: { browser: true, export: false, server: false }, + openPopup: { browser: false, export: false, server: false }, + pasteDialog: { browser: false, export: false, server: false }, + previousSelectedElementIds: { browser: true, export: false, server: false }, + resizingElement: { browser: false, export: false, server: false }, + scrolledOutside: { browser: true, export: false, server: false }, + scrollX: { browser: true, export: false, server: false }, + scrollY: { browser: true, export: false, server: false }, + selectedElementIds: { browser: true, export: false, server: false }, + selectedGroupIds: { browser: true, export: false, server: false }, + selectionElement: { browser: false, export: false, server: false }, + shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, + showHelpDialog: { browser: false, export: false, server: false }, + showStats: { browser: true, export: false, server: false }, + startBoundElement: { browser: false, export: false, server: false }, + suggestedBindings: { browser: false, export: false, server: false }, + toastMessage: { browser: false, export: false, server: false }, + viewBackgroundColor: { browser: true, export: true, server: true }, + width: { browser: false, export: false, server: false }, + zenModeEnabled: { browser: true, export: false, server: false }, + zoom: { browser: true, export: false, server: false }, + viewModeEnabled: { browser: false, export: false, server: false }, + pendingImageElement: { browser: false, export: false, server: false }, }); -const _clearAppStateForStorage = ( +const _clearAppStateForStorage = < + ExportType extends "export" | "browser" | "server" +>( appState: Partial, exportType: ExportType, ) => { @@ -176,8 +186,10 @@ const _clearAppStateForStorage = ( for (const key of Object.keys(appState) as (keyof typeof appState)[]) { const propConfig = APP_STATE_STORAGE_CONF[key]; if (propConfig?.[exportType]) { - // @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445 - stateForExport[key] = appState[key]; + const nextValue = appState[key]; + + // https://github.com/microsoft/TypeScript/issues/31445 + (stateForExport as any)[key] = nextValue; } } return stateForExport; @@ -190,3 +202,7 @@ export const clearAppStateForLocalStorage = (appState: Partial) => { export const cleanAppStateForExport = (appState: Partial) => { return _clearAppStateForStorage(appState, "export"); }; + +export const clearAppStateForDatabase = (appState: Partial) => { + return _clearAppStateForStorage(appState, "server"); +}; diff --git a/src/clipboard.ts b/src/clipboard.ts index 5728554d..cde96eb1 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -3,19 +3,22 @@ import { NonDeletedExcalidrawElement, } from "./element/types"; import { getSelectedElements } from "./scene"; -import { AppState } from "./types"; +import { AppState, BinaryFiles } from "./types"; import { SVG_EXPORT_TAG } from "./scene/export"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; -import { EXPORT_DATA_TYPES } from "./constants"; +import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; +import { isInitializedImageElement } from "./element/typeChecks"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; elements: ExcalidrawElement[]; + files: BinaryFiles | undefined; }; export interface ClipboardData { spreadsheet?: Spreadsheet; elements?: readonly ExcalidrawElement[]; + files?: BinaryFiles; text?: string; errorMessage?: string; } @@ -37,7 +40,7 @@ export const probablySupportsClipboardBlob = const clipboardContainsElements = ( contents: any, -): contents is { elements: ExcalidrawElement[] } => { +): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => { if ( [ EXPORT_DATA_TYPES.excalidraw, @@ -53,10 +56,18 @@ const clipboardContainsElements = ( export const copyToClipboard = async ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, + files: BinaryFiles, ) => { + const selectedElements = getSelectedElements(elements, appState); const contents: ElementsClipboard = { type: EXPORT_DATA_TYPES.excalidrawClipboard, - elements: getSelectedElements(elements, appState), + elements: selectedElements, + files: selectedElements.reduce((acc, element) => { + if (isInitializedImageElement(element) && files[element.fileId]) { + acc[element.fileId] = files[element.fileId]; + } + return acc; + }, {} as BinaryFiles), }; const json = JSON.stringify(contents); CLIPBOARD = json; @@ -138,7 +149,10 @@ export const parseClipboard = async ( try { const systemClipboardData = JSON.parse(systemClipboard); if (clipboardContainsElements(systemClipboardData)) { - return { elements: systemClipboardData.elements }; + return { + elements: systemClipboardData.elements, + files: systemClipboardData.files, + }; } return appClipboardData; } catch { @@ -153,7 +167,7 @@ export const parseClipboard = async ( export const copyBlobToClipboardAsPng = async (blob: Blob) => { await navigator.clipboard.write([ - new window.ClipboardItem({ "image/png": blob }), + new window.ClipboardItem({ [MIME_TYPES.png]: blob }), ]); }; diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 8f0211ae..cc291eac 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -1,7 +1,7 @@ import React from "react"; import { ActionManager } from "../actions/manager"; import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement } from "../element/types"; +import { ExcalidrawElement, PointerType } from "../element/types"; import { t } from "../i18n"; import { useIsMobile } from "../components/App"; import { @@ -18,6 +18,7 @@ import { AppState, Zoom } from "../types"; import { capitalizeString, isTransparent, setCursorForShape } from "../utils"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; +import { hasStrokeColor } from "../scene/comparisons"; export const SelectedShapeActions = ({ appState, @@ -48,9 +49,22 @@ export const SelectedShapeActions = ({ hasBackground(elementType) || targetElements.some((element) => hasBackground(element.type)); + let commonSelectedType: string | null = targetElements[0]?.type || null; + + for (const element of targetElements) { + if (element.type !== commonSelectedType) { + commonSelectedType = null; + break; + } + } + return (
- {renderAction("changeStrokeColor")} + {((hasStrokeColor(elementType) && + elementType !== "image" && + commonSelectedType !== "image") || + targetElements.some((element) => hasStrokeColor(element.type))) && + renderAction("changeStrokeColor")} {showChangeBackgroundIcons && renderAction("changeBackgroundColor")} {showFillIcons && renderAction("changeFillStyle")} @@ -155,18 +169,20 @@ export const ShapesSwitcher = ({ canvas, elementType, setAppState, + onImageAction, }: { canvas: HTMLCanvasElement | null; elementType: ExcalidrawElement["type"]; setAppState: React.Component["setState"]; + onImageAction: (data: { pointerType: PointerType | null }) => void; }) => ( <> {SHAPES.map(({ value, icon, key }, index) => { const label = t(`toolBar.${value}`); - const letter = typeof key === "string" ? key : key[0]; - const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${ - index + 1 - }`; + const letter = key && (typeof key === "string" ? key : key[0]); + const shortcut = letter + ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}` + : `${index + 1}`; return ( { + onChange={({ pointerType }) => { setAppState({ elementType: value, multiElement: null, selectedElementIds: {}, }); setCursorForShape(canvas, value); - setAppState({}); + if (value === "image") { + onImageAction({ pointerType }); + } }} /> ); diff --git a/src/components/App.tsx b/src/components/App.tsx index d5564c20..fb4ff7ba 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -43,6 +43,7 @@ import { import { APP_NAME, CURSOR_TYPE, + DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_UI_OPTIONS, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, @@ -51,7 +52,9 @@ import { ENV, EVENT, GRID_SIZE, + IMAGE_RENDER_TIMEOUT, LINE_CONFIRM_THRESHOLD, + MAX_ALLOWED_FILE_BYTES, MIME_TYPES, MQ_MAX_HEIGHT_LANDSCAPE, MQ_MAX_WIDTH_LANDSCAPE, @@ -91,6 +94,7 @@ import { newElement, newLinearElement, newTextElement, + newImageElement, textWysiwyg, transformElements, updateTextElement, @@ -109,11 +113,13 @@ import { updateBoundElements, } from "../element/binding"; import { LinearElementEditor } from "../element/linearElementEditor"; -import { mutateElement } from "../element/mutateElement"; +import { bumpVersion, mutateElement } from "../element/mutateElement"; import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; import { isBindingElement, isBindingElementType, + isImageElement, + isInitializedImageElement, isLinearElement, isLinearElementType, } from "../element/typeChecks"; @@ -125,6 +131,9 @@ import { ExcalidrawLinearElement, ExcalidrawTextElement, NonDeleted, + InitializedExcalidrawImageElement, + ExcalidrawImageElement, + FileId, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -140,9 +149,9 @@ import History from "../history"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { CODES, - getResizeCenterPointKey, - getResizeWithSidesSameLengthKey, - getRotateWithDiscreteAngleKey, + shouldResizeFromCenter, + shouldMaintainAspectRatio, + shouldRotateWithDiscreteAngle, isArrowKey, KEYS, } from "../keys"; @@ -165,9 +174,13 @@ import { SceneState, ScrollBars } from "../scene/types"; import { getNewZoom } from "../scene/zoom"; import { findShapeByKey } from "../shapes"; import { + AppClassProperties, AppProps, AppState, + BinaryFileData, + DataURL, ExcalidrawImperativeAPI, + BinaryFiles, Gesture, GestureEvent, LibraryItems, @@ -195,7 +208,22 @@ import LayerUI from "./LayerUI"; import { Stats } from "./Stats"; import { Toast } from "./Toast"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; -import { nativeFileSystemSupported } from "../data/filesystem"; +import { + dataURLToFile, + generateIdFromFile, + getDataURL, + isSupportedImageFile, + resizeImageFile, + SVGStringToFile, +} from "../data/blob"; +import { + getInitializedImageElements, + loadHTMLImageElement, + normalizeSVG, + updateImageCache as _updateImageCache, +} from "../element/image"; +import throttle from "lodash.throttle"; +import { fileOpen, nativeFileSystemSupported } from "../data/filesystem"; const IsMobileContext = React.createContext(false); export const useIsMobile = () => useContext(IsMobileContext); @@ -226,7 +254,7 @@ const gesture: Gesture = { }; class App extends React.Component { - canvas: HTMLCanvasElement | null = null; + canvas: AppClassProperties["canvas"] = null; rc: RoughCanvas | null = null; unmounted: boolean = false; actionManager: ActionManager; @@ -243,7 +271,7 @@ class App extends React.Component { private scene: Scene; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; - public library: Library; + public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; private id: string; private history: History; @@ -252,6 +280,9 @@ class App extends React.Component { id: string; }; + public files: BinaryFiles = {}; + public imageCache: AppClassProperties["imageCache"] = new Map(); + constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); @@ -287,6 +318,7 @@ class App extends React.Component { ready: true, readyPromise, updateScene: this.updateScene, + addFiles: this.addFiles, resetScene: this.resetScene, getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, history: { @@ -295,6 +327,7 @@ class App extends React.Component { scrollToContent: this.scrollToContent, getSceneElements: this.getSceneElements, getAppState: () => this.state, + getFiles: () => this.files, refresh: this.refresh, importLibrary: this.importLibraryFromUrl, setToastMessage: this.setToastMessage, @@ -413,6 +446,7 @@ class App extends React.Component { { this.addElementsFromPasteOrLibrary({ elements, position: "center", + files: null, }) } zenModeEnabled={zenModeEnabled} @@ -444,6 +479,7 @@ class App extends React.Component { focusContainer={this.focusContainer} library={this.library} id={this.id} + onImageAction={this.onImageAction} />
@@ -469,7 +505,7 @@ class App extends React.Component { ); } - public focusContainer = () => { + public focusContainer: AppClassProperties["focusContainer"] = () => { if (this.props.autoFocus) { this.excalidrawContainerRef.current?.focus(); } @@ -506,6 +542,13 @@ class App extends React.Component { } } + if (actionResult.files) { + this.files = actionResult.replaceFiles + ? actionResult.files + : { ...this.files, ...actionResult.files }; + this.addNewImagesToImageCache(); + } + if (actionResult.appState || editingElement) { if (actionResult.commitToHistory) { this.history.resumeRecording(); @@ -661,16 +704,16 @@ class App extends React.Component { this.state, this.scene.getElementsIncludingDeleted(), ) - .then(({ elements, appState }) => + .then((scene) => { this.syncActionResult({ - elements, + ...scene, appState: { - ...(appState || this.state), + ...(scene.appState || this.state), isLoading: false, }, commitToHistory: true, - }), - ) + }); + }) .catch((error) => { this.setState({ isLoading: false, errorMessage: error.message }); }); @@ -699,8 +742,13 @@ class App extends React.Component { } const scene = restore(initialData, null, null); + scene.appState = { ...scene.appState, + elementType: + scene.appState.elementType === "image" + ? "selection" + : scene.appState.elementType, isLoading: false, }; if (initialData?.scrollToContent) { @@ -816,6 +864,8 @@ class App extends React.Component { } public componentWillUnmount() { + this.files = {}; + this.imageCache.clear(); this.resizeObserver?.disconnect(); this.unmounted = true; this.removeEventListeners(); @@ -1026,17 +1076,26 @@ class App extends React.Component { ); cursorButton[socketId] = user.button; }); - const elements = this.scene.getElements(); + const renderingElements = this.scene.getElements().filter((element) => { + if (isImageElement(element)) { + if ( + // not placed on canvas yet (but in elements array) + this.state.pendingImageElement && + element.id === this.state.pendingImageElement.id + ) { + return false; + } + } + // don't render text element that's being currently edited (it's + // rendered on remote only) + return ( + !this.state.editingElement || + this.state.editingElement.type !== "text" || + element.id !== this.state.editingElement.id + ); + }); const { atLeastOneVisibleElement, scrollBars } = renderScene( - elements.filter((element) => { - // don't render text element that's being currently edited (it's - // rendered on remote only) - return ( - !this.state.editingElement || - this.state.editingElement.type !== "text" || - element.id !== this.state.editingElement.id - ); - }), + renderingElements, this.state, this.state.selectionElement, window.devicePixelRatio, @@ -1053,6 +1112,8 @@ class App extends React.Component { remotePointerUsernames: pointerUsernames, remotePointerUserStates: pointerUserStates, shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom, + theme: this.state.theme, + imageCache: this.imageCache, }, { renderOptimizations: true, @@ -1066,13 +1127,15 @@ class App extends React.Component { // hide when editing text this.state.editingElement?.type === "text" ? false - : !atLeastOneVisibleElement && elements.length > 0; + : !atLeastOneVisibleElement && renderingElements.length > 0; if (this.state.scrolledOutside !== scrolledOutside) { this.setState({ scrolledOutside }); } this.history.record(this.state, this.scene.getElementsIncludingDeleted()); + this.scheduleImageRefresh(); + // Do not notify consumers if we're still loading the scene. Among other // potential issues, this fixes a case where the tab isn't focused during // init, which would trigger onChange with empty elements, which would then @@ -1081,6 +1144,7 @@ class App extends React.Component { this.props.onChange?.( this.scene.getElementsIncludingDeleted(), this.state, + this.files, ); } } @@ -1125,7 +1189,7 @@ class App extends React.Component { }; private copyAll = () => { - copyToClipboard(this.scene.getElements(), this.state); + copyToClipboard(this.scene.getElements(), this.state, this.files); }; private static resetTapTwice() { @@ -1192,7 +1256,34 @@ class App extends React.Component { ) { return; } + const data = await parseClipboard(event); + + let file = event?.clipboardData?.files[0]; + + if (!file && data.text) { + const string = data.text.trim(); + if (string.startsWith("")) { + // ignore SVG validation/normalization which will be done during image + // initialization + file = SVGStringToFile(string); + } + } + + if (isSupportedImageFile(file)) { + const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( + { clientX: cursorX, clientY: cursorY }, + this.state, + ); + + const imageElement = this.createImageElement({ sceneX, sceneY }); + this.insertImageElement(imageElement, file); + this.initializeImageDimensions(imageElement); + this.setState({ selectedElementIds: { [imageElement.id]: true } }); + + return; + } + if (this.props.onPaste) { try { if ((await this.props.onPaste(data, event)) === false) { @@ -1214,6 +1305,7 @@ class App extends React.Component { } else if (data.elements) { this.addElementsFromPasteOrLibrary({ elements: data.elements, + files: data.files || null, position: "cursor", }); } else if (data.text) { @@ -1226,6 +1318,7 @@ class App extends React.Component { private addElementsFromPasteOrLibrary = (opts: { elements: readonly ExcalidrawElement[]; + files: BinaryFiles | null; position: { clientX: number; clientY: number } | "cursor" | "center"; }) => { const elements = restoreElements(opts.elements, null); @@ -1278,6 +1371,10 @@ class App extends React.Component { ]; fixBindingsAfterDuplication(nextElements, elements, oldIdToDuplicatedId); + if (opts.files) { + this.files = { ...this.files, ...opts.files }; + } + this.scene.replaceAllElements(nextElements); this.history.resumeRecording(); this.setState( @@ -1293,6 +1390,11 @@ class App extends React.Component { }, this.scene.getElements(), ), + () => { + if (opts.files) { + this.addNewImagesToImageCache(); + } + }, ); this.selectShapeTool("selection"); }; @@ -1406,6 +1508,31 @@ class App extends React.Component { } }; + /** adds supplied files to existing files in the appState */ + public addFiles: ExcalidrawImperativeAPI["addFiles"] = withBatchedUpdates( + (files) => { + const filesMap = files.reduce((acc, fileData) => { + acc.set(fileData.id, fileData); + return acc; + }, new Map()); + + this.files = { ...this.files, ...Object.fromEntries(filesMap) }; + this.addNewImagesToImageCache(); + + // bump versions for elements that reference added files so that + // we/host apps can detect the change + this.scene.getElements().forEach((element) => { + if ( + isInitializedImageElement(element) && + filesMap.has(element.fileId) + ) { + bumpVersion(element); + } + }); + this.scene.informMutation(); + }, + ); + public updateScene = withBatchedUpdates( (sceneData: { elements?: SceneData["elements"]; @@ -1496,7 +1623,7 @@ class App extends React.Component { this.setState({ isBindingEnabled: false }); } - if (event.code === CODES.NINE) { + if (event.code === CODES.ZERO) { this.setState({ isLibraryOpen: !this.state.isLibraryOpen }); } @@ -1660,6 +1787,9 @@ class App extends React.Component { if (!isLinearElementType(elementType)) { this.setState({ suggestedBindings: [] }); } + if (elementType === "image") { + this.onImageAction(); + } if (elementType !== "selection") { this.setState({ elementType, @@ -2342,6 +2472,26 @@ class App extends React.Component { this.state.elementType, pointerDownState, ); + } else if (this.state.elementType === "image") { + // reset image preview on pointerdown + setCursor(this.canvas, CURSOR_TYPE.CROSSHAIR); + + if (!this.state.pendingImageElement) { + return; + } + + this.setState({ + draggingElement: this.state.pendingImageElement, + editingElement: this.state.pendingImageElement, + pendingImageElement: null, + multiElement: null, + }); + + const { x, y } = viewportCoordsToSceneCoords(event, this.state); + mutateElement(this.state.pendingImageElement, { + x, + y, + }); } else if (this.state.elementType === "freedraw") { this.handleFreeDrawElementOnPointerDown( event, @@ -2911,6 +3061,32 @@ class App extends React.Component { }); }; + private createImageElement = ({ + sceneX, + sceneY, + }: { + sceneX: number; + sceneY: number; + }) => { + const [gridX, gridY] = getGridPoint(sceneX, sceneY, this.state.gridSize); + + const element = newImageElement({ + type: "image", + x: gridX, + y: gridY, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, + strokeSharpness: this.state.currentItemLinearStrokeSharpness, + }); + + return element; + }; + private handleLinearElementOnPointerDown = ( event: React.PointerEvent, elementType: ExcalidrawLinearElement["type"], @@ -3295,7 +3471,7 @@ class App extends React.Component { let dx = gridX - draggingElement.x; let dy = gridY - draggingElement.y; - if (getRotateWithDiscreteAngleKey(event) && points.length === 2) { + if (shouldRotateWithDiscreteAngle(event) && points.length === 2) { ({ width: dx, height: dy } = getPerfectElementSize( this.state.elementType, dx, @@ -3472,6 +3648,10 @@ class App extends React.Component { pointerDownState.eventListeners.onKeyUp!, ); + if (this.state.pendingImageElement) { + this.setState({ pendingImageElement: null }); + } + if (draggingElement?.type === "freedraw") { const pointerCoords = viewportCoordsToSceneCoords( childEvent, @@ -3502,6 +3682,27 @@ class App extends React.Component { return; } + if (isImageElement(draggingElement)) { + const imageElement = draggingElement; + try { + this.initializeImageDimensions(imageElement); + this.setState( + { selectedElementIds: { [imageElement.id]: true } }, + () => { + this.actionManager.executeAction(actionFinalize); + }, + ); + } catch (error) { + console.error(error); + this.scene.replaceAllElements( + this.scene + .getElementsIncludingDeleted() + .filter((el) => el.id !== imageElement.id), + ); + this.actionManager.executeAction(actionFinalize); + } + return; + } if (isLinearElement(draggingElement)) { if (draggingElement!.points.length > 1) { @@ -3736,6 +3937,369 @@ class App extends React.Component { }); } + private initializeImage = async ({ + imageFile, + imageElement: _imageElement, + showCursorImagePreview = false, + }: { + imageFile: File; + imageElement: ExcalidrawImageElement; + showCursorImagePreview?: boolean; + }) => { + // at this point this should be guaranteed image file, but we do this check + // to satisfy TS down the line + if (!isSupportedImageFile(imageFile)) { + throw new Error(t("errors.unsupportedFileType")); + } + const mimeType = imageFile.type; + + setCursor(this.canvas, "wait"); + + if (mimeType === MIME_TYPES.svg) { + try { + imageFile = SVGStringToFile( + await normalizeSVG(await imageFile.text()), + imageFile.name, + ); + } catch (error) { + console.warn(error); + throw new Error(t("errors.svgImageInsertError")); + } + } + + // generate image id (by default the file digest) before any + // resizing/compression takes place to keep it more portable + const fileId = await ((this.props.generateIdForFile?.( + imageFile, + ) as Promise) || generateIdFromFile(imageFile)); + + if (!fileId) { + console.warn( + "Couldn't generate file id or the supplied `generateIdForFile` didn't resolve to one.", + ); + throw new Error(t("errors.imageInsertError")); + } + + const existingFileData = this.files[fileId]; + if (!existingFileData?.dataURL) { + try { + imageFile = await resizeImageFile( + imageFile, + DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, + ); + } catch (error) { + console.error("error trying to resing image file on insertion", error); + } + + if (imageFile.size > MAX_ALLOWED_FILE_BYTES) { + throw new Error( + t("errors.fileTooBig", { + maxSize: `${Math.trunc(MAX_ALLOWED_FILE_BYTES / 1024 / 1024)}MB`, + }), + ); + } + } + + if (showCursorImagePreview) { + const dataURL = this.files[fileId]?.dataURL; + // optimization so that we don't unnecessarily resize the original + // full-size file for cursor preview + // (it's much faster to convert the resized dataURL to File) + const resizedFile = dataURL && dataURLToFile(dataURL); + + this.setImagePreviewCursor(resizedFile || imageFile); + } + + const dataURL = + this.files[fileId]?.dataURL || (await getDataURL(imageFile)); + + const imageElement = mutateElement( + _imageElement, + { + fileId, + }, + false, + ) as NonDeleted; + + return new Promise>( + async (resolve, reject) => { + try { + this.files = { + ...this.files, + [fileId]: { + mimeType, + id: fileId, + dataURL, + created: Date.now(), + }, + }; + const cachedImageData = this.imageCache.get(fileId); + if (!cachedImageData) { + this.addNewImagesToImageCache(); + await this.updateImageCache([imageElement]); + } + if (cachedImageData?.image instanceof Promise) { + await cachedImageData.image; + } + if ( + this.state.pendingImageElement?.id !== imageElement.id && + this.state.draggingElement?.id !== imageElement.id + ) { + this.initializeImageDimensions(imageElement, true); + } + resolve(imageElement); + } catch (error) { + console.error(error); + reject(new Error(t("errors.imageInsertError"))); + } finally { + if (!showCursorImagePreview) { + resetCursor(this.canvas); + } + } + }, + ); + }; + + /** + * inserts image into elements array and rerenders + */ + private insertImageElement = async ( + imageElement: ExcalidrawImageElement, + imageFile: File, + showCursorImagePreview?: boolean, + ) => { + this.scene.replaceAllElements([ + ...this.scene.getElementsIncludingDeleted(), + imageElement, + ]); + + try { + await this.initializeImage({ + imageFile, + imageElement, + showCursorImagePreview, + }); + } catch (error) { + mutateElement(imageElement, { + isDeleted: true, + }); + this.actionManager.executeAction(actionFinalize); + this.setState({ + errorMessage: error.message || t("errors.imageInsertError"), + }); + } + }; + + private setImagePreviewCursor = async (imageFile: File) => { + // mustn't be larger than 128 px + // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property + const cursorImageSizePx = 96; + + const imagePreview = await resizeImageFile(imageFile, cursorImageSizePx); + + let previewDataURL = await getDataURL(imagePreview); + + // SVG cannot be resized via `resizeImageFile` so we resize by rendering to + // a small canvas + if (imageFile.type === MIME_TYPES.svg) { + const img = await loadHTMLImageElement(previewDataURL); + + let height = Math.min(img.height, cursorImageSizePx); + let width = height * (img.width / img.height); + + if (width > cursorImageSizePx) { + width = cursorImageSizePx; + height = width * (img.height / img.width); + } + + const canvas = document.createElement("canvas"); + canvas.height = height; + canvas.width = width; + const context = canvas.getContext("2d")!; + + context.drawImage(img, 0, 0, width, height); + + previewDataURL = canvas.toDataURL(MIME_TYPES.svg) as DataURL; + } + + if (this.state.pendingImageElement) { + setCursor(this.canvas, `url(${previewDataURL}) 4 4, auto`); + } + }; + + private onImageAction = async ( + { insertOnCanvasDirectly } = { insertOnCanvasDirectly: false }, + ) => { + try { + const clientX = this.state.width / 2 + this.state.offsetLeft; + const clientY = this.state.height / 2 + this.state.offsetTop; + + const { x, y } = viewportCoordsToSceneCoords( + { clientX, clientY }, + this.state, + ); + + const imageFile = await fileOpen({ + description: "Image", + extensions: ["jpg", "png", "svg", "gif"], + }); + + const imageElement = this.createImageElement({ + sceneX: x, + sceneY: y, + }); + + if (insertOnCanvasDirectly) { + this.insertImageElement(imageElement, imageFile); + this.initializeImageDimensions(imageElement); + this.setState( + { + selectedElementIds: { [imageElement.id]: true }, + }, + () => { + this.actionManager.executeAction(actionFinalize); + }, + ); + } else { + this.setState( + { + pendingImageElement: imageElement, + }, + () => { + this.insertImageElement( + imageElement, + imageFile, + /* showCursorImagePreview */ true, + ); + }, + ); + } + } catch (error) { + if (error.name !== "AbortError") { + console.error(error); + } + this.setState( + { + pendingImageElement: null, + editingElement: null, + elementType: "selection", + }, + () => { + this.actionManager.executeAction(actionFinalize); + }, + ); + } + }; + + private initializeImageDimensions = ( + imageElement: ExcalidrawImageElement, + forceNaturalSize = false, + ) => { + const image = + isInitializedImageElement(imageElement) && + this.imageCache.get(imageElement.fileId)?.image; + + if (!image || image instanceof Promise) { + if ( + imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value && + imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value + ) { + const placeholderSize = 100 / this.state.zoom.value; + mutateElement(imageElement, { + x: imageElement.x - placeholderSize / 2, + y: imageElement.y - placeholderSize / 2, + width: placeholderSize, + height: placeholderSize, + }); + } + + return; + } + + if ( + forceNaturalSize || + // if user-created bounding box is below threshold, assume the + // intention was to click instead of drag, and use the image's + // intrinsic size + (imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value && + imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value) + ) { + const minHeight = Math.max(this.state.height - 120, 160); + // max 65% of canvas height, clamped to <300px, vh - 120px> + const maxHeight = Math.min( + minHeight, + Math.floor(this.state.height * 0.5) / this.state.zoom.value, + ); + + const height = Math.min(image.naturalHeight, maxHeight); + const width = height * (image.naturalWidth / image.naturalHeight); + + // add current imageElement width/height to account for previous centering + // of the placholder image + const x = imageElement.x + imageElement.width / 2 - width / 2; + const y = imageElement.y + imageElement.height / 2 - height / 2; + + mutateElement(imageElement, { x, y, width, height }); + } + }; + + /** updates image cache, refreshing updated elements and/or setting status + to error for images that fail during element creation */ + private updateImageCache = async ( + elements: readonly InitializedExcalidrawImageElement[], + files = this.files, + ) => { + const { updatedFiles, erroredFiles } = await _updateImageCache({ + imageCache: this.imageCache, + fileIds: elements.map((element) => element.fileId), + files, + }); + if (updatedFiles.size || erroredFiles.size) { + for (const element of elements) { + if (updatedFiles.has(element.fileId)) { + invalidateShapeForElement(element); + } + + if (erroredFiles.has(element.fileId)) { + mutateElement( + element, + { status: "error" }, + /* informMutation */ false, + ); + } + } + } + return { updatedFiles, erroredFiles }; + }; + + /** adds new images to imageCache and re-renders if needed */ + private addNewImagesToImageCache = async ( + imageElements: InitializedExcalidrawImageElement[] = getInitializedImageElements( + this.scene.getElements(), + ), + files: BinaryFiles = this.files, + ) => { + const uncachedImageElements = imageElements.filter( + (element) => !element.isDeleted && !this.imageCache.has(element.fileId), + ); + + if (uncachedImageElements.length) { + const { updatedFiles } = await this.updateImageCache( + uncachedImageElements, + files, + ); + if (updatedFiles.size) { + this.scene.informMutation(); + } + } + }; + + /** generally you should use `addNewImagesToImageCache()` directly if you need + * to render new images. This is just a failsafe */ + private scheduleImageRefresh = throttle(() => { + this.addNewImagesToImageCache(); + }, IMAGE_RENDER_TIMEOUT); + private updateBindingEnabledOnPointerMove = ( event: React.PointerEvent, ) => { @@ -3834,31 +4398,60 @@ class App extends React.Component { private handleAppOnDrop = async (event: React.DragEvent) => { try { const file = event.dataTransfer.files[0]; - if (file?.type === "image/png" || file?.type === "image/svg+xml") { - if (nativeFileSystemSupported) { + + if (isSupportedImageFile(file)) { + // first attempt to decode scene from the image if it's embedded + // --------------------------------------------------------------------- + + if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) { try { - // This will only work as of Chrome 86, - // but can be safely ignored on older releases. - const item = event.dataTransfer.items[0]; - (file as any).handle = await (item as any).getAsFileSystemHandle(); + if (nativeFileSystemSupported) { + try { + // This will only work as of Chrome 86, + // but can be safely ignored on older releases. + const item = event.dataTransfer.items[0]; + (file as any).handle = await (item as any).getAsFileSystemHandle(); + } catch (error: any) { + console.warn(error.name, error.message); + } + } + + const scene = await loadFromBlob( + file, + this.state, + this.scene.getElementsIncludingDeleted(), + ); + this.syncActionResult({ + ...scene, + appState: { + ...(scene.appState || this.state), + isLoading: false, + }, + replaceFiles: true, + commitToHistory: true, + }); + return; } catch (error: any) { - console.warn(error.name, error.message); + if (error.name !== "EncodingError") { + throw error; + } } } - const { elements, appState } = await loadFromBlob( - file, + // if no scene is embedded or we fail for whatever reason, fall back + // to importing as regular image + // --------------------------------------------------------------------- + + const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( + event, this.state, - this.scene.getElementsIncludingDeleted(), ); - this.syncActionResult({ - elements, - appState: { - ...(appState || this.state), - isLoading: false, - }, - commitToHistory: true, - }); + + const imageElement = this.createImageElement({ sceneX, sceneY }); + this.insertImageElement(imageElement, file); + this.initializeImageDimensions(imageElement); + this.setState({ selectedElementIds: { [imageElement.id]: true } }); + return; } } catch (error: any) { @@ -3873,6 +4466,7 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements: JSON.parse(libraryShapes), position: event, + files: null, }); return; } @@ -3911,16 +4505,17 @@ class App extends React.Component { loadFileToCanvas = (file: Blob) => { loadFromBlob(file, this.state, this.scene.getElementsIncludingDeleted()) - .then(({ elements, appState }) => + .then((scene) => { this.syncActionResult({ - elements, + ...scene, appState: { - ...(appState || this.state), + ...(scene.appState || this.state), isLoading: false, }, + replaceFiles: true, commitToHistory: true, - }), - ) + }); + }) .catch((error) => { this.setState({ isLoading: false, errorMessage: error.message }); }); @@ -3972,8 +4567,8 @@ class App extends React.Component { pointerCoords.y, distance(pointerDownState.origin.x, pointerCoords.x), distance(pointerDownState.origin.y, pointerCoords.y), - getResizeWithSidesSameLengthKey(event), - getResizeCenterPointKey(event), + shouldMaintainAspectRatio(event), + shouldResizeFromCenter(event), ); } else { const [gridX, gridY] = getGridPoint( @@ -3981,6 +4576,15 @@ class App extends React.Component { pointerCoords.y, this.state.gridSize, ); + + const image = + isInitializedImageElement(draggingElement) && + this.imageCache.get(draggingElement.fileId)?.image; + const aspectRatio = + image && !(image instanceof Promise) + ? image.width / image.height + : null; + dragNewElement( draggingElement, this.state.elementType, @@ -3990,9 +4594,13 @@ class App extends React.Component { gridY, distance(pointerDownState.originInGrid.x, gridX), distance(pointerDownState.originInGrid.y, gridY), - getResizeWithSidesSameLengthKey(event), - getResizeCenterPointKey(event), + isImageElement(draggingElement) + ? !shouldMaintainAspectRatio(event) + : shouldMaintainAspectRatio(event), + shouldResizeFromCenter(event), + aspectRatio, ); + this.maybeSuggestBindingForAll([draggingElement]); } }; @@ -4025,9 +4633,11 @@ class App extends React.Component { transformHandleType, selectedElements, pointerDownState.resize.arrowDirection, - getRotateWithDiscreteAngleKey(event), - getResizeCenterPointKey(event), - getResizeWithSidesSameLengthKey(event), + shouldRotateWithDiscreteAngle(event), + shouldResizeFromCenter(event), + selectedElements.length === 1 && isImageElement(selectedElements[0]) + ? !shouldMaintainAspectRatio(event) + : shouldMaintainAspectRatio(event), resizeX, resizeY, pointerDownState.resize.center.x, diff --git a/src/components/Card.scss b/src/components/Card.scss index 4d1309ad..c9e575dd 100644 --- a/src/components/Card.scss +++ b/src/components/Card.scss @@ -48,6 +48,10 @@ .ToolIcon__label { color: $oc-white; } + + .Spinner { + --spinner-color: #fff; + } } } } diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 6a702f96..e84100cb 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -157,6 +157,8 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { shortcuts={["Shift+P", "7"]} /> + + { return t("hints.text"); } + if (appState.elementType === "image" && appState.pendingImageElement) { + return t("hints.placeImage"); + } + const selectedElements = getSelectedElements(elements, appState); if ( isResizing && @@ -40,7 +48,9 @@ const getHints = ({ appState, elements }: Hint) => { if (isLinearElement(targetElement) && targetElement.points.length === 2) { return t("hints.lockAngle"); } - return t("hints.resize"); + return isImageElement(targetElement) + ? t("hints.resizeImage") + : t("hints.resize"); } if (isRotating && lastPointerDownWith === "mouse") { diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index 36c12fd0..213f3841 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -9,7 +9,7 @@ import { t } from "../i18n"; import { useIsMobile } from "./App"; import { getSelectedElements, isSomeElementSelected } from "../scene"; import { exportToCanvas } from "../scene/export"; -import { AppState } from "../types"; +import { AppState, BinaryFiles } from "../types"; import { Dialog } from "./Dialog"; import { clipboard, exportImage } from "./icons"; import Stack from "./Stack"; @@ -79,6 +79,7 @@ const ExportButton: React.FC<{ const ImageExportModal = ({ elements, appState, + files, exportPadding = DEFAULT_EXPORT_PADDING, actionManager, onExportToPng, @@ -87,6 +88,7 @@ const ImageExportModal = ({ }: { appState: AppState; elements: readonly NonDeletedExcalidrawElement[]; + files: BinaryFiles; exportPadding?: number; actionManager: ActionsManagerInterface; onExportToPng: ExportCB; @@ -112,29 +114,25 @@ const ImageExportModal = ({ if (!previewNode) { return; } - try { - const canvas = exportToCanvas(exportedElements, appState, { - exportBackground, - viewBackgroundColor, - exportPadding, - }); - - // if converting to blob fails, there's some problem that will - // likely prevent preview and export (e.g. canvas too big) - canvasToBlob(canvas) - .then(() => { + exportToCanvas(exportedElements, appState, files, { + exportBackground, + viewBackgroundColor, + exportPadding, + }) + .then((canvas) => { + // if converting to blob fails, there's some problem that will + // likely prevent preview and export (e.g. canvas too big) + return canvasToBlob(canvas).then(() => { renderPreview(canvas, previewNode); - }) - .catch((error) => { - console.error(error); - renderPreview(new CanvasError(), previewNode); }); - } catch (error) { - console.error(error); - renderPreview(new CanvasError(), previewNode); - } + }) + .catch((error) => { + console.error(error); + renderPreview(new CanvasError(), previewNode); + }); }, [ appState, + files, exportedElements, exportBackground, exportPadding, @@ -220,6 +218,7 @@ const ImageExportModal = ({ export const ImageExportDialog = ({ elements, appState, + files, exportPadding = DEFAULT_EXPORT_PADDING, actionManager, onExportToPng, @@ -228,6 +227,7 @@ export const ImageExportDialog = ({ }: { appState: AppState; elements: readonly NonDeletedExcalidrawElement[]; + files: BinaryFiles; exportPadding?: number; actionManager: ActionsManagerInterface; onExportToPng: ExportCB; @@ -258,6 +258,7 @@ export const ImageExportDialog = ({ void; @@ -68,12 +70,14 @@ const JSONExportModal = ({ title={t("exportDialog.link_button")} aria-label={t("exportDialog.link_button")} showAriaLabel={true} - onClick={() => onExportToBackend(elements, appState, canvas)} + onClick={() => + onExportToBackend(elements, appState, files, canvas) + } /> )} {exportOpts.renderCustomUI && - exportOpts.renderCustomUI(elements, appState, canvas)} + exportOpts.renderCustomUI(elements, appState, files, canvas)}
); @@ -82,12 +86,14 @@ const JSONExportModal = ({ export const JSONExportDialog = ({ elements, appState, + files, actionManager, exportOpts, canvas, }: { - appState: AppState; elements: readonly NonDeletedExcalidrawElement[]; + appState: AppState; + files: BinaryFiles; actionManager: ActionsManagerInterface; exportOpts: ExportOpts; canvas: HTMLCanvasElement | null; @@ -116,6 +122,7 @@ export const JSONExportDialog = ({ ["setState"]; elements: readonly NonDeletedExcalidrawElement[]; @@ -76,6 +78,7 @@ interface LayerUIProps { focusContainer: () => void; library: Library; id: string; + onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; } const useOnClickOutside = ( @@ -118,6 +121,7 @@ const LibraryMenuItems = ({ libraryReturnUrl, focusContainer, library, + files, id, }: { libraryItems: LibraryItems; @@ -126,6 +130,7 @@ const LibraryMenuItems = ({ onInsertShape: (elements: LibraryItem) => void; onAddToLibrary: (elements: LibraryItem) => void; theme: AppState["theme"]; + files: BinaryFiles; setAppState: React.Component["setState"]; setLibraryItems: (library: LibraryItems) => void; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; @@ -221,6 +226,7 @@ const LibraryMenuItems = ({ void; onAddToLibrary: () => void; theme: AppState["theme"]; + files: BinaryFiles; setAppState: React.Component["setState"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; focusContainer: () => void; @@ -286,12 +294,12 @@ const LibraryMenu = ({ "preloading" | "loading" | "ready" >("preloading"); - const loadingTimerRef = useRef(null); + const loadingTimerRef = useRef(null); useEffect(() => { Promise.race([ new Promise((resolve) => { - loadingTimerRef.current = setTimeout(() => { + loadingTimerRef.current = window.setTimeout(() => { resolve("loading"); }, 100); }), @@ -324,6 +332,12 @@ const LibraryMenu = ({ const addToLibrary = useCallback( async (elements: LibraryItem) => { + if (elements.some((element) => element.type === "image")) { + return setAppState({ + errorMessage: "Support for adding images to the library coming soon!", + }); + } + const items = await library.loadLibrary(); const nextItems = [...items, elements]; onAddToLibrary(); @@ -355,6 +369,7 @@ const LibraryMenu = ({ focusContainer={focusContainer} library={library} theme={theme} + files={files} id={id} /> )} @@ -365,6 +380,7 @@ const LibraryMenu = ({ const LayerUI = ({ actionManager, appState, + files, setAppState, canvas, elements, @@ -384,6 +400,7 @@ const LayerUI = ({ focusContainer, library, id, + onImageAction, }: LayerUIProps) => { const isMobile = useIsMobile(); @@ -396,6 +413,7 @@ const LayerUI = ({ async ( exportedElements, ) => { - const fileHandle = await exportCanvas(type, exportedElements, appState, { - exportBackground: appState.exportBackground, - name: appState.name, - viewBackgroundColor: appState.viewBackgroundColor, - }) + const fileHandle = await exportCanvas( + type, + exportedElements, + appState, + files, + { + exportBackground: appState.exportBackground, + name: appState.name, + viewBackgroundColor: appState.viewBackgroundColor, + }, + ) .catch(muteFSAbortError) .catch((error) => { console.error(error); @@ -435,6 +459,7 @@ const LayerUI = ({ ) : null; @@ -605,6 +631,11 @@ const LayerUI = ({ canvas={canvas} elementType={appState.elementType} setAppState={setAppState} + onImageAction={({ pointerType }) => { + onImageAction({ + insertOnCanvasDirectly: pointerType !== "mouse", + }); + }} /> @@ -765,6 +796,7 @@ const LayerUI = ({ renderCustomFooter={renderCustomFooter} viewModeEnabled={viewModeEnabled} showThemeBtn={showThemeBtn} + onImageAction={onImageAction} renderTopRightUI={renderTopRightUI} /> diff --git a/src/components/LibraryButton.tsx b/src/components/LibraryButton.tsx index 7fca4e92..829d6299 100644 --- a/src/components/LibraryButton.tsx +++ b/src/components/LibraryButton.tsx @@ -26,7 +26,7 @@ export const LibraryButton: React.FC<{ "zen-mode-visibility--hidden": appState.zenModeEnabled, }, )} - title={`${capitalizeString(t("toolBar.library"))} — 9`} + title={`${capitalizeString(t("toolBar.library"))} — 0`} style={{ marginInlineStart: "var(--space-factor)" }} >
{LIBRARY_ICON}
diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 5619e776..3e4a8986 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -6,7 +6,7 @@ import { MIME_TYPES } from "../constants"; import { t } from "../i18n"; import { useIsMobile } from "../components/App"; import { exportToSvg } from "../scene/export"; -import { LibraryItem } from "../types"; +import { BinaryFiles, LibraryItem } from "../types"; import "./LibraryUnit.scss"; // fa-plus @@ -21,44 +21,37 @@ const PLUS_ICON = ( export const LibraryUnit = ({ elements, + files, pendingElements, onRemoveFromLibrary, onClick, }: { elements?: LibraryItem; + files: BinaryFiles; pendingElements?: LibraryItem; onRemoveFromLibrary: () => void; onClick: () => void; }) => { const ref = useRef(null); useEffect(() => { - const elementsToRender = elements || pendingElements; - if (!elementsToRender) { - return; - } - let svg: SVGSVGElement; - const current = ref.current!; - (async () => { - svg = await exportToSvg(elementsToRender, { - exportBackground: false, - viewBackgroundColor: oc.white, - }); - for (const child of ref.current!.children) { - if (child.tagName !== "svg") { - continue; - } - current!.removeChild(child); + const elementsToRender = elements || pendingElements; + if (!elementsToRender) { + return; + } + const svg = await exportToSvg( + elementsToRender, + { + exportBackground: false, + viewBackgroundColor: oc.white, + }, + files, + ); + if (ref.current) { + ref.current.innerHTML = svg.outerHTML; } - current!.appendChild(svg); })(); - - return () => { - if (svg) { - current.removeChild(svg); - } - }; - }, [elements, pendingElements]); + }, [elements, pendingElements, files]); const [isHovered, setIsHovered] = useState(false); const isMobile = useIsMobile(); diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 1c7ecc70..37626b70 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -33,6 +33,7 @@ type MobileMenuProps = { renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element; viewModeEnabled: boolean; showThemeBtn: boolean; + onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; renderTopRightUI?: ( isMobile: boolean, appState: AppState, @@ -54,6 +55,7 @@ export const MobileMenu = ({ renderCustomFooter, viewModeEnabled, showThemeBtn, + onImageAction, renderTopRightUI, }: MobileMenuProps) => { const renderToolbar = () => { @@ -70,6 +72,11 @@ export const MobileMenu = ({ canvas={canvas} elementType={appState.elementType} setAppState={setAppState} + onImageAction={({ pointerType }) => { + onImageAction({ + insertOnCanvasDirectly: pointerType !== "mouse", + }); + }} /> diff --git a/src/components/PasteChartDialog.tsx b/src/components/PasteChartDialog.tsx index 9f42355a..7444534b 100644 --- a/src/components/PasteChartDialog.tsx +++ b/src/components/PasteChartDialog.tsx @@ -38,10 +38,14 @@ const ChartPreviewBtn = (props: { const previewNode = previewRef.current!; (async () => { - svg = await exportToSvg(elements, { - exportBackground: false, - viewBackgroundColor: oc.white, - }); + svg = await exportToSvg( + elements, + { + exportBackground: false, + viewBackgroundColor: oc.white, + }, + null, // files + ); previewNode.appendChild(svg); diff --git a/src/components/Spinner.scss b/src/components/Spinner.scss new file mode 100644 index 00000000..fd6fd50e --- /dev/null +++ b/src/components/Spinner.scss @@ -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; + } + } +} diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx new file mode 100644 index 00000000..c4edb65a --- /dev/null +++ b/src/components/Spinner.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import "./Spinner.scss"; + +const Spinner = ({ + size = "1em", + circleWidth = 8, +}: { + size?: string | number; + circleWidth?: number; +}) => { + return ( +
+ + + +
+ ); +}; + +export default Spinner; diff --git a/src/components/ToolButton.tsx b/src/components/ToolButton.tsx index 745cce28..3e302786 100644 --- a/src/components/ToolButton.tsx +++ b/src/components/ToolButton.tsx @@ -1,8 +1,11 @@ import "./ToolIcon.scss"; -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import clsx from "clsx"; import { useExcalidrawContainer } from "./App"; +import { AbortError } from "../errors"; +import Spinner from "./Spinner"; +import { PointerType } from "../element/types"; export type ToolButtonSize = "small" | "medium"; @@ -28,7 +31,7 @@ type ToolButtonProps = | (ToolButtonBaseProps & { type: "button"; children?: React.ReactNode; - onClick?(): void; + onClick?(event: React.MouseEvent): void; }) | (ToolButtonBaseProps & { type: "icon"; @@ -38,7 +41,7 @@ type ToolButtonProps = | (ToolButtonBaseProps & { type: "radio"; checked: boolean; - onChange?(): void; + onChange?(data: { pointerType: PointerType | null }): void; }); export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { @@ -47,6 +50,38 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { React.useImperativeHandle(ref, () => innerRef.current); const sizeCn = `ToolIcon_size_${props.size}`; + const [isLoading, setIsLoading] = useState(false); + + const isMountedRef = useRef(true); + + const onClick = async (event: React.MouseEvent) => { + const ret = "onClick" in props && props.onClick?.(event); + + if (ret && "then" in ret) { + try { + setIsLoading(true); + await ret; + } catch (error) { + if (!(error instanceof AbortError)) { + throw error; + } + } finally { + if (isMountedRef.current) { + setIsLoading(false); + } + } + } + }; + + useEffect( + () => () => { + isMountedRef.current = false; + }, + [], + ); + + const lastPointerTypeRef = useRef(null); + if (props.type === "button" || props.type === "icon") { return ( @@ -90,7 +128,18 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { } return ( -