From 5daff2d3cd3c049e501cb6bd7cf0810923ba92b3 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 14 Jun 2022 16:27:41 +0530 Subject: [PATCH 01/31] fix: copy arrow head when using copy styles (#5303) * fix: copy arrow head when using copy styles * remove mutations & check against `arrow` type * fix lint Co-authored-by: dwelle --- src/actions/actionStyles.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index 0f4aff99c..084d74c9e 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -6,7 +6,7 @@ import { import { CODES, KEYS } from "../keys"; import { t } from "../i18n"; import { register } from "./register"; -import { mutateElement, newElementWith } from "../element/mutateElement"; +import { newElementWith } from "../element/mutateElement"; import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY, @@ -49,7 +49,7 @@ export const actionPasteStyles = register({ return { elements: elements.map((element) => { if (appState.selectedElementIds[element.id]) { - const newElement = newElementWith(element, { + let newElement = newElementWith(element, { backgroundColor: pastedElement?.backgroundColor, strokeWidth: pastedElement?.strokeWidth, strokeColor: pastedElement?.strokeColor, @@ -58,8 +58,9 @@ export const actionPasteStyles = register({ opacity: pastedElement?.opacity, roughness: pastedElement?.roughness, }); - if (isTextElement(newElement) && isTextElement(element)) { - mutateElement(newElement, { + + if (isTextElement(newElement)) { + newElement = newElementWith(newElement, { fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE, fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY, textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN, @@ -67,6 +68,14 @@ export const actionPasteStyles = register({ redrawTextBoundingBox(newElement, getContainerElement(newElement)); } + + if (newElement.type === "arrow") { + newElement = newElementWith(newElement, { + startArrowhead: pastedElement.startArrowhead, + endArrowhead: pastedElement.endArrowhead, + }); + } + return newElement; } return element; From 6196fba286cc511a3b902bb10362bef20a7c64fd Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 14 Jun 2022 17:56:05 +0530 Subject: [PATCH 02/31] docs: migrate example to typescript (#5243) * docs: migrate example to typescript * fix * fix sidebar * fix --- .../excalidraw/example/{App.js => App.tsx} | 271 +++++++++++------- .../example/{index.js => index.tsx} | 0 .../sidebar/{Sidebar.js => Sidebar.tsx} | 6 +- .../excalidraw/webpack.dev-server.config.js | 2 +- 4 files changed, 173 insertions(+), 106 deletions(-) rename src/packages/excalidraw/example/{App.js => App.tsx} (74%) rename src/packages/excalidraw/example/{index.js => index.tsx} (100%) rename src/packages/excalidraw/example/sidebar/{Sidebar.js => Sidebar.tsx} (82%) diff --git a/src/packages/excalidraw/example/App.js b/src/packages/excalidraw/example/App.tsx similarity index 74% rename from src/packages/excalidraw/example/App.js rename to src/packages/excalidraw/example/App.tsx index 9cbe95c0f..644a596b6 100644 --- a/src/packages/excalidraw/example/App.js +++ b/src/packages/excalidraw/example/App.tsx @@ -1,12 +1,13 @@ import { useEffect, useState, useRef, useCallback } from "react"; -import InitialData from "./initialData"; import Sidebar from "./sidebar/Sidebar"; import "./App.scss"; import initialData from "./initialData"; import { nanoid } from "nanoid"; import { + resolvablePromise, + ResolvablePromise, withBatchedUpdates, withBatchedUpdatesThrottled, } from "../../../utils"; @@ -14,7 +15,42 @@ import { EVENT } from "../../../constants"; import { distance2d } from "../../../math"; import { fileOpen } from "../../../data/filesystem"; import { loadSceneOrLibraryFromBlob } from "../../utils"; +import { + AppState, + BinaryFileData, + ExcalidrawImperativeAPI, + ExcalidrawInitialDataState, + Gesture, + LibraryItems, + PointerDownState as ExcalidrawPointerDownState, +} from "../../../types"; +import { ExcalidrawElement } from "../../../element/types"; +import { ImportedLibraryData } from "../../../data/types"; +declare global { + interface Window { + ExcalidrawLib: any; + } +} + +type Comment = { + x: number; + y: number; + value: string; + id?: string; +}; + +type PointerDownState = { + x: number; + y: number; + hitElement: Comment; + onMove: any; + onUp: any; + hitElementOffsets: { + x: number; + y: number; + }; +}; // This is so that we use the bundled excalidraw.development.js file instead // of the actual source code @@ -28,6 +64,7 @@ const { MIME_TYPES, sceneCoordsToViewportCoords, viewportCoordsToSceneCoords, + restoreElements, } = window.ExcalidrawLib; const COMMENT_SVG = ( @@ -41,7 +78,7 @@ const COMMENT_SVG = ( stroke-width="2" stroke-linecap="round" stroke-linejoin="round" - class="feather feather-message-circle" + className="feather feather-message-circle" > @@ -50,18 +87,6 @@ const COMMENT_ICON_DIMENSION = 32; const COMMENT_INPUT_HEIGHT = 50; const COMMENT_INPUT_WIDTH = 150; -const resolvablePromise = () => { - let resolve; - let reject; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - promise.resolve = resolve; - promise.reject = reject; - return promise; -}; - const renderTopRightUI = () => { return ( - {props.children} + {children} ); diff --git a/src/packages/excalidraw/webpack.dev-server.config.js b/src/packages/excalidraw/webpack.dev-server.config.js index 41c40aeb1..4e8df8992 100644 --- a/src/packages/excalidraw/webpack.dev-server.config.js +++ b/src/packages/excalidraw/webpack.dev-server.config.js @@ -5,7 +5,7 @@ const devConfig = require("./webpack.dev.config"); const devServerConfig = { entry: { - bundle: "./example/index.js", + bundle: "./example/index.tsx", }, // Server Configuration options devServer: { From 84b47a2ed5ede6696e7690b59423ee323cb7d9d9 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 14 Jun 2022 19:42:49 +0530 Subject: [PATCH 03/31] fix: copy bound text style when copying element having bound text (#5305) * fix: copy bound text style when copying element having bound text * fix * fix tests --- src/actions/actionStyles.test.tsx | 2 +- src/actions/actionStyles.ts | 66 ++++++++++++++++++++++--------- src/tests/contextmenu.test.tsx | 4 +- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/actions/actionStyles.test.tsx b/src/actions/actionStyles.test.tsx index 0b324b1a5..c73864cc4 100644 --- a/src/actions/actionStyles.test.tsx +++ b/src/actions/actionStyles.test.tsx @@ -48,7 +48,7 @@ describe("actionStyles", () => { Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => { Keyboard.codeDown(CODES.C); }); - const secondRect = JSON.parse(copiedStyles); + const secondRect = JSON.parse(copiedStyles)[0]; expect(secondRect.id).toBe(h.elements[1].id); mouse.reset(); diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index 084d74c9e..ded60aa11 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -12,7 +12,9 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGN, } from "../constants"; -import { getContainerElement } from "../element/textElement"; +import { getBoundTextElement } from "../element/textElement"; +import { hasBoundTextElement } from "../element/typeChecks"; +import { getSelectedElements } from "../scene"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; @@ -21,9 +23,15 @@ export const actionCopyStyles = register({ name: "copyStyles", trackEvent: { category: "element" }, perform: (elements, appState) => { + const elementsCopied = []; const element = elements.find((el) => appState.selectedElementIds[el.id]); + elementsCopied.push(element); + if (element && hasBoundTextElement(element)) { + const boundTextElement = getBoundTextElement(element); + elementsCopied.push(boundTextElement); + } if (element) { - copiedStyles = JSON.stringify(element); + copiedStyles = JSON.stringify(elementsCopied); } return { appState: { @@ -42,37 +50,59 @@ export const actionPasteStyles = register({ name: "pasteStyles", trackEvent: { category: "element" }, perform: (elements, appState) => { - const pastedElement = JSON.parse(copiedStyles); + const elementsCopied = JSON.parse(copiedStyles); + const pastedElement = elementsCopied[0]; + const boundTextElement = elementsCopied[1]; if (!isExcalidrawElement(pastedElement)) { return { elements, commitToHistory: false }; } + + const selectedElements = getSelectedElements(elements, appState, true); + const selectedElementIds = selectedElements.map((element) => element.id); return { elements: elements.map((element) => { - if (appState.selectedElementIds[element.id]) { + if (selectedElementIds.includes(element.id)) { + let elementStylesToCopyFrom = pastedElement; + if (isTextElement(element) && element.containerId) { + elementStylesToCopyFrom = boundTextElement; + } + if (!elementStylesToCopyFrom) { + return element; + } let newElement = newElementWith(element, { - backgroundColor: pastedElement?.backgroundColor, - strokeWidth: pastedElement?.strokeWidth, - strokeColor: pastedElement?.strokeColor, - strokeStyle: pastedElement?.strokeStyle, - fillStyle: pastedElement?.fillStyle, - opacity: pastedElement?.opacity, - roughness: pastedElement?.roughness, + backgroundColor: elementStylesToCopyFrom?.backgroundColor, + strokeWidth: elementStylesToCopyFrom?.strokeWidth, + strokeColor: elementStylesToCopyFrom?.strokeColor, + strokeStyle: elementStylesToCopyFrom?.strokeStyle, + fillStyle: elementStylesToCopyFrom?.fillStyle, + opacity: elementStylesToCopyFrom?.opacity, + roughness: elementStylesToCopyFrom?.roughness, }); if (isTextElement(newElement)) { newElement = newElementWith(newElement, { - fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE, - fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY, - textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN, + fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE, + fontFamily: + elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY, + textAlign: + elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN, }); - - redrawTextBoundingBox(newElement, getContainerElement(newElement)); + let container = null; + if (newElement.containerId) { + container = + selectedElements.find( + (element) => + isTextElement(newElement) && + element.id === newElement.containerId, + ) || null; + } + redrawTextBoundingBox(newElement, container); } if (newElement.type === "arrow") { newElement = newElementWith(newElement, { - startArrowhead: pastedElement.startArrowhead, - endArrowhead: pastedElement.endArrowhead, + startArrowhead: elementStylesToCopyFrom.startArrowhead, + endArrowhead: elementStylesToCopyFrom.endArrowhead, }); } diff --git a/src/tests/contextmenu.test.tsx b/src/tests/contextmenu.test.tsx index 9cb7267fa..3e1c74479 100644 --- a/src/tests/contextmenu.test.tsx +++ b/src/tests/contextmenu.test.tsx @@ -289,7 +289,7 @@ describe("contextMenu element", () => { expect(copiedStyles).toBe("{}"); fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); expect(copiedStyles).not.toBe("{}"); - const element = JSON.parse(copiedStyles); + const element = JSON.parse(copiedStyles)[0]; expect(element).toEqual(API.getSelectedElement()); }); @@ -329,7 +329,7 @@ describe("contextMenu element", () => { }); let contextMenu = UI.queryContextMenu(); fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); - const secondRect = JSON.parse(copiedStyles); + const secondRect = JSON.parse(copiedStyles)[0]; expect(secondRect.id).toBe(h.elements[1].id); mouse.reset(); From adc1e585ffb9e9e1c8b8440aad9987e17041fb6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:04:01 +0530 Subject: [PATCH 04/31] chore(deps): bump protobufjs from 6.10.2 to 6.11.3 (#5266) Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 6.10.2 to 6.11.3. - [Release notes](https://github.com/protobufjs/protobuf.js/releases) - [Changelog](https://github.com/protobufjs/protobuf.js/blob/v6.11.3/CHANGELOG.md) - [Commits](https://github.com/protobufjs/protobuf.js/compare/v6.10.2...v6.11.3) --- updated-dependencies: - dependency-name: protobufjs dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 65 +++++++++++++++++++++++++------------------------------ 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5f8645cd5..04a6a5e1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1719,56 +1719,56 @@ "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" - resolved "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz" - integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78= + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== "@protobufjs/base64@^1.1.2": version "1.1.2" - resolved "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== "@protobufjs/codegen@^2.0.4": version "2.0.4" - resolved "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== "@protobufjs/eventemitter@^1.1.0": version "1.1.0" - resolved "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz" - integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A= + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== "@protobufjs/fetch@^1.1.0": version "1.1.0" - resolved "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz" - integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU= + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== dependencies: "@protobufjs/aspromise" "^1.1.1" "@protobufjs/inquire" "^1.1.0" "@protobufjs/float@^1.0.2": version "1.0.2" - resolved "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz" - integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E= + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== "@protobufjs/inquire@^1.1.0": version "1.1.0" - resolved "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz" - integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik= + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== "@protobufjs/path@^1.1.2": version "1.1.2" - resolved "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz" - integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0= + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== "@protobufjs/pool@^1.1.0": version "1.1.0" - resolved "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz" - integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q= + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== "@protobufjs/utf8@^1.1.0": version "1.1.0" - resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz" - integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== "@rollup/plugin-node-resolve@^7.1.1": version "7.1.3" @@ -2159,24 +2159,19 @@ integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== "@types/long@^4.0.1": - version "4.0.1" - resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz" - integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" + integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== "@types/minimatch@*": version "3.0.3" resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== -"@types/node@*", "@types/node@>=12.12.47": - version "14.14.37" - resolved "https://registry.npmjs.org/@types/node/-/node-14.14.37.tgz" - integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw== - -"@types/node@^13.7.0": - version "13.13.48" - resolved "https://registry.npmjs.org/@types/node/-/node-13.13.48.tgz" - integrity sha512-z8wvSsgWQzkr4sVuMEEOvwMdOQjiRY2Y/ZW4fDfjfe3+TfQrZqFKOthBgk2RnVEmtOKrkwdZ7uTvsxTBLjKGDQ== +"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0": + version "17.0.38" + resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947" + integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g== "@types/normalize-package-data@^2.4.0": version "2.4.0" @@ -8142,7 +8137,7 @@ loglevel@^1.6.8: long@^4.0.0: version "4.0.0" - resolved "https://registry.npmjs.org/long/-/long-4.0.0.tgz" + resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== loose-envify@^1.1.0, loose-envify@^1.4.0: @@ -10138,9 +10133,9 @@ prop-types@^15.7.2: react-is "^16.8.1" protobufjs@^6.8.6: - version "6.10.2" - resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.10.2.tgz" - integrity sha512-27yj+04uF6ya9l+qfpH187aqEzfCF4+Uit0I9ZBQVqK09hk/SQzKa2MUqUpXaVa7LOFRg1TSSr3lVxGOk6c0SQ== + version "6.11.3" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.3.tgz#637a527205a35caa4f3e2a9a4a13ddffe0e7af74" + integrity sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg== dependencies: "@protobufjs/aspromise" "^1.1.2" "@protobufjs/base64" "^1.1.2" @@ -10153,7 +10148,7 @@ protobufjs@^6.8.6: "@protobufjs/pool" "^1.1.0" "@protobufjs/utf8" "^1.1.0" "@types/long" "^4.0.1" - "@types/node" "^13.7.0" + "@types/node" ">=13.7.0" long "^4.0.0" proxy-addr@~2.0.5: From ddf088e4285028c60cf2b995681854ea5dd3798b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:04:35 +0530 Subject: [PATCH 05/31] chore(deps): bump eventsource from 1.1.0 to 1.1.1 (#5262) Bumps [eventsource](https://github.com/EventSource/eventsource) from 1.1.0 to 1.1.1. - [Release notes](https://github.com/EventSource/eventsource/releases) - [Changelog](https://github.com/EventSource/eventsource/blob/master/HISTORY.md) - [Commits](https://github.com/EventSource/eventsource/compare/v1.1.0...v1.1.1) --- updated-dependencies: - dependency-name: eventsource dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 04a6a5e1e..e12a29e45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5479,9 +5479,9 @@ events@^3.0.0: integrity sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg== eventsource@^1.0.7: - version "1.1.0" - resolved "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz" - integrity sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg== + version "1.1.1" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.1.1.tgz#4544a35a57d7120fba4fa4c86cb4023b2c09df2f" + integrity sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA== dependencies: original "^1.0.0" @@ -8913,7 +8913,7 @@ optionator@^0.9.1: original@^1.0.0: version "1.0.2" - resolved "https://registry.npmjs.org/original/-/original-1.0.2.tgz" + resolved "https://registry.yarnpkg.com/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" integrity sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg== dependencies: url-parse "^1.4.3" @@ -12413,7 +12413,15 @@ url-loader@4.1.1: mime-types "^2.1.27" schema-utils "^3.0.0" -url-parse@^1.4.3, url-parse@^1.4.7: +url-parse@^1.4.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +url-parse@^1.4.7: version "1.5.7" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.7.tgz#00780f60dbdae90181f51ed85fb24109422c932a" integrity sha512-HxWkieX+STA38EDk7CE9MEryFeHCKzgagxlGvsdS7WBImq9Mk+PGwiT56w82WI3aicwJA8REp42Cxo98c8FZMA== From ec35d5db51cfe9bcb8ac5d16c640e05e6df020ba Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 15 Jun 2022 16:09:12 +0530 Subject: [PATCH 06/31] fix: bind text to correct container when nested (#5307) * fix: bind text to correct container when nested * fix tests --- src/components/App.tsx | 7 ++++--- src/element/textWysiwyg.test.tsx | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index e430026c8..8cf5f5225 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2224,12 +2224,13 @@ class App extends React.Component { existingTextElement = selectedElements[0]; } else if (isTextBindableContainer(selectedElements[0], false)) { existingTextElement = getBoundTextElement(selectedElements[0]); + } else { + existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); } + } else { + existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); } - existingTextElement = - existingTextElement ?? this.getTextElementAtPosition(sceneX, sceneY); - // bind to container when shouldBind is true or // clicked on center of container if ( diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index ae7f1341c..de7528bef 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -115,6 +115,9 @@ describe("textWysiwyg", () => { height: textSize, containerId: container.id, }); + mutateElement(container, { + boundElements: [{ type: "text", id: text.id }], + }); h.elements = [container, text]; From 5feacd9a3b8c0f39b6bb225ea2387d05daa2ed61 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Wed, 15 Jun 2022 15:35:57 +0200 Subject: [PATCH 07/31] feat: deduplicate collab avatars based on `id` (#5309) --- src/actions/actionNavigate.tsx | 11 +------ src/actions/types.ts | 3 +- src/components/LayerUI.tsx | 21 +++---------- src/components/MobileMenu.tsx | 19 ++++-------- src/components/UserList.tsx | 44 ++++++++++++++++++++++++---- src/packages/excalidraw/CHANGELOG.md | 2 ++ src/types.ts | 2 ++ 7 files changed, 54 insertions(+), 48 deletions(-) diff --git a/src/actions/actionNavigate.tsx b/src/actions/actionNavigate.tsx index bdf6865d6..26ce7bdbf 100644 --- a/src/actions/actionNavigate.tsx +++ b/src/actions/actionNavigate.tsx @@ -31,16 +31,7 @@ export const actionGoToCollaborator = register({ }; }, PanelComponent: ({ appState, updateData, data }) => { - const clientId: string | undefined = data?.id; - if (!clientId) { - return null; - } - - const collaborator = appState.collaborators.get(clientId); - - if (!collaborator) { - return null; - } + const [clientId, collaborator] = data as [string, Collaborator]; const { background, stroke } = getClientColors(clientId, appState); diff --git a/src/actions/types.ts b/src/actions/types.ts index f9cd9186f..098c69ada 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -6,7 +6,6 @@ import { ExcalidrawProps, BinaryFiles, } from "../types"; -import { ToolButtonSize } from "../components/ToolButton"; export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api"; @@ -119,7 +118,7 @@ export type PanelComponentProps = { appState: AppState; updateData: (formData?: any) => void; appProps: ExcalidrawProps; - data?: Partial<{ id: string; size: ToolButtonSize }>; + data?: Record; }; export interface Action { diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 83715ffdc..f8190a99f 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -25,7 +25,6 @@ import { PasteChartDialog } from "./PasteChartDialog"; import { Section } from "./Section"; import { HelpDialog } from "./HelpDialog"; import Stack from "./Stack"; -import { Tooltip } from "./Tooltip"; import { UserList } from "./UserList"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { JSONExportDialog } from "./JSONExportDialog"; @@ -380,22 +379,10 @@ const LayerUI = ({ }, )} > - - {appState.collaborators.size > 0 && - Array.from(appState.collaborators) - // Collaborator is either not initialized or is actually the current user. - .filter(([_, client]) => Object.keys(client).length !== 0) - .map(([clientId, client]) => ( - - {actionManager.renderAction("goToCollaborator", { - id: clientId, - })} - - ))} - + {renderTopRightUI?.(deviceType.isMobile, appState)} diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index f59fc42fd..e5771e5ab 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -202,20 +202,11 @@ export const MobileMenu = ({ {appState.collaborators.size > 0 && (
{t("labels.collaborators")} - - {Array.from(appState.collaborators) - // Collaborator is either not initialized or is actually the current user. - .filter( - ([_, client]) => Object.keys(client).length !== 0, - ) - .map(([clientId, client]) => ( - - {actionManager.renderAction("goToCollaborator", { - id: clientId, - })} - - ))} - +
)} diff --git a/src/components/UserList.tsx b/src/components/UserList.tsx index 7cd1e1248..b9f7a36a0 100644 --- a/src/components/UserList.tsx +++ b/src/components/UserList.tsx @@ -2,17 +2,51 @@ import "./UserList.scss"; import React from "react"; import clsx from "clsx"; +import { AppState, Collaborator } from "../types"; +import { Tooltip } from "./Tooltip"; +import { ActionManager } from "../actions/manager"; -type UserListProps = { - children: React.ReactNode; +export const UserList: React.FC<{ className?: string; mobile?: boolean; -}; + collaborators: AppState["collaborators"]; + actionManager: ActionManager; +}> = ({ className, mobile, collaborators, actionManager }) => { + const uniqueCollaborators = new Map(); + + collaborators.forEach((collaborator, socketId) => { + uniqueCollaborators.set( + // filter on user id, else fall back on unique socketId + collaborator.id || socketId, + collaborator, + ); + }); + + const avatars = + uniqueCollaborators.size > 0 && + Array.from(uniqueCollaborators) + .filter(([_, client]) => Object.keys(client).length !== 0) + .map(([clientId, collaborator]) => { + const avatarJSX = actionManager.renderAction("goToCollaborator", [ + clientId, + collaborator, + ]); + + return mobile ? ( + + {avatarJSX} + + ) : ( + {avatarJSX} + ); + }); -export const UserList = ({ children, className, mobile }: UserListProps) => { return (
- {children} + {avatars}
); }; diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index f6dc31513..8f5265dbb 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -17,6 +17,8 @@ Please add the latest change on the top under the correct section. #### Features +- Added support for supplying user `id` in the Collaborator object (see `collaborators` in [`updateScene()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#updateScene)), which will be used to deduplicate users when rendering collaborator avatar list. Cursors will still be rendered for every user. [#5309](https://github.com/excalidraw/excalidraw/pull/5309) + - Export API to [set](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#setCursor) and [reset](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#resetCursor) mouse cursor on the canvas [#5215](https://github.com/excalidraw/excalidraw/pull/5215). - Export [`sceneCoordsToViewportCoords`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onPointerDown) and [`viewportCoordsToSceneCoords`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onPointerDown) utilities [#5187](https://github.com/excalidraw/excalidraw/pull/5187). diff --git a/src/types.ts b/src/types.ts index beee25385..b8d59a9a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,8 @@ export type Collaborator = { // The url of the collaborator's avatar, defaults to username intials // if not present avatarUrl?: string; + // user id. If supplied, we'll filter out duplicates when rendering user avatars. + id?: string; }; export type DataURL = string & { _brand: "DataURL" }; From fd48c2cf792ae11f67ded2503620edce419e4598 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Fri, 17 Jun 2022 12:37:11 +0200 Subject: [PATCH 08/31] fix: non-letter shortcuts being swallowed by color picker (#5316) --- src/components/App.tsx | 4 +++- src/components/ColorPicker.tsx | 22 +++++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 8cf5f5225..c5d0124bd 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1694,7 +1694,9 @@ class App extends React.Component { } if ( - (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) || + (isWritableElement(event.target) && + !event[KEYS.CTRL_OR_CMD] && + event.key !== KEYS.ESCAPE) || // case: using arrows to move between buttons (isArrowKey(event.key) && isInputLike(event.target)) ) { diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index c70e81ba9..fd2847523 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -128,7 +128,9 @@ const Picker = ({ }, []); const handleKeyDown = (event: React.KeyboardEvent) => { + let handled = false; if (event.key === KEYS.TAB) { + handled = true; const { activeElement } = document; if (event.shiftKey) { if (activeElement === firstItem.current) { @@ -140,19 +142,19 @@ const Picker = ({ event.preventDefault(); } } else if (isArrowKey(event.key)) { + handled = true; const { activeElement } = document; const isRTL = getLanguage().rtl; let isCustom = false; let index = Array.prototype.indexOf.call( - gallery.current!.querySelector(".color-picker-content--default")! - .children, + gallery.current!.querySelector(".color-picker-content--default") + ?.children, activeElement, ); if (index === -1) { index = Array.prototype.indexOf.call( - gallery.current!.querySelector( - ".color-picker-content--canvas-colors", - )!.children, + gallery.current!.querySelector(".color-picker-content--canvas-colors") + ?.children, activeElement, ); if (index !== -1) { @@ -180,8 +182,11 @@ const Picker = ({ event.preventDefault(); } else if ( keyBindings.includes(event.key.toLowerCase()) && + !event[KEYS.CTRL_OR_CMD] && + !event.altKey && !isWritableElement(event.target) ) { + handled = true; const index = keyBindings.indexOf(event.key.toLowerCase()); const isCustom = index >= MAX_DEFAULT_COLORS; const parentElement = isCustom @@ -196,11 +201,14 @@ const Picker = ({ event.preventDefault(); } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { + handled = true; event.preventDefault(); onClose(); } - event.nativeEvent.stopImmediatePropagation(); - event.stopPropagation(); + if (handled) { + event.nativeEvent.stopImmediatePropagation(); + event.stopPropagation(); + } }; const renderColors = (colors: Array, custom: boolean = false) => { From 4712393b62533156534bedd7047db69cc5b95cea Mon Sep 17 00:00:00 2001 From: David Luzar Date: Sun, 19 Jun 2022 14:13:43 +0200 Subject: [PATCH 09/31] fix: stale `appState.pendingImageElement` (#5322) * fix: stale `appState.pendingImageElement` * unrelated fix for devTools race conditions * snap fix --- src/actions/actionFinalize.tsx | 12 +- src/appState.ts | 4 +- src/components/App.tsx | 36 +++--- src/components/HintViewer.tsx | 2 +- src/excalidraw-app/index.tsx | 13 +-- .../__snapshots__/contextmenu.test.tsx.snap | 34 +++--- .../regressionTests.test.tsx.snap | 104 +++++++++--------- .../packages/__snapshots__/utils.test.ts.snap | 2 +- src/types.ts | 3 +- 9 files changed, 107 insertions(+), 103 deletions(-) diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index 74068793f..72f95f759 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -19,7 +19,7 @@ import { AppState } from "../types"; export const actionFinalize = register({ name: "finalize", trackEvent: false, - perform: (elements, appState, _, { canvas, focusContainer }) => { + perform: (elements, appState, _, { canvas, focusContainer, scene }) => { if (appState.editingLinearElement) { const { elementId, startBindingElement, endBindingElement } = appState.editingLinearElement; @@ -50,8 +50,12 @@ export const actionFinalize = register({ let newElements = elements; - if (appState.pendingImageElement) { - mutateElement(appState.pendingImageElement, { isDeleted: true }, false); + const pendingImageElement = + appState.pendingImageElementId && + scene.getElement(appState.pendingImageElementId); + + if (pendingImageElement) { + mutateElement(pendingImageElement, { isDeleted: true }, false); } if (window.document.activeElement instanceof HTMLElement) { @@ -177,7 +181,7 @@ export const actionFinalize = register({ [multiPointElement.id]: true, } : appState.selectedElementIds, - pendingImageElement: null, + pendingImageElementId: null, }, commitToHistory: appState.activeTool.type === "freedraw", }; diff --git a/src/appState.ts b/src/appState.ts index 58f63a16e..c320ffc1c 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -87,7 +87,7 @@ export const getDefaultAppState = (): Omit< value: 1 as NormalizedZoomValue, }, viewModeEnabled: false, - pendingImageElement: null, + pendingImageElementId: null, showHyperlinkPopup: false, }; }; @@ -177,7 +177,7 @@ const APP_STATE_STORAGE_CONF = (< 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 }, + pendingImageElementId: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false }, }); diff --git a/src/components/App.tsx b/src/components/App.tsx index c5d0124bd..4c36f6027 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -309,7 +309,7 @@ class App extends React.Component { UIOptions: DEFAULT_UI_OPTIONS, }; - private scene: Scene; + public scene: Scene; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; @@ -1141,8 +1141,7 @@ class App extends React.Component { if (isImageElement(element)) { if ( // not placed on canvas yet (but in elements array) - this.state.pendingImageElement && - element.id === this.state.pendingImageElement.id + this.state.pendingImageElementId === element.id ) { return false; } @@ -3002,19 +3001,24 @@ class App extends React.Component { // reset image preview on pointerdown setCursor(this.canvas, CURSOR_TYPE.CROSSHAIR); - if (!this.state.pendingImageElement) { + // retrieve the latest element as the state may be stale + const pendingImageElement = + this.state.pendingImageElementId && + this.scene.getElement(this.state.pendingImageElementId); + + if (!pendingImageElement) { return; } this.setState({ - draggingElement: this.state.pendingImageElement, - editingElement: this.state.pendingImageElement, - pendingImageElement: null, + draggingElement: pendingImageElement, + editingElement: pendingImageElement, + pendingImageElementId: null, multiElement: null, }); const { x, y } = viewportCoordsToSceneCoords(event, this.state); - mutateElement(this.state.pendingImageElement, { + mutateElement(pendingImageElement, { x, y, }); @@ -4330,8 +4334,8 @@ class App extends React.Component { pointerDownState.eventListeners.onKeyUp!, ); - if (this.state.pendingImageElement) { - this.setState({ pendingImageElement: null }); + if (this.state.pendingImageElementId) { + this.setState({ pendingImageElementId: null }); } if (draggingElement?.type === "freedraw") { @@ -4819,7 +4823,7 @@ class App extends React.Component { await cachedImageData.image; } if ( - this.state.pendingImageElement?.id !== imageElement.id && + this.state.pendingImageElementId !== imageElement.id && this.state.draggingElement?.id !== imageElement.id ) { this.initializeImageDimensions(imageElement, true); @@ -4901,7 +4905,7 @@ class App extends React.Component { previewDataURL = canvas.toDataURL(MIME_TYPES.svg) as DataURL; } - if (this.state.pendingImageElement) { + if (this.state.pendingImageElementId) { setCursor(this.canvas, `url(${previewDataURL}) 4 4, auto`); } }; @@ -4942,7 +4946,7 @@ class App extends React.Component { } else { this.setState( { - pendingImageElement: imageElement, + pendingImageElementId: imageElement.id, }, () => { this.insertImageElement( @@ -4961,7 +4965,7 @@ class App extends React.Component { } this.setState( { - pendingImageElement: null, + pendingImageElementId: null, editingElement: null, activeTool: updateActiveTool(this.state, { type: "selection" }), }, @@ -5881,10 +5885,10 @@ if ( elements: { configurable: true, get() { - return this.app.scene.getElementsIncludingDeleted(); + return this.app?.scene.getElementsIncludingDeleted(); }, set(elements: ExcalidrawElement[]) { - return this.app.scene.replaceAllElements(elements); + return this.app?.scene.replaceAllElements(elements); }, }, }); diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index 1809c2af3..ecce71b51 100644 --- a/src/components/HintViewer.tsx +++ b/src/components/HintViewer.tsx @@ -45,7 +45,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { return t("hints.text"); } - if (appState.activeTool.type === "image" && appState.pendingImageElement) { + if (appState.activeTool.type === "image" && appState.pendingImageElementId) { return t("hints.placeImage"); } diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 2f89e3c33..a3f6ce1c5 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -479,19 +479,17 @@ const ExcalidrawWrapper = () => { if (excalidrawAPI) { let didChange = false; - let pendingImageElement = appState.pendingImageElement; const elements = excalidrawAPI .getSceneElementsIncludingDeleted() .map((element) => { if ( LocalData.fileStorage.shouldUpdateImageElementStatus(element) ) { - didChange = true; - const newEl = newElementWith(element, { status: "saved" }); - if (pendingImageElement === element) { - pendingImageElement = newEl; + const newElement = newElementWith(element, { status: "saved" }); + if (newElement !== element) { + didChange = true; } - return newEl; + return newElement; } return element; }); @@ -499,9 +497,6 @@ const ExcalidrawWrapper = () => { if (didChange) { excalidrawAPI.updateScene({ elements, - appState: { - pendingImageElement, - }, }); } } diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 9ffbbad53..156a17f49 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap @@ -55,7 +55,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -228,7 +228,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -405,7 +405,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -743,7 +743,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -1081,7 +1081,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -1258,7 +1258,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -1471,7 +1471,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -1743,7 +1743,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id1": true, }, @@ -2099,7 +2099,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -2899,7 +2899,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -3237,7 +3237,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -3575,7 +3575,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id1": true, }, @@ -3993,7 +3993,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id2": true, @@ -4271,7 +4271,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id2": true, @@ -4630,7 +4630,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -4736,7 +4736,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -4820,7 +4820,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index 3669e4e62..14270d1db 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -55,7 +55,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id1": true, @@ -564,7 +564,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id1": true, @@ -1079,7 +1079,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -1939,7 +1939,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, }, @@ -2160,7 +2160,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id3": true, @@ -2666,7 +2666,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, }, @@ -2942,7 +2942,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -3119,7 +3119,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id2": true, }, @@ -3608,7 +3608,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -3865,7 +3865,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, }, @@ -4086,7 +4086,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id1": true, @@ -4351,7 +4351,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id1": true, }, @@ -4626,7 +4626,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id2": true, }, @@ -5048,7 +5048,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id1": true, @@ -5372,7 +5372,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id1": true, @@ -5671,7 +5671,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, }, @@ -5898,7 +5898,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, }, @@ -6075,7 +6075,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -6576,7 +6576,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id1": true, @@ -6924,7 +6924,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -9163,7 +9163,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id2": true, @@ -9561,7 +9561,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id2": true, @@ -9837,7 +9837,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id2": true, @@ -10074,7 +10074,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id2": true, @@ -10380,7 +10380,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -10557,7 +10557,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -10734,7 +10734,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -10911,7 +10911,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -11118,7 +11118,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -11325,7 +11325,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -11550,7 +11550,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -11757,7 +11757,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -11934,7 +11934,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -12141,7 +12141,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -12318,7 +12318,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -12495,7 +12495,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -12720,7 +12720,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id1": true, @@ -13514,7 +13514,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id3": true, @@ -13790,7 +13790,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": -2.916666666666668, @@ -13898,7 +13898,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -14004,7 +14004,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, }, @@ -14184,7 +14184,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id1": true, @@ -14535,7 +14535,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -14752,7 +14752,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, "id1": true, @@ -15664,7 +15664,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 60, @@ -15770,7 +15770,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, }, @@ -16609,7 +16609,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id1": true, "id2": true, @@ -17056,7 +17056,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object { "id1": true, }, @@ -17355,7 +17355,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 10, @@ -17463,7 +17463,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -18007,7 +18007,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, @@ -18113,7 +18113,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, diff --git a/src/tests/packages/__snapshots__/utils.test.ts.snap b/src/tests/packages/__snapshots__/utils.test.ts.snap index 6d6154e6e..32f519c48 100644 --- a/src/tests/packages/__snapshots__/utils.test.ts.snap +++ b/src/tests/packages/__snapshots__/utils.test.ts.snap @@ -53,7 +53,7 @@ Object { }, "penDetected": false, "penMode": false, - "pendingImageElement": null, + "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, "scrollX": 0, diff --git a/src/types.ts b/src/types.ts index b8d59a9a8..eb7e534c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,7 +176,7 @@ export type AppState = { data: Spreadsheet; }; /** imageElement waiting to be placed on canvas */ - pendingImageElement: NonDeleted | null; + pendingImageElementId: ExcalidrawImageElement["id"] | null; showHyperlinkPopup: false | "info" | "editor"; }; @@ -378,6 +378,7 @@ export type AppClassProperties = { >; files: BinaryFiles; deviceType: App["deviceType"]; + scene: App["scene"]; }; export type PointerDownState = Readonly<{ From cdf352d4c3bcd6a0f4c906d85ca729b54248ea89 Mon Sep 17 00:00:00 2001 From: Ishtiaq Bhatti Date: Tue, 21 Jun 2022 20:03:23 +0500 Subject: [PATCH 10/31] feat: add sidebar for libraries panel (#5274) Co-authored-by: dwelle Co-authored-by: Aakansha Doshi --- src/actions/actionExport.tsx | 6 +- src/actions/manager.tsx | 2 +- src/appState.ts | 4 +- src/components/Actions.tsx | 8 +- src/components/App.tsx | 168 +++++++++----- src/components/ClearCanvas.tsx | 4 +- src/components/CollabButton.tsx | 4 +- src/components/Dialog.tsx | 4 +- src/components/ImageExportDialog.tsx | 4 +- src/components/JSONExportDialog.tsx | 4 +- src/components/LayerUI.scss | 56 ++++- src/components/LayerUI.tsx | 108 +++++---- src/components/LibraryButton.tsx | 17 +- src/components/LibraryMenu.scss | 54 ++++- src/components/LibraryMenu.tsx | 33 ++- src/components/LibraryMenuItems.scss | 19 +- src/components/LibraryMenuItems.tsx | 216 ++++++++++++++---- src/components/LibraryUnit.scss | 6 +- src/components/LibraryUnit.tsx | 4 +- src/components/MobileMenu.tsx | 3 + src/components/Modal.tsx | 12 +- src/components/SidebarLockButton.scss | 22 ++ src/components/SidebarLockButton.tsx | 46 ++++ src/components/Stack.tsx | 2 + src/components/Stats.scss | 1 + src/components/Stats.tsx | 9 +- src/components/Toolbar.scss | 23 +- src/constants.ts | 10 + src/css/styles.scss | 19 +- src/css/variables.module.scss | 24 ++ src/data/restore.ts | 5 + src/locales/en.json | 7 +- src/packages/excalidraw/CHANGELOG.md | 2 + src/packages/excalidraw/README_NEXT.md | 8 +- src/packages/excalidraw/index.tsx | 1 + .../__snapshots__/contextmenu.test.tsx.snap | 17 ++ .../regressionTests.test.tsx.snap | 52 +++++ .../packages/__snapshots__/utils.test.ts.snap | 1 + src/types.ts | 38 +-- 39 files changed, 782 insertions(+), 241 deletions(-) create mode 100644 src/components/SidebarLockButton.scss create mode 100644 src/components/SidebarLockButton.tsx diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index dda8569c1..7ae69db98 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -7,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle"; import { loadFromJSON, saveAsJSON } from "../data"; import { resaveAsImageWithScene } from "../data/resave"; import { t } from "../i18n"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { KEYS } from "../keys"; import { register } from "./register"; import { CheckboxItem } from "../components/CheckboxItem"; @@ -204,7 +204,7 @@ export const actionSaveFileToDisk = register({ icon={saveAs} title={t("buttons.saveAs")} aria-label={t("buttons.saveAs")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} hidden={!nativeFileSystemSupported} onClick={() => updateData(null)} data-testid="save-as-button" @@ -248,7 +248,7 @@ export const actionLoadScene = register({ icon={load} title={t("buttons.load")} aria-label={t("buttons.load")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} onClick={updateData} data-testid="load-button" /> diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 7481d56a3..246bfe7a6 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -30,7 +30,7 @@ const trackAction = ( trackEvent( action.trackEvent.category, action.trackEvent.action || action.name, - `${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`, + `${source} (${app.device.isMobile ? "mobile" : "desktop"})`, ); } } diff --git a/src/appState.ts b/src/appState.ts index c320ffc1c..879d0590f 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -58,6 +58,7 @@ export const getDefaultAppState = (): Omit< gridSize: null, isBindingEnabled: true, isLibraryOpen: false, + isLibraryMenuDocked: false, isLoading: false, isResizing: false, isRotating: false, @@ -146,7 +147,8 @@ const APP_STATE_STORAGE_CONF = (< 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 }, + isLibraryOpen: { browser: true, export: false, server: false }, + isLibraryMenuDocked: { browser: true, 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 }, diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 29eddbe04..897bc09aa 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager"; import { getNonDeletedElements } from "../element"; import { ExcalidrawElement, PointerType } from "../element/types"; import { t } from "../i18n"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { canChangeSharpness, canHaveArrowheads, @@ -52,7 +52,7 @@ export const SelectedShapeActions = ({ isSingleElementBoundContainer = true; } const isEditing = Boolean(appState.editingElement); - const deviceType = useDeviceType(); + const device = useDevice(); const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const showFillIcons = @@ -177,8 +177,8 @@ export const SelectedShapeActions = ({
{t("labels.actions")}
- {!deviceType.isMobile && renderAction("duplicateSelection")} - {!deviceType.isMobile && renderAction("deleteSelectedElements")} + {!device.isMobile && renderAction("duplicateSelection")} + {!device.isMobile && renderAction("deleteSelectedElements")} {renderAction("group")} {renderAction("ungroup")} {showLinkIcon && renderAction("hyperlink")} diff --git a/src/components/App.tsx b/src/components/App.tsx index 4c36f6027..4a861f085 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -64,6 +64,8 @@ import { MQ_MAX_HEIGHT_LANDSCAPE, MQ_MAX_WIDTH_LANDSCAPE, MQ_MAX_WIDTH_PORTRAIT, + MQ_RIGHT_SIDEBAR_MIN_WIDTH, + MQ_SM_MAX_WIDTH, POINTER_BUTTON, SCROLL_TIMEOUT, TAP_TWICE_TIMEOUT, @@ -194,7 +196,7 @@ import { LibraryItems, PointerDownState, SceneData, - DeviceType, + Device, } from "../types"; import { debounce, @@ -220,7 +222,6 @@ import { } from "../utils"; import ContextMenu, { ContextMenuOption } from "./ContextMenu"; import LayerUI from "./LayerUI"; -import { Stats } from "./Stats"; import { Toast } from "./Toast"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { @@ -259,12 +260,14 @@ import { isLocalLink, } from "../element/Hyperlink"; -const defaultDeviceTypeContext: DeviceType = { +const deviceContextInitialValue = { + isSmScreen: false, isMobile: false, isTouchScreen: false, + canDeviceFitSidebar: false, }; -const DeviceTypeContext = React.createContext(defaultDeviceTypeContext); -export const useDeviceType = () => useContext(DeviceTypeContext); +const DeviceContext = React.createContext(deviceContextInitialValue); +export const useDevice = () => useContext(DeviceContext); const ExcalidrawContainerContext = React.createContext<{ container: HTMLDivElement | null; id: string | null; @@ -296,10 +299,7 @@ class App extends React.Component { rc: RoughCanvas | null = null; unmounted: boolean = false; actionManager: ActionManager; - deviceType: DeviceType = { - isMobile: false, - isTouchScreen: false, - }; + device: Device = deviceContextInitialValue; detachIsMobileMqHandler?: () => void; private excalidrawContainerRef = React.createRef(); @@ -353,12 +353,12 @@ class App extends React.Component { width: window.innerWidth, height: window.innerHeight, showHyperlinkPopup: false, + isLibraryMenuDocked: false, }; this.id = nanoid(); this.library = new Library(this); - if (excalidrawRef) { const readyPromise = ("current" in excalidrawRef && excalidrawRef.current?.readyPromise) || @@ -485,7 +485,7 @@ class App extends React.Component {
{ - + { isCollaborating={this.props.isCollaborating} renderTopRightUI={renderTopRightUI} renderCustomFooter={renderFooter} + renderCustomStats={renderCustomStats} viewModeEnabled={viewModeEnabled} showExitZenModeBtn={ typeof this.props?.zenModeEnabled === "undefined" && @@ -548,15 +549,6 @@ class App extends React.Component { onLinkOpen={this.props.onLinkOpen} /> )} - {this.state.showStats && ( - - )} {this.state.toastMessage !== null && ( { /> )}
{this.renderCanvas()}
-
+
); @@ -763,7 +755,12 @@ class App extends React.Component { const scene = restore(initialData, null, null); scene.appState = { ...scene.appState, - isLibraryOpen: this.state.isLibraryOpen, + // we're falling back to current (pre-init) state when deciding + // whether to open the library, to handle a case where we + // update the state outside of initialData (e.g. when loading the app + // with a library install link, which should auto-open the library) + isLibraryOpen: + initialData?.appState?.isLibraryOpen || this.state.isLibraryOpen, activeTool: scene.appState.activeTool.type === "image" ? { ...scene.appState.activeTool, type: "selection" } @@ -794,6 +791,21 @@ class App extends React.Component { }); }; + private refreshDeviceState = (container: HTMLDivElement) => { + const { width, height } = container.getBoundingClientRect(); + const sidebarBreakpoint = + this.props.UIOptions.dockedSidebarBreakpoint != null + ? this.props.UIOptions.dockedSidebarBreakpoint + : MQ_RIGHT_SIDEBAR_MIN_WIDTH; + this.device = updateObject(this.device, { + isSmScreen: width < MQ_SM_MAX_WIDTH, + isMobile: + width < MQ_MAX_WIDTH_PORTRAIT || + (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE), + canDeviceFitSidebar: width > sidebarBreakpoint, + }); + }; + public async componentDidMount() { this.unmounted = false; this.excalidrawContainerValue.container = @@ -835,34 +847,53 @@ class App extends React.Component { this.focusContainer(); } + if ( + this.excalidrawContainerRef.current && + // bounding rects don't work in tests so updating + // the state on init would result in making the test enviro run + // in mobile breakpoint (0 width/height), making everything fail + process.env.NODE_ENV !== "test" + ) { + this.refreshDeviceState(this.excalidrawContainerRef.current); + } + if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) { this.resizeObserver = new ResizeObserver(() => { - // compute isMobile state + // recompute device dimensions state // --------------------------------------------------------------------- - const { width, height } = - this.excalidrawContainerRef.current!.getBoundingClientRect(); - this.deviceType = updateObject(this.deviceType, { - isMobile: - width < MQ_MAX_WIDTH_PORTRAIT || - (height < MQ_MAX_HEIGHT_LANDSCAPE && - width < MQ_MAX_WIDTH_LANDSCAPE), - }); + this.refreshDeviceState(this.excalidrawContainerRef.current!); // refresh offsets // --------------------------------------------------------------------- this.updateDOMRect(); }); this.resizeObserver?.observe(this.excalidrawContainerRef.current); } else if (window.matchMedia) { - const mediaQuery = window.matchMedia( + const mdScreenQuery = window.matchMedia( `(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`, ); + const smScreenQuery = window.matchMedia( + `(max-width: ${MQ_SM_MAX_WIDTH}px)`, + ); + const canDeviceFitSidebarMediaQuery = window.matchMedia( + `(min-width: ${ + // NOTE this won't update if a different breakpoint is supplied + // after mount + this.props.UIOptions.dockedSidebarBreakpoint != null + ? this.props.UIOptions.dockedSidebarBreakpoint + : MQ_RIGHT_SIDEBAR_MIN_WIDTH + }px)`, + ); const handler = () => { - this.deviceType = updateObject(this.deviceType, { - isMobile: mediaQuery.matches, + this.excalidrawContainerRef.current!.getBoundingClientRect(); + this.device = updateObject(this.device, { + isSmScreen: smScreenQuery.matches, + isMobile: mdScreenQuery.matches, + canDeviceFitSidebar: canDeviceFitSidebarMediaQuery.matches, }); }; - mediaQuery.addListener(handler); - this.detachIsMobileMqHandler = () => mediaQuery.removeListener(handler); + mdScreenQuery.addListener(handler); + this.detachIsMobileMqHandler = () => + mdScreenQuery.removeListener(handler); } const searchParams = new URLSearchParams(window.location.search.slice(1)); @@ -1003,6 +1034,14 @@ class App extends React.Component { } componentDidUpdate(prevProps: AppProps, prevState: AppState) { + if ( + this.excalidrawContainerRef.current && + prevProps.UIOptions.dockedSidebarBreakpoint !== + this.props.UIOptions.dockedSidebarBreakpoint + ) { + this.refreshDeviceState(this.excalidrawContainerRef.current); + } + if ( prevState.scrollX !== this.state.scrollX || prevState.scrollY !== this.state.scrollY @@ -1175,7 +1214,7 @@ class App extends React.Component { theme: this.state.theme, imageCache: this.imageCache, isExporting: false, - renderScrollbars: !this.deviceType.isMobile, + renderScrollbars: !this.device.isMobile, }, ); @@ -1453,11 +1492,15 @@ class App extends React.Component { this.scene.replaceAllElements(nextElements); this.history.resumeRecording(); + this.setState( selectGroupsForSelectedElements( { ...this.state, - isLibraryOpen: false, + isLibraryOpen: + this.state.isLibraryOpen && this.device.canDeviceFitSidebar + ? this.state.isLibraryMenuDocked + : false, selectedElementIds: newElements.reduce((map, element) => { if (!isBoundToContainer(element)) { map[element.id] = true; @@ -1529,7 +1572,7 @@ class App extends React.Component { trackEvent( "toolbar", "toggleLock", - `${source} (${this.deviceType.isMobile ? "mobile" : "desktop"})`, + `${source} (${this.device.isMobile ? "mobile" : "desktop"})`, ); } this.setState((prevState) => { @@ -1560,10 +1603,6 @@ class App extends React.Component { this.actionManager.executeAction(actionToggleZenMode); }; - toggleStats = () => { - this.actionManager.executeAction(actionToggleStats); - }; - scrollToContent = ( target: | ExcalidrawElement @@ -1721,7 +1760,16 @@ class App extends React.Component { } if (event.code === CODES.ZERO) { - this.setState({ isLibraryOpen: !this.state.isLibraryOpen }); + const nextState = !this.state.isLibraryOpen; + this.setState({ isLibraryOpen: nextState }); + // track only openings + if (nextState) { + trackEvent( + "library", + "toggleLibrary (open)", + `keyboard (${this.device.isMobile ? "mobile" : "desktop"})`, + ); + } } if (isArrowKey(event.key)) { @@ -1815,7 +1863,7 @@ class App extends React.Component { trackEvent( "toolbar", shape, - `keyboard (${this.deviceType.isMobile ? "mobile" : "desktop"})`, + `keyboard (${this.device.isMobile ? "mobile" : "desktop"})`, ); } this.setActiveTool({ type: shape }); @@ -2440,7 +2488,7 @@ class App extends React.Component { element, this.state, [scenePointer.x, scenePointer.y], - this.deviceType.isMobile, + this.device.isMobile, ) ); }); @@ -2472,7 +2520,7 @@ class App extends React.Component { this.hitLinkElement, this.state, [lastPointerDownCoords.x, lastPointerDownCoords.y], - this.deviceType.isMobile, + this.device.isMobile, ); const lastPointerUpCoords = viewportCoordsToSceneCoords( this.lastPointerUp!, @@ -2482,7 +2530,7 @@ class App extends React.Component { this.hitLinkElement, this.state, [lastPointerUpCoords.x, lastPointerUpCoords.y], - this.deviceType.isMobile, + this.device.isMobile, ); if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { const url = this.hitLinkElement.link; @@ -2921,10 +2969,10 @@ class App extends React.Component { } if ( - !this.deviceType.isTouchScreen && + !this.device.isTouchScreen && ["pen", "touch"].includes(event.pointerType) ) { - this.deviceType = updateObject(this.deviceType, { isTouchScreen: true }); + this.device = updateObject(this.device, { isTouchScreen: true }); } if (isPanning) { @@ -3066,7 +3114,7 @@ class App extends React.Component { event: React.PointerEvent, ) => { this.lastPointerUp = event; - if (this.deviceType.isTouchScreen) { + if (this.device.isTouchScreen) { const scenePointer = viewportCoordsToSceneCoords( { clientX: event.clientX, clientY: event.clientY }, this.state, @@ -3084,7 +3132,7 @@ class App extends React.Component { this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { - this.redirectToLink(event, this.deviceType.isTouchScreen); + this.redirectToLink(event, this.device.isTouchScreen); } this.removePointer(event); @@ -3456,7 +3504,7 @@ class App extends React.Component { pointerDownState.hit.element, this.state, [pointerDownState.origin.x, pointerDownState.origin.y], - this.deviceType.isMobile, + this.device.isMobile, ) ) { return false; @@ -5563,7 +5611,7 @@ class App extends React.Component { } else { ContextMenu.push({ options: [ - this.deviceType.isMobile && + this.device.isMobile && navigator.clipboard && { trackEvent: false, name: "paste", @@ -5575,7 +5623,7 @@ class App extends React.Component { }, contextItemLabel: "labels.paste", }, - this.deviceType.isMobile && navigator.clipboard && separator, + this.device.isMobile && navigator.clipboard && separator, probablySupportsClipboardBlob && elements.length > 0 && actionCopyAsPng, @@ -5620,9 +5668,9 @@ class App extends React.Component { } else { ContextMenu.push({ options: [ - this.deviceType.isMobile && actionCut, - this.deviceType.isMobile && navigator.clipboard && actionCopy, - this.deviceType.isMobile && + this.device.isMobile && actionCut, + this.device.isMobile && navigator.clipboard && actionCopy, + this.device.isMobile && navigator.clipboard && { name: "paste", trackEvent: false, @@ -5634,7 +5682,7 @@ class App extends React.Component { }, contextItemLabel: "labels.paste", }, - this.deviceType.isMobile && separator, + this.device.isMobile && separator, ...options, separator, actionCopyStyles, diff --git a/src/components/ClearCanvas.tsx b/src/components/ClearCanvas.tsx index 6d25a4a1d..ab1cd6704 100644 --- a/src/components/ClearCanvas.tsx +++ b/src/components/ClearCanvas.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { t } from "../i18n"; -import { useDeviceType } from "./App"; +import { useDevice } from "./App"; import { trash } from "./icons"; import { ToolButton } from "./ToolButton"; @@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { icon={trash} title={t("buttons.clearReset")} aria-label={t("buttons.clearReset")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} onClick={toggleDialog} data-testid="clear-canvas-button" /> diff --git a/src/components/CollabButton.tsx b/src/components/CollabButton.tsx index 3e9c370cb..d6544e95b 100644 --- a/src/components/CollabButton.tsx +++ b/src/components/CollabButton.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import { ToolButton } from "./ToolButton"; import { t } from "../i18n"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { users } from "./icons"; import "./CollabButton.scss"; @@ -26,7 +26,7 @@ const CollabButton = ({ type="button" title={t("labels.liveCollaboration")} aria-label={t("labels.liveCollaboration")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} > {collaboratorCount > 0 && (
{collaboratorCount}
diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 6de3f00ba..06615101e 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import React, { useEffect, useState } from "react"; import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { t } from "../i18n"; -import { useExcalidrawContainer, useDeviceType } from "../components/App"; +import { useExcalidrawContainer, useDevice } from "../components/App"; import { KEYS } from "../keys"; import "./Dialog.scss"; import { back, close } from "./icons"; @@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => { onClick={onClose} aria-label={t("buttons.close")} > - {useDeviceType().isMobile ? back : close} + {useDevice().isMobile ? back : close}
{props.children}
diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index ca21362de..bfc7f02c9 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -5,7 +5,7 @@ import { canvasToBlob } from "../data/blob"; import { NonDeletedExcalidrawElement } from "../element/types"; import { CanvasError } from "../errors"; import { t } from "../i18n"; -import { useDeviceType } from "./App"; +import { useDevice } from "./App"; import { getSelectedElements, isSomeElementSelected } from "../scene"; import { exportToCanvas } from "../scene/export"; import { AppState, BinaryFiles } from "../types"; @@ -250,7 +250,7 @@ export const ImageExportDialog = ({ icon={exportImage} type="button" aria-label={t("buttons.exportImage")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} title={t("buttons.exportImage")} /> {modalIsShown && ( diff --git a/src/components/JSONExportDialog.tsx b/src/components/JSONExportDialog.tsx index 5f29360d6..98e0519f3 100644 --- a/src/components/JSONExportDialog.tsx +++ b/src/components/JSONExportDialog.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import { useDeviceType } from "./App"; +import { useDevice } from "./App"; import { AppState, ExportOpts, BinaryFiles } from "../types"; import { Dialog } from "./Dialog"; import { exportFile, exportToFileIcon, link } from "./icons"; @@ -117,7 +117,7 @@ export const JSONExportDialog = ({ icon={exportFile} type="button" aria-label={t("buttons.export")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} title={t("buttons.export")} /> {modalIsShown && ( diff --git a/src/components/LayerUI.scss b/src/components/LayerUI.scss index 5b1ce6d39..92029ceeb 100644 --- a/src/components/LayerUI.scss +++ b/src/components/LayerUI.scss @@ -1,9 +1,63 @@ @import "open-color/open-color"; +@import "../css/variables.module"; + +.layer-ui__sidebar { + position: absolute; + top: var(--sat); + bottom: var(--sab); + right: var(--sar); + z-index: 5; + + box-shadow: var(--shadow-island); + overflow: hidden; + border-radius: var(--border-radius-lg); + margin: var(--space-factor); + width: calc(#{$right-sidebar-width} - var(--space-factor) * 2); + + .Island { + box-shadow: none; + } + + .ToolIcon__icon { + border-radius: var(--border-radius-md); + } + + .ToolIcon__icon__close { + .Modal__close { + width: calc(var(--space-factor) * 7); + height: calc(var(--space-factor) * 7); + display: flex; + justify-content: center; + align-items: center; + color: var(--color-text); + } + } + + .Island { + --padding: 0; + background-color: var(--island-bg-color); + border-radius: var(--border-radius-lg); + padding: calc(var(--padding) * var(--space-factor)); + position: relative; + transition: box-shadow 0.5s ease-in-out; + } +} .excalidraw { + .layer-ui__wrapper.animate { + transition: width 0.1s ease-in-out; + } .layer-ui__wrapper { + // when the rightside sidebar is docked, we need to resize the UI by its + // width, making the nested UI content shift to the left. To do this, + // we need the UI container to actually have dimensions set, but + // then we also need to disable pointer events else the canvas below + // wouldn't be interactive. + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; z-index: var(--zIndex-layerUI); - &__top-right { display: flex; } diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index f8190a99f..72a6b0e13 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import React, { useCallback } from "react"; import { ActionManager } from "../actions/manager"; -import { CLASSES } from "../constants"; +import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants"; import { exportCanvas } from "../data"; import { isTextElement, showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; @@ -36,7 +36,9 @@ import "./LayerUI.scss"; import "./Toolbar.scss"; import { PenModeButton } from "./PenModeButton"; import { trackEvent } from "../analytics"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; +import { Stats } from "./Stats"; +import { actionToggleStats } from "../actions/actionToggleStats"; interface LayerUIProps { actionManager: ActionManager; @@ -55,14 +57,9 @@ interface LayerUIProps { toggleZenMode: () => void; langCode: Language["code"]; isCollaborating: boolean; - renderTopRightUI?: ( - isMobile: boolean, - appState: AppState, - ) => JSX.Element | null; - renderCustomFooter?: ( - isMobile: boolean, - appState: AppState, - ) => JSX.Element | null; + renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; + renderCustomFooter?: ExcalidrawProps["renderFooter"]; + renderCustomStats?: ExcalidrawProps["renderCustomStats"]; viewModeEnabled: boolean; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; UIOptions: AppProps["UIOptions"]; @@ -71,7 +68,6 @@ interface LayerUIProps { id: string; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; } - const LayerUI = ({ actionManager, appState, @@ -90,6 +86,7 @@ const LayerUI = ({ isCollaborating, renderTopRightUI, renderCustomFooter, + renderCustomStats, viewModeEnabled, libraryReturnUrl, UIOptions, @@ -98,7 +95,7 @@ const LayerUI = ({ id, onImageAction, }: LayerUIProps) => { - const deviceType = useDeviceType(); + const device = useDevice(); const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { @@ -344,7 +341,7 @@ const LayerUI = ({ {heading} @@ -366,7 +363,6 @@ const LayerUI = ({ setAppState={setAppState} /> - {libraryMenu} )} @@ -383,7 +379,7 @@ const LayerUI = ({ collaborators={appState.collaborators} actionManager={actionManager} /> - {renderTopRightUI?.(deviceType.isMobile, appState)} + {renderTopRightUI?.(device.isMobile, appState)}
@@ -436,7 +432,7 @@ const LayerUI = ({ )} {!viewModeEnabled && appState.multiElement && - deviceType.isTouchScreen && ( + device.isTouchScreen && (
); - return deviceType.isMobile ? ( + const renderStats = () => { + if (!appState.showStats) { + return null; + } + return ( + { + actionManager.executeAction(actionToggleStats); + }} + renderCustomStats={renderCustomStats} + /> + ); + }; + + return device.isMobile ? ( <> {dialogs} ) : ( -
- {dialogs} - {renderFixedSideContainer()} - {renderBottomAppMenu()} - {appState.scrolledOutside && ( - + <> +
+ {dialogs} + {renderFixedSideContainer()} + {renderBottomAppMenu()} + {renderStats()} + {appState.scrolledOutside && ( + + )} +
+ {appState.isLibraryOpen && ( +
{libraryMenu}
)} -
+ ); }; diff --git a/src/components/LibraryButton.tsx b/src/components/LibraryButton.tsx index f6c398b2a..9b15d3e4c 100644 --- a/src/components/LibraryButton.tsx +++ b/src/components/LibraryButton.tsx @@ -3,6 +3,8 @@ import clsx from "clsx"; import { t } from "../i18n"; import { AppState } from "../types"; import { capitalizeString } from "../utils"; +import { trackEvent } from "../analytics"; +import { useDevice } from "./App"; const LIBRARY_ICON = ( @@ -18,6 +20,7 @@ export const LibraryButton: React.FC<{ setAppState: React.Component["setState"]; isMobile?: boolean; }> = ({ appState, setAppState, isMobile }) => { + const device = useDevice(); return (
- {(!itemsSelected || !isMobile) && ( + {!itemsSelected && ( - {!isMobile && } + {!device.isMobile && } {selectedItems.length > 0 && ( {selectedItems.length} @@ -195,11 +198,25 @@ const LibraryMenuItems = ({ )} + {device.isMobile && ( + + )}
); }; - const CELLS_PER_ROW = isMobile ? 4 : 6; + const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4; const referrer = libraryReturnUrl || window.location.origin + window.location.pathname; @@ -356,48 +373,169 @@ const LibraryMenuItems = ({ (item) => item.status === "published", ); - return ( -
- {showRemoveLibAlert && renderRemoveLibAlert()} -
- {renderLibraryActions()} - {isLoading ? ( - - ) : ( - - {t("labels.libraries")} - - )} -
+ const renderLibraryHeader = () => { + return ( + <> +
+ {renderLibraryActions()} + {device.canDeviceFitSidebar && ( + <> +
+ { + document + .querySelector(".layer-ui__wrapper") + ?.classList.add("animate"); + const nextState = !appState.isLibraryMenuDocked; + setAppState({ + isLibraryMenuDocked: nextState, + }); + trackEvent( + "library", + `toggleLibraryDock (${nextState ? "dock" : "undock"})`, + `sidebar (${device.isMobile ? "mobile" : "desktop"})`, + ); + }} + /> +
+ + )} + {!device.isMobile && ( +
+ +
+ )} +
+ + ); + }; + + const renderLibraryMenuItems = () => { + return ( 0 ? 1 : "0 0 auto", + marginBottom: 0, + }} > <> -
{t("labels.personalLib")}
- {renderLibrarySection([ - // append pending library item - ...(pendingElements.length - ? [{ id: null, elements: pendingElements }] - : []), - ...unpublishedItems, - ])} +
+ {(pendingElements.length > 0 || + unpublishedItems.length > 0 || + publishedItems.length > 0) && ( +
{t("labels.personalLib")}
+ )} + {isLoading && ( +
+
+ +
+
+ )} +
+ {!pendingElements.length && !unpublishedItems.length ? ( +
+ No items yet! +
+ {publishedItems.length > 0 + ? t("library.hint_emptyPrivateLibrary") + : t("library.hint_emptyLibrary")} +
+
+ ) : ( + renderLibrarySection([ + // append pending library item + ...(pendingElements.length + ? [{ id: null, elements: pendingElements }] + : []), + ...unpublishedItems, + ]) + )} <> -
{t("labels.excalidrawLib")}
- - {renderLibrarySection(publishedItems)} + {(publishedItems.length > 0 || + (!device.isMobile && + (pendingElements.length > 0 || unpublishedItems.length > 0))) && ( +
{t("labels.excalidrawLib")}
+ )} + {publishedItems.length > 0 && renderLibrarySection(publishedItems)}
+ ); + }; + + const renderLibraryFooter = () => { + return ( + + {t("labels.libraries")} + + ); + }; + + return ( +
+ {showRemoveLibAlert && renderRemoveLibAlert()} + {renderLibraryHeader()} + {renderLibraryMenuItems()} + {!device.isMobile && renderLibraryFooter()}
); }; diff --git a/src/components/LibraryUnit.scss b/src/components/LibraryUnit.scss index dca390509..e0c00814f 100644 --- a/src/components/LibraryUnit.scss +++ b/src/components/LibraryUnit.scss @@ -3,7 +3,7 @@ .excalidraw { .library-unit { align-items: center; - border: 1px solid var(--button-gray-2); + border: 1px solid transparent; display: flex; justify-content: center; position: relative; @@ -21,10 +21,6 @@ } } - &.theme--dark .library-unit { - border-color: rgb(48, 48, 48); - } - .library-unit__dragger { display: flex; align-items: center; diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 46f8d11be..54bb6ff41 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import oc from "open-color"; import { useEffect, useRef, useState } from "react"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { exportToSvg } from "../scene/export"; import { BinaryFiles, LibraryItem } from "../types"; import "./LibraryUnit.scss"; @@ -67,7 +67,7 @@ export const LibraryUnit = ({ }, [elements, files]); const [isHovered, setIsHovered] = useState(false); - const isMobile = useDeviceType().isMobile; + const isMobile = useDevice().isMobile; const adder = isPending && (
{PLUS_ICON}
); diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index e5771e5ab..ea45b393e 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -43,6 +43,7 @@ type MobileMenuProps = { isMobile: boolean, appState: AppState, ) => JSX.Element | null; + renderStats: () => JSX.Element | null; }; export const MobileMenu = ({ @@ -63,6 +64,7 @@ export const MobileMenu = ({ showThemeBtn, onImageAction, renderTopRightUI, + renderStats, }: MobileMenuProps) => { const renderToolbar = () => { return ( @@ -184,6 +186,7 @@ export const MobileMenu = ({ return ( <> {!viewModeEnabled && renderToolbar()} + {renderStats()}
{ const [div, setDiv] = useState(null); - const deviceType = useDeviceType(); - const isMobileRef = useRef(deviceType.isMobile); - isMobileRef.current = deviceType.isMobile; + const device = useDevice(); + const isMobileRef = useRef(device.isMobile); + isMobileRef.current = device.isMobile; const { container: excalidrawContainer } = useExcalidrawContainer(); useLayoutEffect(() => { if (div) { - div.classList.toggle("excalidraw--mobile", deviceType.isMobile); + div.classList.toggle("excalidraw--mobile", device.isMobile); } - }, [div, deviceType.isMobile]); + }, [div, device.isMobile]); useLayoutEffect(() => { const isDarkTheme = diff --git a/src/components/SidebarLockButton.scss b/src/components/SidebarLockButton.scss new file mode 100644 index 000000000..0e6799a38 --- /dev/null +++ b/src/components/SidebarLockButton.scss @@ -0,0 +1,22 @@ +@import "../css/variables.module"; + +.excalidraw { + .layer-ui__sidebar-lock-button { + @include toolbarButtonColorStates; + margin-right: 0.2rem; + } + .ToolIcon_type_floating .side_lock_icon { + width: calc(var(--space-factor) * 7); + height: calc(var(--space-factor) * 7); + svg { + // mirror + transform: scale(-1, 1); + } + } + + .ToolIcon_type_checkbox { + &:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon { + background-color: var(--color-primary); + } + } +} diff --git a/src/components/SidebarLockButton.tsx b/src/components/SidebarLockButton.tsx new file mode 100644 index 000000000..2730c9825 --- /dev/null +++ b/src/components/SidebarLockButton.tsx @@ -0,0 +1,46 @@ +import "./ToolIcon.scss"; + +import React from "react"; +import clsx from "clsx"; +import { ToolButtonSize } from "./ToolButton"; +import { t } from "../i18n"; +import { Tooltip } from "./Tooltip"; + +import "./SidebarLockButton.scss"; + +type SidebarLockIconProps = { + checked: boolean; + onChange?(): void; +}; + +const DEFAULT_SIZE: ToolButtonSize = "medium"; + +const SIDE_LIBRARY_TOGGLE_ICON = ( + + + +); + +export const SidebarLockButton = (props: SidebarLockIconProps) => { + return ( + + {" "} + + ); +}; diff --git a/src/components/Stack.tsx b/src/components/Stack.tsx index cf937493f..aa18e8998 100644 --- a/src/components/Stack.tsx +++ b/src/components/Stack.tsx @@ -41,6 +41,7 @@ const ColStack = ({ align, justifyContent, className, + style, }: StackProps) => { return (
{children} diff --git a/src/components/Stats.scss b/src/components/Stats.scss index 72acd2662..0a2f6b62d 100644 --- a/src/components/Stats.scss +++ b/src/components/Stats.scss @@ -7,6 +7,7 @@ right: 12px; font-size: 12px; z-index: 10; + pointer-events: all; h3 { margin: 0 24px 8px 0; diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index 61b2df098..b3f2816c9 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -2,7 +2,7 @@ import React from "react"; import { getCommonBounds } from "../element/bounds"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { getTargetElements } from "../scene"; import { AppState, ExcalidrawProps } from "../types"; import { close } from "./icons"; @@ -16,16 +16,13 @@ export const Stats = (props: { onClose: () => void; renderCustomStats: ExcalidrawProps["renderCustomStats"]; }) => { - const deviceType = useDeviceType(); - + const device = useDevice(); const boundingBox = getCommonBounds(props.elements); const selectedElements = getTargetElements(props.elements, props.appState); const selectedBoundingBox = getCommonBounds(selectedElements); - - if (deviceType.isMobile && props.appState.openMenu) { + if (device.isMobile && props.appState.openMenu) { return null; } - return (
diff --git a/src/components/Toolbar.scss b/src/components/Toolbar.scss index fb2a32b17..e6831b45b 100644 --- a/src/components/Toolbar.scss +++ b/src/components/Toolbar.scss @@ -1,26 +1,5 @@ @import "open-color/open-color.scss"; - -@mixin toolbarButtonColorStates { - .ToolIcon_type_radio, - .ToolIcon_type_checkbox { - & + .ToolIcon__icon:active { - background: var(--color-primary-light); - } - &:checked + .ToolIcon__icon { - background: var(--color-primary); - --icon-fill-color: #{$oc-white}; - --keybinding-color: #{$oc-white}; - } - &:checked + .ToolIcon__icon:active { - background: var(--color-primary-darker); - } - } - - .ToolIcon__keybinding { - bottom: 4px; - right: 4px; - } -} +@import "../css/variables.module"; .excalidraw { .App-toolbar-container { diff --git a/src/constants.ts b/src/constants.ts index afb7ecf3e..9bbacd8f7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -155,9 +155,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { }, }; +// breakpoints +// ----------------------------------------------------------------------------- +// sm screen +export const MQ_SM_MAX_WIDTH = 640; +// md screen export const MQ_MAX_WIDTH_PORTRAIT = 730; export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_HEIGHT_LANDSCAPE = 500; +// sidebar +export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229; +// ----------------------------------------------------------------------------- + +export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth); export const MAX_DECIMALS_FOR_SVG_EXPORT = 2; diff --git a/src/css/styles.scss b/src/css/styles.scss index 0de93901b..920f7e7ae 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -350,7 +350,6 @@ align-items: flex-start; cursor: default; pointer-events: none !important; - z-index: 100; :root[dir="ltr"] & { left: 0.25rem; @@ -391,6 +390,7 @@ .App-menu__left { overflow-y: auto; + box-shadow: var(--shadow-island); } .dropdown-select { @@ -449,6 +449,7 @@ bottom: 30px; transform: translateX(-50%); padding: 10px 20px; + pointer-events: all; } .help-icon { @@ -567,6 +568,22 @@ display: none; } } + + // use custom, minimalistic scrollbar + // (doesn't work in Firefox) + ::-webkit-scrollbar { + width: 5px; + } + ::-webkit-scrollbar-thumb { + background: var(--button-gray-2); + border-radius: 10px; + } + ::-webkit-scrollbar-thumb:hover { + background: var(--button-gray-3); + } + ::-webkit-scrollbar-thumb:active { + background: var(--button-gray-2); + } } .ErrorSplash.excalidraw { diff --git a/src/css/variables.module.scss b/src/css/variables.module.scss index 0d2c37f99..4c90fd136 100644 --- a/src/css/variables.module.scss +++ b/src/css/variables.module.scss @@ -6,8 +6,32 @@ } } +@mixin toolbarButtonColorStates { + .ToolIcon_type_radio, + .ToolIcon_type_checkbox { + & + .ToolIcon__icon:active { + background: var(--color-primary-light); + } + &:checked + .ToolIcon__icon { + background: var(--color-primary); + --icon-fill-color: #{$oc-white}; + --keybinding-color: #{$oc-white}; + } + &:checked + .ToolIcon__icon:active { + background: var(--color-primary-darker); + } + } + + .ToolIcon__keybinding { + bottom: 4px; + right: 4px; + } +} + $theme-filter: "invert(93%) hue-rotate(180deg)"; +$right-sidebar-width: "302px"; :export { themeFilter: unquote($theme-filter); + rightSidebarWidth: unquote($right-sidebar-width); } diff --git a/src/data/restore.ts b/src/data/restore.ts index 8b5ba4336..24bfca79f 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -283,6 +283,11 @@ export const restoreAppState = ( value: appState.zoom as NormalizedZoomValue, } : appState.zoom || defaultAppState.zoom, + // when sidebar docked and user left it open in last session, + // keep it open. If not docked, keep it closed irrespective of last state. + isLibraryOpen: nextAppState.isLibraryMenuDocked + ? nextAppState.isLibraryOpen + : false, }; }; diff --git a/src/locales/en.json b/src/locales/en.json index f6d887cf5..b20f6a4e1 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -120,7 +120,12 @@ "lockAll": "Lock all", "unlockAll": "Unlock all" }, - "statusPublished": "Published" + "statusPublished": "Published", + "sidebarLock": "Keep sidebar open" + }, + "library": { + "hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.", + "hint_emptyPrivateLibrary": "Select an item on canvas to add it here." }, "buttons": { "clearReset": "Reset the canvas", diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 8f5265dbb..c5030800d 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -17,6 +17,8 @@ Please add the latest change on the top under the correct section. #### Features +- Add `[UIOptions.dockedSidebarBreakpoint]`(https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#dockedSidebarBreakpoint) to customize at which point to break from the docked sidebar [#5274](https://github.com/excalidraw/excalidraw/pull/5274). + - Added support for supplying user `id` in the Collaborator object (see `collaborators` in [`updateScene()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#updateScene)), which will be used to deduplicate users when rendering collaborator avatar list. Cursors will still be rendered for every user. [#5309](https://github.com/excalidraw/excalidraw/pull/5309) - Export API to [set](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#setCursor) and [reset](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#resetCursor) mouse cursor on the canvas [#5215](https://github.com/excalidraw/excalidraw/pull/5215). diff --git a/src/packages/excalidraw/README_NEXT.md b/src/packages/excalidraw/README_NEXT.md index 1cb7e617d..d01d12803 100644 --- a/src/packages/excalidraw/README_NEXT.md +++ b/src/packages/excalidraw/README_NEXT.md @@ -639,7 +639,7 @@ This prop sets the name of the drawing which will be used when exporting the dra #### `UIOptions` -This prop can be used to customise UI of Excalidraw. Currently we support customising only [`canvasActions`](#canvasActions). It accepts the below parameters +This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasActions) and [`dockedSidebarBreakpoint`](dockedSidebarBreakpoint). It accepts the below parameters
 { canvasActions:  CanvasActions }
@@ -657,6 +657,12 @@ This prop can be used to customise UI of Excalidraw. Currently we support custom
 | `theme` | boolean | true | Implies whether to show `Theme toggle` |
 | `saveAsImage` | boolean | true | Implies whether to show `Save as image button` |
 
+##### `dockedSidebarBreakpoint`
+
+This prop indicates at what point should we break to a docked, permanent sidebar. If not passed it defaults to [`MQ_RIGHT_SIDEBAR_MAX_WIDTH_PORTRAIT`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L167). If the `width` of the `excalidraw` container exceeds `dockedSidebarBreakpoint`, the sidebar will be dockable. If user choses to dock the sidebar, it will push the right part of the UI towards the left, making space for the sidebar as shown below.
+
+![image](https://user-images.githubusercontent.com/11256141/174664866-c698c3fa-197b-43ff-956c-d79852c7b326.png)
+
 #### `exportOpts`
 
 The below attributes can be set in `UIOptions.canvasActions.export` to customize the export dialog. If `UIOptions.canvasActions.export` is `false` the export button will not be rendered.
diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx
index 936effdb2..fc567a5cb 100644
--- a/src/packages/excalidraw/index.tsx
+++ b/src/packages/excalidraw/index.tsx
@@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
   const canvasActions = props.UIOptions?.canvasActions;
 
   const UIOptions: AppProps["UIOptions"] = {
+    ...props.UIOptions,
     canvasActions: {
       ...DEFAULT_UI_OPTIONS.canvasActions,
       ...canvasActions,
diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap
index 156a17f49..72c4defdf 100644
--- a/src/tests/__snapshots__/contextmenu.test.tsx.snap
+++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap
@@ -38,6 +38,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -211,6 +212,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -388,6 +390,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -726,6 +729,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1064,6 +1068,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1241,6 +1246,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1454,6 +1460,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1726,6 +1733,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2082,6 +2090,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2882,6 +2891,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3220,6 +3230,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3558,6 +3569,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3976,6 +3988,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4254,6 +4267,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4613,6 +4627,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4719,6 +4734,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4803,6 +4819,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap
index 14270d1db..9716320cb 100644
--- a/src/tests/__snapshots__/regressionTests.test.tsx.snap
+++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap
@@ -38,6 +38,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -547,6 +548,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1062,6 +1064,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": false,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1922,6 +1925,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2143,6 +2147,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2649,6 +2654,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2925,6 +2931,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3102,6 +3109,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3591,6 +3599,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3848,6 +3857,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4069,6 +4079,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4334,6 +4345,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4609,6 +4621,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -5031,6 +5044,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -5355,6 +5369,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -5654,6 +5669,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -5881,6 +5897,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -6058,6 +6075,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -6559,6 +6577,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -6907,6 +6926,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -9146,6 +9166,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -9544,6 +9565,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -9820,6 +9842,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10057,6 +10080,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10363,6 +10387,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10540,6 +10565,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10717,6 +10743,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10894,6 +10921,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11101,6 +11129,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11308,6 +11337,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11533,6 +11563,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11740,6 +11771,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11917,6 +11949,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -12124,6 +12157,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -12301,6 +12335,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -12478,6 +12513,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -12703,6 +12739,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -13497,6 +13534,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -13773,6 +13811,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -13881,6 +13920,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -13987,6 +14027,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -14167,6 +14208,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -14518,6 +14560,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -14735,6 +14778,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -15647,6 +15691,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -15753,6 +15798,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -16592,6 +16638,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -17039,6 +17086,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -17338,6 +17386,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -17446,6 +17495,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -17990,6 +18040,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -18096,6 +18147,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
diff --git a/src/tests/packages/__snapshots__/utils.test.ts.snap b/src/tests/packages/__snapshots__/utils.test.ts.snap
index 32f519c48..ebc6b9b24 100644
--- a/src/tests/packages/__snapshots__/utils.test.ts.snap
+++ b/src/tests/packages/__snapshots__/utils.test.ts.snap
@@ -38,6 +38,7 @@ Object {
   "fileHandle": null,
   "gridSize": null,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
diff --git a/src/types.ts b/src/types.ts
index eb7e534c0..21e4b164d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -162,6 +162,7 @@ export type AppState = {
   offsetLeft: number;
 
   isLibraryOpen: boolean;
+  isLibraryMenuDocked: boolean;
   fileHandle: FileSystemHandle | null;
   collaborators: Map;
   showStats: boolean;
@@ -291,7 +292,10 @@ export interface ExcalidrawProps {
     elements: readonly NonDeletedExcalidrawElement[],
     appState: AppState,
   ) => JSX.Element;
-  UIOptions?: UIOptions;
+  UIOptions?: {
+    dockedSidebarBreakpoint?: number;
+    canvasActions?: CanvasActions;
+  };
   detectScroll?: boolean;
   handleKeyboardGlobally?: boolean;
   onLibraryChange?: (libraryItems: LibraryItems) => void | Promise;
@@ -349,18 +353,18 @@ type CanvasActions = {
   saveAsImage?: boolean;
 };
 
-export type UIOptions = {
-  canvasActions?: CanvasActions;
-};
-
-export type AppProps = ExcalidrawProps & {
-  UIOptions: {
-    canvasActions: Required & { export: ExportOpts };
-  };
-  detectScroll: boolean;
-  handleKeyboardGlobally: boolean;
-  isCollaborating: boolean;
-};
+export type AppProps = Merge<
+  ExcalidrawProps,
+  {
+    UIOptions: {
+      canvasActions: Required & { export: ExportOpts };
+      dockedSidebarBreakpoint?: number;
+    };
+    detectScroll: boolean;
+    handleKeyboardGlobally: boolean;
+    isCollaborating: 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. */
@@ -377,7 +381,7 @@ export type AppClassProperties = {
     }
   >;
   files: BinaryFiles;
-  deviceType: App["deviceType"];
+  device: App["device"];
   scene: App["scene"];
 };
 
@@ -473,7 +477,9 @@ export type ExcalidrawImperativeAPI = {
   resetCursor: InstanceType["resetCursor"];
 };
 
-export type DeviceType = {
+export type Device = Readonly<{
+  isSmScreen: boolean;
   isMobile: boolean;
   isTouchScreen: boolean;
-};
+  canDeviceFitSidebar: boolean;
+}>;

From de95c68d7571df3c20ea6eb140f70721dcb2d939 Mon Sep 17 00:00:00 2001
From: Aakansha Doshi 
Date: Wed, 22 Jun 2022 01:33:08 +0530
Subject: [PATCH 11/31] fix: remove unnecessary options passed to language
 detector (#5336)

---
 src/excalidraw-app/index.tsx | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)

diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx
index a3f6ce1c5..c67e48bce 100644
--- a/src/excalidraw-app/index.tsx
+++ b/src/excalidraw-app/index.tsx
@@ -18,7 +18,7 @@ import {
   NonDeletedExcalidrawElement,
 } from "../element/types";
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
-import { Language, t } from "../i18n";
+import { t } from "../i18n";
 import {
   Excalidraw,
   defaultLang,
@@ -80,11 +80,7 @@ const isExcalidrawPlusSignedUser = document.cookie.includes(
 
 const languageDetector = new LanguageDetector();
 languageDetector.init({
-  languageUtils: {
-    formatLanguageCode: (langCode: Language["code"]) => langCode,
-    isWhitelisted: () => true,
-  },
-  checkWhitelist: false,
+  languageUtils: {},
 });
 
 const initializeScene = async (opts: {

From d34c2a75db151b6511dc2d583019e187d43e4811 Mon Sep 17 00:00:00 2001
From: Aakansha Doshi 
Date: Wed, 22 Jun 2022 18:10:57 +0530
Subject: [PATCH 12/31] fix: command to trigger release (#5347)

---
 .github/workflows/autorelease-preview.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/autorelease-preview.yml b/.github/workflows/autorelease-preview.yml
index 65c6a09eb..581cd5887 100644
--- a/.github/workflows/autorelease-preview.yml
+++ b/.github/workflows/autorelease-preview.yml
@@ -6,7 +6,7 @@ on:
 jobs:
   Auto-release-excalidraw-preview:
     name: Auto release preview
-    if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
+    if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
     runs-on: ubuntu-latest
     steps:
       - name: React to release comment

From 39d17c4a3ca371fe8515a1d21cdc95de00d146cb Mon Sep 17 00:00:00 2001
From: Aakansha Doshi 
Date: Wed, 22 Jun 2022 22:06:29 +0530
Subject: [PATCH 13/31] fix: delay loading until language imported (#5344)

---
 src/components/InitializeApp.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/InitializeApp.tsx b/src/components/InitializeApp.tsx
index f6ff278d3..c1941512a 100644
--- a/src/components/InitializeApp.tsx
+++ b/src/components/InitializeApp.tsx
@@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
   useEffect(() => {
     const updateLang = async () => {
       await setLanguage(currentLang);
+      setLoading(false);
     };
     const currentLang =
       languages.find((lang) => lang.code === props.langCode) || defaultLang;
     updateLang();
-    setLoading(false);
   }, [props.langCode]);
 
   return loading ?  : props.children;

From 50bc7e099ae5ecc75d449d2e16bf783f6b4acd17 Mon Sep 17 00:00:00 2001
From: David Luzar 
Date: Thu, 23 Jun 2022 17:27:15 +0200
Subject: [PATCH 14/31] fix: unable to use cmd/ctrl-delete/backspace in inputs
 (#5348)

---
 src/components/App.tsx | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/components/App.tsx b/src/components/App.tsx
index 4a861f085..a16fcdf5e 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1731,11 +1731,13 @@ class App extends React.Component {
         });
       }
 
+      // bail if
       if (
+        // inside an input
         (isWritableElement(event.target) &&
-          !event[KEYS.CTRL_OR_CMD] &&
+          // unless pressing escape (finalize action)
           event.key !== KEYS.ESCAPE) ||
-        // case: using arrows to move between buttons
+        // or unless using arrows (to move between buttons)
         (isArrowKey(event.key) && isInputLike(event.target))
       ) {
         return;

From af31e9dcc2a0851b5e988e69e5743183a4b6ba0f Mon Sep 17 00:00:00 2001
From: David Luzar 
Date: Thu, 23 Jun 2022 17:33:50 +0200
Subject: [PATCH 15/31] fix: focus traps inside popovers (#5317)

---
 src/components/ColorPicker.tsx | 17 +++-------------
 src/components/Dialog.tsx      |  9 +--------
 src/components/Popover.tsx     | 37 ++++++++++++++++++++++++++++++++++
 src/utils.ts                   | 13 ++++++++++++
 4 files changed, 54 insertions(+), 22 deletions(-)

diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx
index fd2847523..76a66b5bf 100644
--- a/src/components/ColorPicker.tsx
+++ b/src/components/ColorPicker.tsx
@@ -129,19 +129,7 @@ const Picker = ({
 
   const handleKeyDown = (event: React.KeyboardEvent) => {
     let handled = false;
-    if (event.key === KEYS.TAB) {
-      handled = true;
-      const { activeElement } = document;
-      if (event.shiftKey) {
-        if (activeElement === firstItem.current) {
-          colorInput.current?.focus();
-          event.preventDefault();
-        }
-      } else if (activeElement === colorInput.current) {
-        firstItem.current?.focus();
-        event.preventDefault();
-      }
-    } else if (isArrowKey(event.key)) {
+    if (isArrowKey(event.key)) {
       handled = true;
       const { activeElement } = document;
       const isRTL = getLanguage().rtl;
@@ -272,7 +260,8 @@ const Picker = ({
             gallery.current = el;
           }
         }}
-        tabIndex={0}
+        // to allow focusing by clicking but not by tabbing
+        tabIndex={-1}
       >
         
{renderColors(colors)} diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 06615101e..f406a8bba 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -9,6 +9,7 @@ import { back, close } from "./icons"; import { Island } from "./Island"; import { Modal } from "./Modal"; import { AppState } from "../types"; +import { queryFocusableElements } from "../utils"; export interface DialogProps { children: React.ReactNode; @@ -64,14 +65,6 @@ export const Dialog = (props: DialogProps) => { return () => islandNode.removeEventListener("keydown", handleKeyDown); }, [islandNode, props.autofocus]); - const queryFocusableElements = (node: HTMLElement) => { - const focusableElements = node.querySelectorAll( - "button, a, input, select, textarea, div[tabindex]", - ); - - return focusableElements ? Array.from(focusableElements) : []; - }; - const onClose = () => { (lastActiveElement as HTMLElement).focus(); props.onCloseRequest(); diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx index ca47d7283..ba424f3ae 100644 --- a/src/components/Popover.tsx +++ b/src/components/Popover.tsx @@ -1,6 +1,8 @@ import React, { useLayoutEffect, useRef, useEffect } from "react"; import "./Popover.scss"; import { unstable_batchedUpdates } from "react-dom"; +import { queryFocusableElements } from "../utils"; +import { KEYS } from "../keys"; type Props = { top?: number; @@ -27,6 +29,41 @@ export const Popover = ({ }: Props) => { const popoverRef = useRef(null); + const container = popoverRef.current; + + useEffect(() => { + if (!container) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === KEYS.TAB) { + const focusableElements = queryFocusableElements(container); + const { activeElement } = document; + const currentIndex = focusableElements.findIndex( + (element) => element === activeElement, + ); + + if (currentIndex === 0 && event.shiftKey) { + focusableElements[focusableElements.length - 1].focus(); + event.preventDefault(); + event.stopImmediatePropagation(); + } else if ( + currentIndex === focusableElements.length - 1 && + !event.shiftKey + ) { + focusableElements[0].focus(); + event.preventDefault(); + event.stopImmediatePropagation(); + } + } + }; + + container.addEventListener("keydown", handleKeyDown); + + return () => container.removeEventListener("keydown", handleKeyDown); + }, [container]); + // ensure the popover doesn't overflow the viewport useLayoutEffect(() => { if (fitInViewport && popoverRef.current) { diff --git a/src/utils.ts b/src/utils.ts index d81ddfecf..2e651ef8f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -668,3 +668,16 @@ export const isPromiseLike = ( "finally" in value ); }; + +export const queryFocusableElements = (container: HTMLElement | null) => { + const focusableElements = container?.querySelectorAll( + "button, a, input, select, textarea, div[tabindex], label[tabindex]", + ); + + return focusableElements + ? Array.from(focusableElements).filter( + (element) => + element.tabIndex > -1 && !(element as HTMLInputElement).disabled, + ) + : []; +}; From 9135ebf2e281b177dd1273f123ccb0784353cac6 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Thu, 23 Jun 2022 17:42:50 +0200 Subject: [PATCH 16/31] feat: redirect vscode.excalidraw.com to vscode marketplace (#5285) --- vercel.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/vercel.json b/vercel.json index 316d4ce66..a621be1db 100644 --- a/vercel.json +++ b/vercel.json @@ -27,6 +27,16 @@ { "source": "/webex/:match*", "destination": "https://for-webex.excalidraw.com" + }, + { + "source": "/:path*", + "has": [ + { + "type": "host", + "value": "vscode.excalidraw.com" + } + ], + "destination": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor" } ] } From 120c8f373c91512378c3150f0419ff3b291d3613 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Sat, 25 Jun 2022 20:10:53 +0200 Subject: [PATCH 17/31] fix: library not scrollable when no published items installed (#5352) * fix: library not scrollable when no published items installed * show empty lib message in one case & fix i18n --- src/components/LibraryMenuItems.tsx | 22 +++++++++++++++++++--- src/locales/en.json | 1 + 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 222518593..01223baed 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -428,7 +428,7 @@ const LibraryMenuItems = ({ align="start" gap={1} style={{ - flex: publishedItems.length > 0 ? 1 : "0 0 auto", + flex: publishedItems.length > 0 ? 1 : "0 1 auto", marginBottom: 0, }} > @@ -467,7 +467,7 @@ const LibraryMenuItems = ({ fontSize: ".9rem", }} > - No items yet! + {t("library.noItems")}
0 || unpublishedItems.length > 0))) && (
{t("labels.excalidrawLib")}
)} - {publishedItems.length > 0 && renderLibrarySection(publishedItems)} + {publishedItems.length > 0 ? ( + renderLibrarySection(publishedItems) + ) : unpublishedItems.length > 0 ? ( +
+ {t("library.noItems")} +
+ ) : null} ); diff --git a/src/locales/en.json b/src/locales/en.json index b20f6a4e1..36dd36fc9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -124,6 +124,7 @@ "sidebarLock": "Keep sidebar open" }, "library": { + "noItems": "No items added yet...", "hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.", "hint_emptyPrivateLibrary": "Select an item on canvas to add it here." }, From bbfd2b3cd3b944d1c8191e54d848401ed0a00e90 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Tue, 28 Jun 2022 14:44:59 +0200 Subject: [PATCH 18/31] fix: file handle not persisted when importing excalidraw files (#5372) --- src/data/json.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/data/json.ts b/src/data/json.ts index 958cbe239..037c5ca18 100644 --- a/src/data/json.ts +++ b/src/data/json.ts @@ -98,7 +98,12 @@ export const loadFromJSON = async ( // gets resolved. Else, iOS users cannot open `.excalidraw` files. // extensions: ["json", "excalidraw", "png", "svg"], }); - return loadFromBlob(await normalizeFile(file), localAppState, localElements); + return loadFromBlob( + await normalizeFile(file), + localAppState, + localElements, + file.handle, + ); }; export const isValidExcalidrawData = (data?: { From 0ef202f2df2ad096dde8f901d390bef3e08874d4 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Sat, 2 Jul 2022 17:59:03 +0200 Subject: [PATCH 19/31] feat: support debugging PWA in dev (#4853) * feat: support enabling pwa in dev * enable workbox debug * add prebuild script * fix lint --- .env.development | 9 ++++++++ package.json | 3 ++- public/index.html | 16 +++++++++++++ {src => public}/service-worker.js | 37 ++++++++++++++++++++++--------- scripts/prebuild.js | 20 +++++++++++++++++ src/serviceWorker.tsx | 6 ++++- 6 files changed, 78 insertions(+), 13 deletions(-) rename {src => public}/service-worker.js (68%) create mode 100644 scripts/prebuild.js diff --git a/.env.development b/.env.development index f04f0868c..1a5fbda3f 100644 --- a/.env.development +++ b/.env.development @@ -11,3 +11,12 @@ REACT_APP_WS_SERVER_URL=http://localhost:3002 REACT_APP_PORTAL_URL= REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' + +# put these in your .env.local, or make sure you don't commit! +# must be lowercase `true` when turned on +# +# whether to enable Service Workers in development +REACT_APP_DEV_ENABLE_SW= +# whether to disable live reload / HMR. Usuaully what you want to do when +# debugging Service Workers. +REACT_APP_DEV_DISABLE_LIVE_RELOAD= diff --git a/package.json b/package.json index 8f27edb95..e9aed5025 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,8 @@ "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build", "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build", "build:version": "node ./scripts/build-version.js", - "build": "yarn build:app && yarn build:version", + "build:prebuild": "node ./scripts/prebuild.js", + "build": "yarn build:prebuild && yarn build:app && yarn build:version", "eject": "react-scripts eject", "fix:code": "yarn test:code --fix", "fix:other": "yarn prettier --write", diff --git a/public/index.html b/public/index.html index 31e4ed560..0c576a292 100644 --- a/public/index.html +++ b/public/index.html @@ -98,6 +98,22 @@ /> + <% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %> + + <% } %> -``` - -For production use :point_down: - -```js - -``` - -You will need to make sure `react`, `react-dom` is available as shown in the below example. For prod please use the production versions of `react`, `react-dom`. - -
View Example - -```html - - - - Excalidraw in browser - - - - - - - - -
-

Excalidraw Embed Example

-
-
- - - -``` - -```js -/*eslint-disable */ -import "./styles.css"; -import InitialData from "./initialData"; - -const App = () => { - const excalidrawRef = React.useRef(null); - - const [viewModeEnabled, setViewModeEnabled] = React.useState(false); - const [zenModeEnabled, setZenModeEnabled] = React.useState(false); - const [gridModeEnabled, setGridModeEnabled] = React.useState(false); - - const updateScene = () => { - const sceneData = { - elements: [ - { - type: "rectangle", - version: 141, - versionNonce: 361174001, - isDeleted: false, - id: "oDVXy8D6rom3H1-LLH2-f", - fillStyle: "hachure", - strokeWidth: 1, - strokeStyle: "solid", - roughness: 1, - opacity: 100, - angle: 0, - x: 100.50390625, - y: 93.67578125, - strokeColor: "#c92a2a", - backgroundColor: "transparent", - width: 186.47265625, - height: 141.9765625, - seed: 1968410350, - groupIds: [], - }, - ], - appState: { - viewBackgroundColor: "#edf2ff", - }, - }; - excalidrawRef.current.updateScene(sceneData); - }; - - return React.createElement( - React.Fragment, - null, - React.createElement( - "div", - { className: "button-wrapper" }, - React.createElement( - "button", - { - className: "update-scene", - onClick: updateScene, - }, - "Update Scene", - ), - React.createElement( - "button", - { - className: "reset-scene", - onClick: () => excalidrawRef.current.resetScene(), - }, - "Reset Scene", - ), - React.createElement( - "label", - null, - React.createElement("input", { - type: "checkbox", - checked: viewModeEnabled, - onChange: () => setViewModeEnabled(!viewModeEnabled), - }), - "View mode", - ), - React.createElement( - "label", - null, - React.createElement("input", { - type: "checkbox", - checked: zenModeEnabled, - onChange: () => setZenModeEnabled(!zenModeEnabled), - }), - "Zen mode", - ), - React.createElement( - "label", - null, - React.createElement("input", { - type: "checkbox", - checked: gridModeEnabled, - onChange: () => setGridModeEnabled(!gridModeEnabled), - }), - "Grid mode", - ), - ), - React.createElement( - "div", - { - className: "excalidraw-wrapper", - ref: excalidrawWrapperRef, - }, - React.createElement(ExcalidrawLib.Excalidraw, { - initialData: InitialData, - onChange: (elements, state) => - console.log("Elements :", elements, "State : ", state), - onPointerUpdate: (payload) => console.log(payload), - onCollabButtonClick: () => window.alert("You clicked on collab button"), - viewModeEnabled: viewModeEnabled, - zenModeEnabled: zenModeEnabled, - gridModeEnabled: gridModeEnabled, - }), - ), - ); -}; - -const excalidrawWrapper = document.getElementById("app"); - -ReactDOM.render(React.createElement(App), excalidrawWrapper); -``` - -To view the full example visit :point_down: - -[![Edit excalidraw-in-browser](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/excalidraw-in-browser-tlqom?fontsize=14&hidenavigation=1&theme=dark) - -
- -### Customizing styles - -Excalidraw is using CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors. - -Make sure the selector has higher specificity, e.g. by prefixing it with your app's selector: - -```css -.your-app .excalidraw { - --color-primary: red; -} -.your-app .excalidraw.theme--dark { - --color-primary: pink; -} -``` - -Most notably, you can customize the primary colors, by overriding these variables: - -- `--color-primary` -- `--color-primary-darker` -- `--color-primary-darkest` -- `--color-primary-light` -- `--color-primary-contrast-offset` — a slightly darker (in light mode), or lighter (in dark mode) `--color-primary` color to fix contrast issues (see [Chubb illusion](https://en.wikipedia.org/wiki/Chubb_illusion)). It will fall back to `--color-primary` if not present. - -For a complete list of variables, check [theme.scss](https://github.com/excalidraw/excalidraw/blob/master/src/css/theme.scss), though most of them will not make sense to override. - -### Props - -| Name | Type | Default | Description | -| --- | --- | --- | --- | -| [`onChange`](#onChange) | Function | | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw elements and the current app state. | -| [`initialData`](#initialData) |
{elements?: ExcalidrawElement[], appState?: AppState } 
| null | The initial data with which app loads. | -| [`ref`](#ref) | [`createRef`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) | [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) | [`callbackRef`](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) |
{ current: { readyPromise: resolvablePromise } }
| | Ref to be passed to Excalidraw | -| [`onCollabButtonClick`](#onCollabButtonClick) | Function | | Callback to be triggered when the collab button is clicked | -| [`isCollaborating`](#isCollaborating) | `boolean` | | This implies if the app is in collaboration mode | -| [`onPointerUpdate`](#onPointerUpdate) | Function | | Callback triggered when mouse pointer is updated. | -| [`langCode`](#langCode) | string | `en` | Language code string | -| [`renderTopRightUI`](#renderTopRightUI) | Function | | Function that renders custom UI in top right corner | -| [`renderFooter `](#renderFooter) | Function | | Function that renders custom UI footer | -| [`renderCustomStats`](#renderCustomStats) | Function | | Function that can be used to render custom stats on the stats dialog. | -| [`viewModeEnabled`](#viewModeEnabled) | boolean | | This implies if the app is in view mode. | -| [`zenModeEnabled`](#zenModeEnabled) | boolean | | This implies if the zen mode is enabled | -| [`gridModeEnabled`](#gridModeEnabled) | boolean | | This implies if the grid mode is enabled | -| [`libraryReturnUrl`](#libraryReturnUrl) | string | | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to | -| [`theme`](#theme) | [THEME.LIGHT](#THEME-1) | [THEME.LIGHT](#THEME-1) | [THEME.LIGHT](#THEME-1) | The theme of the Excalidraw component | -| [`name`](#name) | string | | Name of the drawing | -| [`UIOptions`](#UIOptions) |
{ canvasActions:  CanvasActions }
| [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L129) | To customise UI options. Currently we support customising [`canvas actions`](#canvasActions) | -| [`onPaste`](#onPaste) |
(data: ClipboardData, event: ClipboardEvent | null) => boolean
| | Callback to be triggered if passed when the something is pasted in to the scene | -| [`detectScroll`](#detectScroll) | boolean | true | Indicates whether to update the offsets when nearest ancestor is scrolled. | -| [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. | -| [`onLibraryChange`](#onLibraryChange) |
(items: LibraryItems) => void | Promise<any> 
| | The callback if supplied is triggered when the library is updated and receives the library items. | -| [`autoFocus`](#autoFocus) | boolean | false | Implies whether to focus the Excalidraw component on page load | -| [`generateIdForFile`](#generateIdForFile) | `(file: File) => string | Promise` | Allows you to override `id` generation for files added on canvas | -| [`onLinkOpen`](#onLinkOpen) |
(element: NonDeletedExcalidrawElement, event: CustomEvent) 
| | This prop if passed will be triggered when link of an element is clicked. | -| [`onPointerDown`](#onPointerDown) |
(activeTool:  AppState["activeTool"], pointerDownState: PointerDownState) => void
| | This prop if passed gets triggered on pointer down evenets | -| [`onScrollChange`](#onScrollChange) | (scrollX: number, scrollY: number) | | This prop if passed gets triggered when scrolling the canvas. | - -### Dimensions of Excalidraw - -Excalidraw takes `100%` of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions. - -#### `onChange` - -Every time component updates, this callback if passed will get triggered and has the below signature. - -```js -(excalidrawElements, appState, files) => void; -``` - -1.`excalidrawElements`: Array of [excalidrawElements](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106) in the scene. - -2.`appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) of the scene. - -3. `files`: The [`BinaryFiles`]([BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) which are added to the scene. - -Here you can try saving the data to your backend or local storage for example. - -#### `initialData` - -This helps to load Excalidraw with `initialData`. It must be an object or a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise) which resolves to an object containing the below optional fields. - -| Name | Type | Description | -| --- | --- | --- | -| `elements` | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106) | The elements with which Excalidraw should be mounted. | -| `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) | The App state with which Excalidraw should be mounted. | -| `scrollToContent` | boolean | This attribute implies whether to scroll to the nearest element to center once Excalidraw is mounted. By default, it will not scroll the nearest element to the center. Make sure you pass `initialData.appState.scrollX` and `initialData.appState.scrollY` when `scrollToContent` is false so that scroll positions are retained | -| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) | Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)> | This library items with which Excalidraw should be mounted. | -| `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) | The files added to the scene. | - -```json -{ - "elements": [ - { - "type": "rectangle", - "version": 141, - "versionNonce": 361174001, - "isDeleted": false, - "id": "oDVXy8D6rom3H1-LLH2-f", - "fillStyle": "hachure", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "angle": 0, - "x": 100.50390625, - "y": 93.67578125, - "strokeColor": "#000000", - "backgroundColor": "transparent", - "width": 186.47265625, - "height": 141.9765625, - "seed": 1968410350, - "groupIds": [] - } - ], - "appState": { "zenModeEnabled": true, "viewBackgroundColor": "#AFEEEE" } -} -``` - -You might want to use this when you want to load excalidraw with some initial elements and app state. - -#### `ref` - -You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs: - -| API | signature | Usage | -| --- | --- | --- | -| ready | `boolean` | This is set to true once Excalidraw is rendered | -| readyPromise | [resolvablePromise](https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317) | This promise will be resolved with the api once excalidraw has rendered. This will be helpful when you want do some action on the host app once this promise resolves. For this to work you will have to pass ref as shown [here](#readyPromise) | -| [updateScene](#updateScene) | (scene: sceneData) => void | updates the scene with the sceneData | -| [updateLibrary](#updateLibrary) | (opts) => Promise<LibraryItems> | updates the scene with the sceneData | -| [addFiles](#addFiles) | (files: BinaryFileData) => void | add files data to the appState | -| resetScene | `({ resetLoadingState: boolean }) => void` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. | -| getSceneElementsIncludingDeleted | () => ExcalidrawElement[] | Returns all the elements including the deleted in the scene | -| getSceneElements | () => ExcalidrawElement[] | Returns all the elements excluding the deleted in the scene | -| getAppState | () => AppState | Returns current appState | -| history | `{ clear: () => void }` | This is the history API. `history.clear()` will clear the history | -| scrollToContent | (target?: ExcalidrawElement | ExcalidrawElement[]) => void | Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene. | -| refresh | `() => void` | Updates the offsets for the Excalidraw component so that the coordinates are computed correctly (for example the cursor position). You don't have to call this when the position is changed on page scroll or when the excalidraw container resizes (we handle that ourselves). For any other cases if the position of excalidraw is updated (example due to scroll on parent container and not page scroll) you should call this API. | -| [importLibrary](#importlibrary) | `(url: string, token?: string) => void` | Imports library from given URL | -| setToastMessage | `(message: string) => void` | This API can be used to show the toast with custom message. | -| [id](#id) | string | Unique ID for the excalidraw component. | -| [getFiles](#getFiles) | () => files | This API can be used to get the files present in the scene. 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. | -| [setActiveTool](#setActiveTool) | (tool: { type: typeof SHAPES[number]["value"] | "eraser" } | { type: "custom"; customType: string }) => void | This API can be used to set the active tool | -| [setCursor](#setCursor) | (cursor: string) => void | This API can be used to set customise the mouse cursor on the canvas | -| [resetCursor](#resetCursor) | () => void | This API can be used to reset to default mouse cursor on the canvas | - -#### `readyPromise` - -
-const excalidrawRef = { current: { readyPromise: resolvablePromise}}
-
- -Since plain object is passed as a `ref`, the `readyPromise` is resolved as soon as the component is mounted. Most of the time you will not need this unless you have a specific use case where you can't pass the `ref` in the react way and want to do some action on the host when this promise resolves. You can check the [example](https://codesandbox.io/s/eexcalidraw-resolvable-promise-d0qg3?file=/src/App.js) for the usage. - -### `updateScene` - -
-(scene: sceneData) => void
-
- -You can use this function to update the scene with the sceneData. It accepts the below attributes. - -| Name | Type | Description | -| --- | --- | --- | -| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L17) | The `elements` to be updated in the scene | -| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L18) | The `appState` to be updated in the scene. | -| `collaborators` |
MapCollaborator>
| The list of collaborators to be updated in the scene. | -| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | -| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) | Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)> | ((currentItems: [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)>) => [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) | Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)>) | The `libraryItems` to be update in the scene. | - -### `updateLibrary` - -
-(opts: {
-  libraryItems: LibraryItemsSource;
-  merge?: boolean;
-  prompt?: boolean;
-  openLibraryMenu?: boolean;
-  defaultStatus?: "unpublished" | "published";
-}) => Promise<LibraryItems>
-
- -You can use this function to update the library. It accepts the below attributes. - -| Name | Type | Default | Description | -| --- | --- | --- | --- | -| `libraryItems` | | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L224) | The `libraryItems` to be replaced/merged with current library | -| `merge` | boolean | `false` | Whether to merge with existing library items. | -| `prompt` | boolean | `false` | Whether to prompt user for confirmation. | -| `openLibraryMenu` | boolean | `false` | Whether to open the library menu before importing. | -| `defaultStatus` | "unpublished" | "published" | `"unpublished"` | Default library item's `status` if not present. | - -### `addFiles` - -
(files: BinaryFileData) => void 
- -Adds supplied files data to the `appState.files` cache on top of existing files present in the cache. - -#### `onCollabButtonClick` - -This callback is triggered when clicked on the collab button in excalidraw. If not supplied, the collab dialog button is not rendered. - -#### `isCollaborating` - -This prop indicates if the app is in collaboration mode. - -#### `onPointerUpdate` - -This callback is triggered when mouse pointer is updated. - -```js -({ x, y }, button, pointersMap}) => void; -``` - -1.`{x, y}`: Pointer coordinates - -2.`button`: The position of the button. This will be one of `["down", "up"]` - -3.`pointersMap`: [`pointers map`](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L131) of the scene - -```js -(exportedElements, appState, canvas) => void -``` - -1. `exportedElements`: An array of [non deleted elements](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L87) which needs to be exported. -2. `appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) of the scene. -3. `canvas`: The `HTMLCanvasElement` of the scene. - -#### `langCode` - -Determines the language of the UI. It should be one of the [available language codes](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L14). Defaults to `en` (English). We also export default language and supported languages which you can import as shown below. - -```js -import { defaultLang, languages } from "@excalidraw/excalidraw-next"; -``` - -| name | type | -| --- | --- | -| defaultLang | string | -| languages | [Language[]](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L8) | - -#### `renderTopRightUI` - -
-(isMobile: boolean, appState: AppState) => JSX | null
-
- -A function returning JSX to render custom UI in the top right corner of the app. - -#### `renderFooter` - -
-(isMobile: boolean, appState: AppState) => JSX | null
-
- -A function returning JSX to render custom UI footer. For example, you can use this to render a language picker that was previously being rendered by Excalidraw itself (for now, you'll need to implement your own language picker). - -#### `renderCustomStats` - -A function that can be used to render custom stats (returns JSX) in the nerd stats dialog. For example you can use this prop to render the size of the elements in the storage. - -#### `viewModeEnabled` - -This prop indicates whether the app is in `view mode`. When supplied, the value takes precedence over `intialData.appState.viewModeEnabled`, the `view mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app. - -#### `zenModeEnabled` - -This prop indicates whether the app is in `zen mode`. When supplied, the value takes precedence over `intialData.appState.zenModeEnabled`, the `zen mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app. - -#### `gridModeEnabled` - -This prop indicates whether the shows the grid. When supplied, the value takes precedence over `intialData.appState.gridModeEnabled`, the grid will be fully controlled by the host app, and users won't be able to toggle it from within the app. - -#### `libraryReturnUrl` - -If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com). Defaults to `window.location.origin + window.location.pathname`. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab. - -#### `theme` - -This prop controls Excalidraw's theme. When supplied, the value takes precedence over `intialData.appState.theme`, the theme will be fully controlled by the host app, and users won't be able to toggle it from within the app. You can use [`THEME`](#THEME-1) to specify the theme. - -#### `name` - -This prop sets the name of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over `intialData.appState.name`, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw. - -#### `UIOptions` - -This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasActions) and [`dockedSidebarBreakpoint`](dockedSidebarBreakpoint). It accepts the below parameters - -
-{ canvasActions:  CanvasActions }
-
- -##### canvasActions - -| Attribute | Type | Default | Description | -| --- | --- | --- | --- | -| `changeViewBackgroundColor` | boolean | true | Implies whether to show `Background color picker` | -| `clearCanvas` | boolean | true | Implies whether to show `Clear canvas button` | -| `export` | false | [exportOpts](#exportOpts) |
{ saveFileToDisk: true }
| This prop allows to customize the UI inside the export dialog. By default it shows the "saveFileToDisk". If this prop is `false` the export button will not be rendered. For more details visit [`exportOpts`](#exportOpts). | -| `loadScene` | boolean | true | Implies whether to show `Load button` | -| `saveToActiveFile` | boolean | true | Implies whether to show `Save button` to save to current file | -| `theme` | boolean | true | Implies whether to show `Theme toggle` | -| `saveAsImage` | boolean | true | Implies whether to show `Save as image button` | - -##### `dockedSidebarBreakpoint` - -This prop indicates at what point should we break to a docked, permanent sidebar. If not passed it defaults to [`MQ_RIGHT_SIDEBAR_MAX_WIDTH_PORTRAIT`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L167). If the `width` of the `excalidraw` container exceeds `dockedSidebarBreakpoint`, the sidebar will be dockable. If user choses to dock the sidebar, it will push the right part of the UI towards the left, making space for the sidebar as shown below. - -![image](https://user-images.githubusercontent.com/11256141/174664866-c698c3fa-197b-43ff-956c-d79852c7b326.png) - -#### `exportOpts` - -The below attributes can be set in `UIOptions.canvasActions.export` to customize the export dialog. If `UIOptions.canvasActions.export` is `false` the export button will not be rendered. - -| Attribute | Type | Default | Description | -| --- | --- | --- | --- | -| `saveFileToDisk` | boolean | true | Implies if save file to disk button should be shown | -| `onExportToBackend` |
 (exportedElements: readonly NonDeletedExcalidrawElement[],appState: AppState,canvas: HTMLCanvasElement | null) => void 
| | This callback is triggered when the shareable-link button is clicked in the export dialog. The link button will only be shown if this callback is passed. | -| `renderCustomUI` |
 (exportedElements: readonly NonDeletedExcalidrawElement[],appState: AppState,canvas: HTMLCanvasElement | null) => void 
| | This callback should be supplied if you want to render custom UI in the export dialog. | - -#### `onPaste` - -This callback is triggered if passed when something is pasted into the scene. You can use this callback in case you want to do something additional when the paste event occurs. - -
-(data: ClipboardData, event: ClipboardEvent | null) => boolean
-
- -This callback must return a `boolean` value or a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise) which resolves to a boolean value. - -In case you want to prevent the excalidraw paste action you must return `false`, it will stop the native excalidraw clipboard management flow (nothing will be pasted into the scene). - -#### `importLibrary` - -Imports library from given URL. You should call this on `hashchange`, passing the `addLibrary` value if you detect it as shown below. Optionally pass a CSRF `token` to skip prompting during installation (retrievable via `token` key from the url coming from [https://libraries.excalidraw.com](https://libraries.excalidraw.com/)). - -```js -useEffect(() => { - const onHashChange = () => { - const hash = new URLSearchParams(window.location.hash.slice(1)); - const libraryUrl = hash.get("addLibrary"); - if (libraryUrl) { - excalidrawRef.current.importLibrary(libraryUrl, hash.get("token")); - } - }; - window.addEventListener("hashchange", onHashChange, false); - return () => { - window.removeEventListener("hashchange", onHashChange); - }; -}, []); -``` - -Try out the [Demo](#Demo) to see it in action. - -#### `setActiveTool` - -This API has the below signature. It sets the `tool` passed in param as the active tool. - -
-(tool: { type: typeof SHAPES[number]["value"] | "eraser" } | { type: "custom"; customType: string }) => void
-
- -#### `setCursor` - -This API can be used to customise the mouse cursor on the canvas and has the below signature. It sets the mouse cursor to the cursor passed in param. - -
-(cursor: string) => void
-
- -#### `resetCursor` - -This API can be used to reset to default mouse cursor. - -#### `detectScroll` - -Indicates whether Excalidraw should listen for `scroll` event on the nearest scrollable container in the DOM tree and recompute the coordinates (e.g. to correctly handle the cursor) when the component's position changes. You can disable this when you either know this doesn't affect your app or you want to take care of it yourself (calling the [`refresh()`](#ref) method). - -#### `handleKeyboardGlobally` - -Indicates whether to bind keyboard events to `document`. Disabled by default, meaning the keyboard events are bound to the Excalidraw component. This allows for multiple Excalidraw components to live on the same page, and ensures that Excalidraw keyboard handling doesn't collide with your app's (or the browser) when the component isn't focused. - -Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar). - -#### `onLibraryChange` - -This callback if supplied will get triggered when the library is updated and has the below signature. - -
-(items: LibraryItems) => void | Promise
-
- -It is invoked with empty items when user clears the library. You can use this callback when you want to do something additional when library is updated for example persisting it to local storage. - -#### `id` - -The unique id of the excalidraw component. This can be used to identify the excalidraw component, for example importing the library items to the excalidraw component from where it was initiated when you have multiple excalidraw components rendered on the same page as shown in [multiple excalidraw demo](https://codesandbox.io/s/multiple-excalidraw-k1xx5). - -#### `autoFocus` - -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 -``` - -#### `onLinkOpen` - -This prop if passed will be triggered when clicked on link. To handle the redirect yourself (such as when using your own router for internal links), you must call `event.preventDefault()`. - -``` -(element: ExcalidrawElement, event: CustomEvent<{ nativeEvent: MouseEvent }>) => void -``` - -Example: - -```ts -const history = useHistory(); - -// open internal links using the app's router, but opens external links in -// a new tab/window -const onLinkOpen: ExcalidrawProps["onLinkOpen"] = useCallback( - (element, event) => { - const link = element.link; - const { nativeEvent } = event.detail; - const isNewTab = nativeEvent.ctrlKey || nativeEvent.metaKey; - const isNewWindow = nativeEvent.shiftKey; - const isInternalLink = - link.startsWith("/") || link.includes(window.location.origin); - if (isInternalLink && !isNewTab && !isNewWindow) { - history.push(link.replace(window.location.origin, "")); - // signal that we're handling the redirect ourselves - event.preventDefault(); - } - }, - [history], -); -``` - -#### `onPointerDown` - -This prop if passed will be triggered on pointer down events and has the below signature. - -
-(activeTool:  AppState["activeTool"], pointerDownState: PointerDownState) => void
-
- -#### `onScrollChange` - -This prop if passed will be triggered when canvas is scrolled and has the below signature. - -```ts -(scrollX: number, scrollY: number) => void -``` - -### 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). - -### Restore utilities - -#### `restoreAppState` - -**_Signature_** - -
-restoreAppState(appState: ImportedDataState["appState"], localAppState: Partial<AppState> | null): AppState
-
- -**_How to use_** - -```js -import { restoreAppState } from "@excalidraw/excalidraw-next"; -``` - -This function will make sure all the keys have appropriate values in [appState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) and if any key is missing, it will be set to default value. - -When `localAppState` is supplied, it's used in place of values that are missing (`undefined`) in `appState` instead of defaults. Use this as a way to not override user's defaults if you persist them. Required: supply `null`/`undefined` if not applicable. - -#### `restoreElements` - -**_Signature_** - -
-restoreElements(elements: ImportedDataState["elements"], localElements: ExcalidrawElement[] | null | undefined): ExcalidrawElement[]
-
- -**_How to use_** - -```js -import { restoreElements } from "@excalidraw/excalidraw-next"; -``` - -This function will make sure all properties of element is correctly set and if any attribute is missing, it will be set to default value. - -When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. Use this when you import elements which may already be present in the scene to ensure that you do not disregard the newly imported elements if you're using element version to detect the updates. - -#### `restore` - -**_Signature_** - -
-restoreElements(data: ImportedDataState, localAppState: Partial<AppState> | null | undefined, localElements: ExcalidrawElement[] | null | undefined): DataState
-
- -See [`restoreAppState()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreAppState) about `localAppState`, and [`restoreElements()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreElements) about `localElements`. - -**_How to use_** - -```js -import { restore } from "@excalidraw/excalidraw-next"; -``` - -This function makes sure elements and state is set to appropriate values and set to default value if not present. It is a combination of [restoreElements](#restoreElements) and [restoreAppState](#restoreAppState). - -#### `restoreLibraryItems` - -**_Signature_** - -
-restoreLibraryItems(libraryItems: ImportedDataState["libraryItems"], defaultStatus: "published" | "unpublished")
-
- -**_How to use_** - -```js -import { restoreLibraryItems } from "@excalidraw/excalidraw-next"; - -restoreLibraryItems(libraryItems, "unpublished"); -``` - -This function normalizes library items elements, adding missing values when needed. - -### Export utilities - -#### `exportToCanvas` - -**_Signature_** - -
exportToCanvas({
-  elements,
-  appState
-  getDimensions,
-  files
-}: ExportOpts
-
- -| Name | Type | Default | Description | -| --- | --- | --- | --- | -| elements | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/src/element/types) | | The elements to be exported to canvas | -| appState | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L12) | [defaultAppState](https://github.com/excalidraw/excalidraw/blob/master/src/appState.ts#L11) | The app state of the scene | -| getDimensions | `(width: number, height: number) => { width: number, height: number, scale?: number }` | undefined | A function which returns the `width`, `height`, and optionally `scale` (defaults `1`), with which canvas is to be exported. | -| maxWidthOrHeight | `number` | undefined | The maximum width or height of the exported image. If provided, `getDimensions` is ignored. | -| files | [BinaryFiles](The [`BinaryFiles`](<[BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64)>) | undefined | The files added to the scene. | - -**How to use** - -```js -import { exportToCanvas } from "@excalidraw/excalidraw-next"; -``` - -This function returns the canvas with the exported elements, appState and dimensions. - -#### `exportToBlob` - -**_Signature_** - -
-exportToBlob(
-  opts: ExportOpts & {
-  mimeType?: string,
-  quality?: number;
-})
-
- -| Name | Type | Default | Description | -| --- | --- | --- | --- | -| opts | | | This param is passed to `exportToCanvas`. You can refer to [`exportToCanvas`](#exportToCanvas) | -| mimeType | string | "image/png" | Indicates the image format | -| quality | number | 0.92 | A value between 0 and 1 indicating the [image quality](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#parameters). Applies only to `image/jpeg`/`image/webp` MIME types. | - -**How to use** - -```js -import { exportToBlob } from "@excalidraw/excalidraw-next"; -``` - -Returns a promise which resolves with a [blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob). It internally uses [canvas.ToBlob](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob). - -#### `exportToSvg` - -**_Signature_** - -
-exportToSvg({
-  elements: ExcalidrawElement[],
-  appState: AppState,
-  exportPadding?: number,
-  metadata?: string,
-  files?: BinaryFiles
-})
-
- -| Name | Type | Default | Description | -| --- | --- | --- | --- | -| elements | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106) | | The elements to exported as svg | -| appState | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) | [defaultAppState](https://github.com/excalidraw/excalidraw/blob/master/src/appState.ts#L11) | The app state of the scene | -| exportPadding | number | 10 | The padding to be added on canvas | -| files | [BinaryFiles](The [`BinaryFiles`](<[BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64)>) | undefined | The files added to the scene. | - -This function returns a promise which resolves to svg of the exported drawing. - -#### `exportToClipboard` - -**_Signature_** - -
-exportToClipboard(
-  opts: ExportOpts & {
-  mimeType?: string,
-  quality?: number;
-  type: 'png' | 'svg' |'json'
-})
-
- -| Name | Type | Default | Description | -| --- | --- | --- | --- | --- | --- | -| opts | | | This param is same as the params passed to `exportToCanvas`. You can refer to [`exportToCanvas`](#exportToCanvas). | -| mimeType | string | "image/png" | Indicates the image format, this will be used when exporting as `png`. | -| quality | number | 0.92 | A value between 0 and 1 indicating the [image quality](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#parameters). Applies only to `image/jpeg`/`image/webp` MIME types. This will be used when exporting as `png`. | -| type | 'png' | 'svg' | 'json' | | This determines the format to which the scene data should be exported. | - -**How to use** - -```js -import { exportToClipboard } from "@excalidraw/excalidraw-next"; -``` - -Copies the scene data in the specified format (determined by `type`) to clipboard. - -##### Additional attributes of appState for `export\*` APIs - -| Name | Type | Default | Description | -| --- | --- | --- | --- | -| exportBackground | boolean | true | Indicates whether background should be exported | -| viewBackgroundColor | string | #fff | The default background color | -| exportWithDarkMode | boolean | false | Indicates whether to export with dark mode | -| exportEmbedScene | boolean | false | Indicates whether scene data should be embedded in svg/png. This will increase the image size. | - -### Extra API's - -#### `serializeAsJSON` - -**_Signature_** - -
-serializeAsJSON({
-  elements: ExcalidrawElement[],
-  appState: AppState,
-}): string
-
- -Takes the scene elements and state and returns a JSON string. Deleted `elements`as well as most properties from `AppState` are removed from the resulting JSON. (see [`serializeAsJSON()`](https://github.com/excalidraw/excalidraw/blob/master/src/data/json.ts#L16) source for details). - -If you want to overwrite the source field in the JSON string, you can set `window.EXCALIDRAW_EXPORT_SOURCE` to the desired value. - -#### `serializeLibraryAsJSON` - -**_Signature_** - -
-serializeLibraryAsJSON({
-  libraryItems: LibraryItems[],
-
- -Takes the library items and returns a JSON string. - -If you want to overwrite the source field in the JSON string, you can set `window.EXCALIDRAW_EXPORT_SOURCE` to the desired value. - -#### `getSceneVersion` - -**How to use** - -
-import { getSceneVersion } from "@excalidraw/excalidraw-next";
-getSceneVersion(elements:  ExcalidrawElement[])
-
- -This function returns the current scene version. - -#### `isInvisiblySmallElement` - -**_Signature_** - -
-isInvisiblySmallElement(element:  ExcalidrawElement): boolean
-
- -**How to use** - -```js -import { isInvisiblySmallElement } from "@excalidraw/excalidraw-next"; -``` - -Returns `true` if element is invisibly small (e.g. width & height are zero). - -#### `loadLibraryFromBlob` - -```js -import { loadLibraryFromBlob } from "@excalidraw/excalidraw-next"; -``` - -**_Signature_** - -
-loadLibraryFromBlob(blob: Blob)
-
- -This function loads the library from the blob. - -#### `loadFromBlob` - -**How to use** - -```js -import { loadFromBlob } from "@excalidraw/excalidraw-next"; - -const scene = await loadFromBlob(file, null, null); -excalidrawAPI.updateScene(scene); -``` - -**Signature** - -
-loadFromBlob(
-  blob: Blob,
-  localAppState: AppState | null,
-  localElements: ExcalidrawElement[] | null,
-  fileHandle?: FileSystemHandle | null
-) => Promise<RestoredDataState>
-
- -This function loads the scene data from the blob (or file). If you pass `localAppState`, `localAppState` value will be preferred over the `appState` derived from `blob`. Throws if blob doesn't contain valid scene data. - -#### `loadSceneOrLibraryFromBlob` - -**How to use** - -```js -import { loadSceneOrLibraryFromBlob, MIME_TYPES } from "@excalidraw/excalidraw"; - -const contents = await loadSceneOrLibraryFromBlob(file, null, null); -if (contents.type === MIME_TYPES.excalidraw) { - excalidrawAPI.updateScene(contents.data); -} else if (contents.type === MIME_TYPES.excalidrawlib) { - excalidrawAPI.updateLibrary(contents.data); -} -``` - -**Signature** - -
-loadSceneOrLibraryFromBlob(
-  blob: Blob,
-  localAppState: AppState | null,
-  localElements: ExcalidrawElement[] | null,
-  fileHandle?: FileSystemHandle | null
-) => Promise<{ type: string, data: RestoredDataState | ImportedLibraryState}>
-
- -This function loads either scene or library data from the supplied blob. If the blob contains scene data, and you pass `localAppState`, `localAppState` value will be preferred over the `appState` derived from `blob`. Throws if blob doesn't contain neither valid scene data or library data. - -#### `getFreeDrawSvgPath` - -**How to use** - -```js -import { getFreeDrawSvgPath } from "@excalidraw/excalidraw-next"; -``` - -**Signature** - -
-getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement
-
- -This function returns the free draw svg path for the element. - -#### `isLinearElement` - -**How to use** - -```js -import { isLinearElement } from "@excalidraw/excalidraw-next"; -``` - -**Signature** - -
-isLinearElement(elementType?: ExcalidrawElement): boolean
-
- -This function returns true if the element is linear type (`arrow` |`line`) else returns false. - -#### `getNonDeletedElements` - -**How to use** - -```js -import { getNonDeletedElements } from "@excalidraw/excalidraw-next"; -``` - -**Signature** - -
-getNonDeletedElements(elements:  readonly ExcalidrawElement[]): as readonly NonDeletedExcalidrawElement[]
-
- -This function returns an array of deleted elements. - -#### `mergeLibraryItems` - -```js -import { mergeLibraryItems } from "@excalidraw/excalidraw-next"; -``` - -**_Signature_** - -
-mergeLibraryItems(localItems: LibraryItems, otherItems: LibraryItems) => LibraryItems
-
- -This function merges two `LibraryItems` arrays, where unique items from `otherItems` are sorted first in the returned array. - -#### `parseLibraryTokensFromUrl` - -**How to use** - -```js -import { parseLibraryTokensFromUrl } from "@excalidraw/excalidraw-next"; -``` - -**Signature** - -
-parseLibraryTokensFromUrl(): {
-    libraryUrl: string;
-    idToken: string | null;
-} | null
-
- -Parses library parameters from URL if present (expects the `#addLibrary` hash key), and returns an object with the `libraryUrl` and `idToken`. Returns `null` if `#addLibrary` hash key not found. - -#### `useHandleLibrary` - -**How to use** - -```js -import { useHandleLibrary } from "@excalidraw/excalidraw-next"; - -export const App = () => { - // ... - useHandleLibrary({ excalidrawAPI }); -}; -``` - -**Signature** - -
-useHandleLibrary(opts: {
-  excalidrawAPI: ExcalidrawAPI,
-  getInitialLibraryItems?: () => LibraryItemsSource
-});
-
- -A hook that automatically imports library from url if `#addLibrary` hash key exists on initial load, or when it changes during the editing session (e.g. when a user installs a new library), and handles initial library load if `getInitialLibraryItems` getter is supplied. - -In the future, we will be adding support for handling library persistence to browser storage (or elsewhere). - -#### `sceneCoordsToViewportCoords` - -```js -import { sceneCoordsToViewportCoords } from "@excalidraw/excalidraw-next"; -``` - -**_Signature_** - -
-sceneCoordsToViewportCoords({sceneX: number, sceneY: number}, appState: AppState): {x: number, y: number}
-
- -This function returns equivalent viewport coords for the provided scene coords in params. - -#### `viewportCoordsToSceneCoords` - -```js -import { viewportCoordsToSceneCoords } from "@excalidraw/excalidraw-next"; -``` - -**_Signature_** - -
-viewportCoordsToSceneCoords({clientX: number, clientY: number}, appState: AppState): {x: number, y: number}
-
- -This function returns equivalent scene coords for the provided viewport coords in params. - -### Exported constants - -#### `FONT_FAMILY` - -**How to use** - -```js -import { FONT_FAMILY } from "@excalidraw/excalidraw-next"; -``` - -`FONT_FAMILY` contains all the font families used in `Excalidraw` as explained below - -| Font Family | Description | -| ----------- | -------------------- | -| Virgil | The handwritten font | -| Helvetica | The Normal Font | -| Cascadia | The Code Font | - -Defaults to `FONT_FAMILY.Virgil` unless passed in `initialData.appState.currentItemFontFamily`. - -#### `THEME` - -**How to use** - -```js -import { THEME } from "@excalidraw/excalidraw-next"; -``` - -`THEME` contains all the themes supported by `Excalidraw` as explained below - -| Theme | Description | -| ----- | --------------- | -| LIGHT | The light theme | -| DARK | The Dark theme | - -Defaults to `THEME.LIGHT` unless passed in `initialData.appState.theme` - -### `MIME_TYPES` - -**How to use ** - -```js -import { MIME_TYPES } from "@excalidraw/excalidraw-next"; -``` - -[`MIME_TYPES`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L92) contains all the mime types supported by `Excalidraw`. - -## Need help? - -Check out the existing [Q&A](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw). If you have any queries or need help, ask us [here](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw). - -### Development - -#### Install the dependencies - -```bash -yarn -``` - -#### Start the server - -```bash -yarn start -``` - -[http://localhost:3001](http://localhost:3001) will open in your default browser. - -The example is same as the [codesandbox example](https://ehlz3.csb.app/) - -#### Create a test release - -You can create a test release by posting the below comment in your pull request - -``` -@excalibot release package -``` - -Once the version is released `@excalibot` will post a comment with the release version. From c5355c08cf77feb4631c07e42fea0c8969dcbd0e Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 4 Jul 2022 22:25:24 +0530 Subject: [PATCH 27/31] fix: action name for autorelease (#5411) --- .github/workflows/autorelease-excalidraw.yml | 2 +- .github/workflows/autorelease-preview.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/autorelease-excalidraw.yml b/.github/workflows/autorelease-excalidraw.yml index d0fd6234a..de947e842 100644 --- a/.github/workflows/autorelease-excalidraw.yml +++ b/.github/workflows/autorelease-excalidraw.yml @@ -1,4 +1,4 @@ -name: Auto release @excalidraw/excalidraw@next +name: Auto release excalidraw next on: push: branches: diff --git a/.github/workflows/autorelease-preview.yml b/.github/workflows/autorelease-preview.yml index 581cd5887..8fe7f40b5 100644 --- a/.github/workflows/autorelease-preview.yml +++ b/.github/workflows/autorelease-preview.yml @@ -1,4 +1,4 @@ -name: Auto release preview @excalidraw/excalidraw-preview +name: Auto release excalidraw preview on: issue_comment: types: [created, edited] From ba3a723e995ddf31c69e9e39f9f7317244a5cc4e Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 4 Jul 2022 22:32:22 +0530 Subject: [PATCH 28/31] fix: autorelease job name (#5412) --- .github/workflows/autorelease-excalidraw.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/autorelease-excalidraw.yml b/.github/workflows/autorelease-excalidraw.yml index de947e842..24071b3a2 100644 --- a/.github/workflows/autorelease-excalidraw.yml +++ b/.github/workflows/autorelease-excalidraw.yml @@ -5,7 +5,7 @@ on: - master jobs: - Auto-release-excalidraw@next: + Auto-release-excalidraw-next: runs-on: ubuntu-latest steps: From a1a62468a61b5b479a912e60f8b42c2d3ae9cfad Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 5 Jul 2022 12:24:50 +0530 Subject: [PATCH 29/31] docs: fix command to trigger release (#5413) --- src/packages/excalidraw/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packages/excalidraw/README.md b/src/packages/excalidraw/README.md index fa5bc9f79..c11966e8c 100644 --- a/src/packages/excalidraw/README.md +++ b/src/packages/excalidraw/README.md @@ -1337,7 +1337,7 @@ The example is same as the [codesandbox example](https://ehlz3.csb.app/) You can create a test release by posting the below comment in your pull request ``` -@excalibot release package +@excalibot trigger release ``` Once the version is released `@excalibot` will post a comment with the release version. From dac8dda4d4f4b92471e3ae388206443dcb66fb78 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Tue, 5 Jul 2022 16:03:40 +0200 Subject: [PATCH 30/31] feat: collab component state handling rewrite & fixes (#5046) --- src/components/CollabButton.scss | 6 +- src/components/CollabButton.tsx | 2 +- src/createInverseContext.tsx | 42 ---- .../collab/{CollabWrapper.tsx => Collab.tsx} | 233 +++++++++--------- src/excalidraw-app/collab/Portal.tsx | 6 +- src/excalidraw-app/collab/RoomDialog.tsx | 12 +- src/excalidraw-app/data/index.ts | 9 +- src/excalidraw-app/index.tsx | 81 ++++-- src/jotai.ts | 25 +- src/tests/collab.test.tsx | 3 +- 10 files changed, 227 insertions(+), 192 deletions(-) delete mode 100644 src/createInverseContext.tsx rename src/excalidraw-app/collab/{CollabWrapper.tsx => Collab.tsx} (85%) diff --git a/src/components/CollabButton.scss b/src/components/CollabButton.scss index 49362343a..93abb07cc 100644 --- a/src/components/CollabButton.scss +++ b/src/components/CollabButton.scss @@ -18,13 +18,15 @@ left: -5px; } min-width: 1em; + min-height: 1em; + line-height: 1; position: absolute; bottom: -5px; padding: 3px; border-radius: 50%; background-color: $oc-green-6; color: $oc-white; - font-size: 0.7em; - font-family: var(--ui-font); + font-size: 0.6em; + font-family: "Cascadia"; } } diff --git a/src/components/CollabButton.tsx b/src/components/CollabButton.tsx index d6544e95b..c2f642744 100644 --- a/src/components/CollabButton.tsx +++ b/src/components/CollabButton.tsx @@ -28,7 +28,7 @@ const CollabButton = ({ aria-label={t("labels.liveCollaboration")} showAriaLabel={useDevice().isMobile} > - {collaboratorCount > 0 && ( + {isCollaborating && (
{collaboratorCount}
)} diff --git a/src/createInverseContext.tsx b/src/createInverseContext.tsx deleted file mode 100644 index ac6cc223e..000000000 --- a/src/createInverseContext.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react"; - -export const createInverseContext = ( - initialValue: T, -) => { - const Context = React.createContext(initialValue) as React.Context & { - _updateProviderValue?: (value: T) => void; - }; - - class InverseConsumer extends React.Component { - state = { value: initialValue }; - constructor(props: any) { - super(props); - Context._updateProviderValue = (value: T) => this.setState({ value }); - } - render() { - return ( - - {this.props.children} - - ); - } - } - - class InverseProvider extends React.Component<{ value: T }> { - componentDidMount() { - Context._updateProviderValue?.(this.props.value); - } - componentDidUpdate() { - Context._updateProviderValue?.(this.props.value); - } - render() { - return {() => this.props.children}; - } - } - - return { - Context, - Consumer: InverseConsumer, - Provider: InverseProvider, - }; -}; diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/Collab.tsx similarity index 85% rename from src/excalidraw-app/collab/CollabWrapper.tsx rename to src/excalidraw-app/collab/Collab.tsx index c3786a440..7112671c4 100644 --- a/src/excalidraw-app/collab/CollabWrapper.tsx +++ b/src/excalidraw-app/collab/Collab.tsx @@ -8,10 +8,12 @@ import { ExcalidrawElement, InitializedExcalidrawImageElement, } from "../../element/types"; -import { getSceneVersion } from "../../packages/excalidraw/index"; +import { + getSceneVersion, + restoreElements, +} from "../../packages/excalidraw/index"; import { Collaborator, Gesture } from "../../types"; import { - getFrame, preventUnload, resolvablePromise, withBatchedUpdates, @@ -47,11 +49,9 @@ import { } from "../data/localStorage"; import Portal from "./Portal"; import RoomDialog from "./RoomDialog"; -import { createInverseContext } from "../../createInverseContext"; import { t } from "../../i18n"; import { UserIdleState } from "../../types"; import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants"; -import { trackEvent } from "../../analytics"; import { encodeFilesForUpload, FileManager, @@ -70,52 +70,45 @@ import { import { decryptData } from "../../data/encryption"; import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; +import { atom, useAtom } from "jotai"; +import { jotaiStore } from "../../jotai"; + +export const collabAPIAtom = atom(null); +export const collabDialogShownAtom = atom(false); +export const isCollaboratingAtom = atom(false); interface CollabState { - modalIsShown: boolean; errorMessage: string; username: string; - userState: UserIdleState; activeRoomLink: string; } -type CollabInstance = InstanceType; +type CollabInstance = InstanceType; export interface CollabAPI { /** function so that we can access the latest value from stale callbacks */ isCollaborating: () => boolean; - username: CollabState["username"]; - userState: CollabState["userState"]; onPointerUpdate: CollabInstance["onPointerUpdate"]; - initializeSocketClient: CollabInstance["initializeSocketClient"]; - onCollabButtonClick: CollabInstance["onCollabButtonClick"]; + startCollaboration: CollabInstance["startCollaboration"]; + stopCollaboration: CollabInstance["stopCollaboration"]; syncElements: CollabInstance["syncElements"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; setUsername: (username: string) => void; } -interface Props { +interface PublicProps { excalidrawAPI: ExcalidrawImperativeAPI; - onRoomClose?: () => void; } -const { - Context: CollabContext, - Consumer: CollabContextConsumer, - Provider: CollabContextProvider, -} = createInverseContext<{ api: CollabAPI | null }>({ api: null }); +type Props = PublicProps & { modalIsShown: boolean }; -export { CollabContext, CollabContextConsumer }; - -class CollabWrapper extends PureComponent { +class Collab extends PureComponent { portal: Portal; fileManager: FileManager; excalidrawAPI: Props["excalidrawAPI"]; activeIntervalId: number | null; idleTimeoutId: number | null; - // marked as private to ensure we don't change it outside this class - private _isCollaborating: boolean = false; private socketInitializationTimer?: number; private lastBroadcastedOrReceivedSceneVersion: number = -1; private collaborators = new Map(); @@ -123,10 +116,8 @@ class CollabWrapper extends PureComponent { constructor(props: Props) { super(props); this.state = { - modalIsShown: false, errorMessage: "", username: importUsernameFromLocalStorage() || "", - userState: UserIdleState.ACTIVE, activeRoomLink: "", }; this.portal = new Portal(this); @@ -164,6 +155,18 @@ class CollabWrapper extends PureComponent { window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.addEventListener(EVENT.UNLOAD, this.onUnload); + const collabAPI: CollabAPI = { + isCollaborating: this.isCollaborating, + onPointerUpdate: this.onPointerUpdate, + startCollaboration: this.startCollaboration, + syncElements: this.syncElements, + fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase, + stopCollaboration: this.stopCollaboration, + setUsername: this.setUsername, + }; + + jotaiStore.set(collabAPIAtom, collabAPI); + if ( process.env.NODE_ENV === ENV.TEST || process.env.NODE_ENV === ENV.DEVELOPMENT @@ -196,7 +199,11 @@ class CollabWrapper extends PureComponent { } } - isCollaborating = () => this._isCollaborating; + isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!; + + private setIsCollaborating = (isCollaborating: boolean) => { + jotaiStore.set(isCollaboratingAtom, isCollaborating); + }; private onUnload = () => { this.destroySocketClient({ isUnload: true }); @@ -208,7 +215,7 @@ class CollabWrapper extends PureComponent { ); if ( - this._isCollaborating && + this.isCollaborating() && (this.fileManager.shouldPreventUnload(syncableElements) || !isSavedToFirebase(this.portal, syncableElements)) ) { @@ -252,12 +259,7 @@ class CollabWrapper extends PureComponent { } }; - openPortal = async () => { - trackEvent("share", "room creation", `ui (${getFrame()})`); - return this.initializeSocketClient(null); - }; - - closePortal = () => { + stopCollaboration = (keepRemoteState = true) => { this.queueBroadcastAllElements.cancel(); this.queueSaveToFirebase.cancel(); this.loadImageFiles.cancel(); @@ -267,16 +269,26 @@ class CollabWrapper extends PureComponent { this.excalidrawAPI.getSceneElementsIncludingDeleted(), ), ); - if (window.confirm(t("alerts.collabStopOverridePrompt"))) { + + if (this.portal.socket && this.fallbackInitializationHandler) { + this.portal.socket.off( + "connect_error", + this.fallbackInitializationHandler, + ); + } + + if (!keepRemoteState) { + LocalData.fileStorage.reset(); + this.destroySocketClient(); + } else if (window.confirm(t("alerts.collabStopOverridePrompt"))) { // hack to ensure that we prefer we disregard any new browser state // that could have been saved in other tabs while we were collaborating resetBrowserStateVersions(); window.history.pushState({}, APP_NAME, window.location.origin); this.destroySocketClient(); - trackEvent("share", "room closed"); - this.props.onRoomClose?.(); + LocalData.fileStorage.reset(); const elements = this.excalidrawAPI .getSceneElementsIncludingDeleted() @@ -295,20 +307,20 @@ class CollabWrapper extends PureComponent { }; private destroySocketClient = (opts?: { isUnload: boolean }) => { + this.lastBroadcastedOrReceivedSceneVersion = -1; + this.portal.close(); + this.fileManager.reset(); if (!opts?.isUnload) { + this.setIsCollaborating(false); + this.setState({ + activeRoomLink: "", + }); this.collaborators = new Map(); this.excalidrawAPI.updateScene({ collaborators: this.collaborators, }); - this.setState({ - activeRoomLink: "", - }); - this._isCollaborating = false; LocalData.resumeSave("collaboration"); } - this.lastBroadcastedOrReceivedSceneVersion = -1; - this.portal.close(); - this.fileManager.reset(); }; private fetchImageFilesFromFirebase = async (scene: { @@ -349,7 +361,9 @@ class CollabWrapper extends PureComponent { } }; - private initializeSocketClient = async ( + private fallbackInitializationHandler: null | (() => any) = null; + + startCollaboration = async ( existingRoomLinkData: null | { roomId: string; roomKey: string }, ): Promise => { if (this.portal.socket) { @@ -372,13 +386,23 @@ class CollabWrapper extends PureComponent { const scenePromise = resolvablePromise(); - this._isCollaborating = true; + this.setIsCollaborating(true); LocalData.pauseSave("collaboration"); const { default: socketIOClient } = await import( /* webpackChunkName: "socketIoClient" */ "socket.io-client" ); + const fallbackInitializationHandler = () => { + this.initializeRoom({ + roomLinkData: existingRoomLinkData, + fetchScene: true, + }).then((scene) => { + scenePromise.resolve(scene); + }); + }; + this.fallbackInitializationHandler = fallbackInitializationHandler; + try { const socketServerData = await getCollabServer(); @@ -391,6 +415,8 @@ class CollabWrapper extends PureComponent { roomId, roomKey, ); + + this.portal.socket.once("connect_error", fallbackInitializationHandler); } catch (error: any) { console.error(error); this.setState({ errorMessage: error.message }); @@ -419,13 +445,10 @@ class CollabWrapper extends PureComponent { // fallback in case you're not alone in the room but still don't receive // initial SCENE_INIT message - this.socketInitializationTimer = window.setTimeout(() => { - this.initializeRoom({ - roomLinkData: existingRoomLinkData, - fetchScene: true, - }); - scenePromise.resolve(null); - }, INITIAL_SCENE_UPDATE_TIMEOUT); + this.socketInitializationTimer = window.setTimeout( + fallbackInitializationHandler, + INITIAL_SCENE_UPDATE_TIMEOUT, + ); // All socket listeners are moving to Portal this.portal.socket.on( @@ -530,6 +553,12 @@ class CollabWrapper extends PureComponent { } | { fetchScene: false; roomLinkData?: null }) => { clearTimeout(this.socketInitializationTimer!); + if (this.portal.socket && this.fallbackInitializationHandler) { + this.portal.socket.off( + "connect_error", + this.fallbackInitializationHandler, + ); + } if (fetchScene && roomLinkData && this.portal.socket) { this.excalidrawAPI.resetScene(); @@ -567,6 +596,8 @@ class CollabWrapper extends PureComponent { const localElements = this.getSceneElementsIncludingDeleted(); const appState = this.excalidrawAPI.getAppState(); + remoteElements = restoreElements(remoteElements, null); + const reconciledElements = _reconcileElements( localElements, remoteElements, @@ -672,19 +703,17 @@ class CollabWrapper extends PureComponent { }; setCollaborators(sockets: string[]) { - this.setState((state) => { - const collaborators: InstanceType["collaborators"] = - new Map(); - for (const socketId of sockets) { - if (this.collaborators.has(socketId)) { - collaborators.set(socketId, this.collaborators.get(socketId)!); - } else { - collaborators.set(socketId, {}); - } + const collaborators: InstanceType["collaborators"] = + new Map(); + for (const socketId of sockets) { + if (this.collaborators.has(socketId)) { + collaborators.set(socketId, this.collaborators.get(socketId)!); + } else { + collaborators.set(socketId, {}); } - this.collaborators = collaborators; - this.excalidrawAPI.updateScene({ collaborators }); - }); + } + this.collaborators = collaborators; + this.excalidrawAPI.updateScene({ collaborators }); } public setLastBroadcastedOrReceivedSceneVersion = (version: number) => { @@ -713,7 +742,6 @@ class CollabWrapper extends PureComponent { ); onIdleStateChange = (userState: UserIdleState) => { - this.setState({ userState }); this.portal.broadcastIdleChange(userState); }; @@ -747,18 +775,22 @@ class CollabWrapper extends PureComponent { this.setLastBroadcastedOrReceivedSceneVersion(newVersion); }, SYNC_FULL_SCENE_INTERVAL_MS); - queueSaveToFirebase = throttle(() => { - if (this.portal.socketInitialized) { - this.saveCollabRoomToFirebase( - getSyncableElements( - this.excalidrawAPI.getSceneElementsIncludingDeleted(), - ), - ); - } - }, SYNC_FULL_SCENE_INTERVAL_MS); + queueSaveToFirebase = throttle( + () => { + if (this.portal.socketInitialized) { + this.saveCollabRoomToFirebase( + getSyncableElements( + this.excalidrawAPI.getSceneElementsIncludingDeleted(), + ), + ); + } + }, + SYNC_FULL_SCENE_INTERVAL_MS, + { leading: false }, + ); handleClose = () => { - this.setState({ modalIsShown: false }); + jotaiStore.set(collabDialogShownAtom, false); }; setUsername = (username: string) => { @@ -770,35 +802,10 @@ class CollabWrapper extends PureComponent { saveUsernameToLocalStorage(username); }; - onCollabButtonClick = () => { - this.setState({ - modalIsShown: true, - }); - }; - - /** PRIVATE. Use `this.getContextValue()` instead. */ - private contextValue: CollabAPI | null = null; - - /** Getter of context value. Returned object is stable. */ - getContextValue = (): CollabAPI => { - if (!this.contextValue) { - this.contextValue = {} as CollabAPI; - } - - this.contextValue.isCollaborating = this.isCollaborating; - this.contextValue.username = this.state.username; - this.contextValue.onPointerUpdate = this.onPointerUpdate; - this.contextValue.initializeSocketClient = this.initializeSocketClient; - this.contextValue.onCollabButtonClick = this.onCollabButtonClick; - this.contextValue.syncElements = this.syncElements; - this.contextValue.fetchImageFilesFromFirebase = - this.fetchImageFilesFromFirebase; - this.contextValue.setUsername = this.setUsername; - return this.contextValue; - }; - render() { - const { modalIsShown, username, errorMessage, activeRoomLink } = this.state; + const { username, errorMessage, activeRoomLink } = this.state; + + const { modalIsShown } = this.props; return ( <> @@ -808,8 +815,8 @@ class CollabWrapper extends PureComponent { activeRoomLink={activeRoomLink} username={username} onUsernameChange={this.onUsernameChange} - onRoomCreate={this.openPortal} - onRoomDestroy={this.closePortal} + onRoomCreate={() => this.startCollaboration(null)} + onRoomDestroy={this.stopCollaboration} setErrorMessage={(errorMessage) => { this.setState({ errorMessage }); }} @@ -822,11 +829,6 @@ class CollabWrapper extends PureComponent { onClose={() => this.setState({ errorMessage: "" })} /> )} - ); } @@ -834,7 +836,7 @@ class CollabWrapper extends PureComponent { declare global { interface Window { - collab: InstanceType; + collab: InstanceType; } } @@ -845,4 +847,11 @@ if ( window.collab = window.collab || ({} as Window["collab"]); } -export default CollabWrapper; +const _Collab: React.FC = (props) => { + const [collabDialogShown] = useAtom(collabDialogShownAtom); + return ; +}; + +export default _Collab; + +export type TCollabClass = Collab; diff --git a/src/excalidraw-app/collab/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx index 3a60e1df7..95e0e7aa4 100644 --- a/src/excalidraw-app/collab/Portal.tsx +++ b/src/excalidraw-app/collab/Portal.tsx @@ -4,7 +4,7 @@ import { SocketUpdateDataSource, } from "../data"; -import CollabWrapper from "./CollabWrapper"; +import { TCollabClass } from "./Collab"; import { ExcalidrawElement } from "../../element/types"; import { @@ -20,14 +20,14 @@ import { BroadcastedExcalidrawElement } from "./reconciliation"; import { encryptData } from "../../data/encryption"; class Portal { - collab: CollabWrapper; + collab: TCollabClass; socket: SocketIOClient.Socket | null = null; socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized roomId: string | null = null; roomKey: string | null = null; broadcastedElementVersions: Map = new Map(); - constructor(collab: CollabWrapper) { + constructor(collab: TCollabClass) { this.collab = collab; } diff --git a/src/excalidraw-app/collab/RoomDialog.tsx b/src/excalidraw-app/collab/RoomDialog.tsx index ac3fc27c5..724856b3d 100644 --- a/src/excalidraw-app/collab/RoomDialog.tsx +++ b/src/excalidraw-app/collab/RoomDialog.tsx @@ -14,6 +14,8 @@ import { t } from "../../i18n"; import "./RoomDialog.scss"; import Stack from "../../components/Stack"; import { AppState } from "../../types"; +import { trackEvent } from "../../analytics"; +import { getFrame } from "../../utils"; const getShareIcon = () => { const navigator = window.navigator as any; @@ -95,7 +97,10 @@ const RoomDialog = ({ title={t("roomDialog.button_startSession")} aria-label={t("roomDialog.button_startSession")} showAriaLabel={true} - onClick={onRoomCreate} + onClick={() => { + trackEvent("share", "room creation", `ui (${getFrame()})`); + onRoomCreate(); + }} />
@@ -160,7 +165,10 @@ const RoomDialog = ({ title={t("roomDialog.button_stopSession")} aria-label={t("roomDialog.button_stopSession")} showAriaLabel={true} - onClick={onRoomDestroy} + onClick={() => { + trackEvent("share", "room closed"); + onRoomDestroy(); + }} />
diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts index 705347fc1..a74c439f1 100644 --- a/src/excalidraw-app/data/index.ts +++ b/src/excalidraw-app/data/index.ts @@ -134,9 +134,16 @@ export type SocketUpdateData = _brand: "socketUpdateData"; }; +const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/; + +export const isCollaborationLink = (link: string) => { + const hash = new URL(link).hash; + return RE_COLLAB_LINK.test(hash); +}; + export const getCollaborationLinkData = (link: string) => { const hash = new URL(link).hash; - const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/); + const match = hash.match(RE_COLLAB_LINK); if (match && match[2].length !== 22) { window.alert(t("alerts.invalidEncryptionKey")); return null; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index c67e48bce..f8ff9541f 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -1,5 +1,5 @@ import LanguageDetector from "i18next-browser-languagedetector"; -import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { trackEvent } from "../analytics"; import { getDefaultAppState } from "../appState"; import { ErrorDialog } from "../components/ErrorDialog"; @@ -45,20 +45,26 @@ import { STORAGE_KEYS, SYNC_BROWSER_TABS_TIMEOUT, } from "./app_constants"; -import CollabWrapper, { +import Collab, { CollabAPI, - CollabContext, - CollabContextConsumer, -} from "./collab/CollabWrapper"; + collabAPIAtom, + collabDialogShownAtom, + isCollaboratingAtom, +} from "./collab/Collab"; import { LanguageList } from "./components/LanguageList"; -import { exportToBackend, getCollaborationLinkData, loadScene } from "./data"; +import { + exportToBackend, + getCollaborationLinkData, + isCollaborationLink, + loadScene, +} from "./data"; import { getLibraryItemsFromStorage, importFromLocalStorage, importUsernameFromLocalStorage, } from "./data/localStorage"; import CustomStats from "./CustomStats"; -import { restoreAppState, RestoredDataState } from "../data/restore"; +import { restore, restoreAppState, RestoredDataState } from "../data/restore"; import { Tooltip } from "../components/Tooltip"; import { shield } from "../components/icons"; @@ -72,6 +78,9 @@ import { loadFilesFromFirebase } from "./data/firebase"; import { LocalData } from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import clsx from "clsx"; +import { Provider, useAtom } from "jotai"; +import { jotaiStore, useAtomWithInitialValue } from "../jotai"; +import { reconcileElements } from "./collab/reconciliation"; import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; const isExcalidrawPlusSignedUser = document.cookie.includes( @@ -170,7 +179,7 @@ const initializeScene = async (opts: { if (roomLinkData) { return { - scene: await opts.collabAPI.initializeSocketClient(roomLinkData), + scene: await opts.collabAPI.startCollaboration(roomLinkData), isExternalScene: true, id: roomLinkData.roomId, key: roomLinkData.roomKey, @@ -242,7 +251,11 @@ const ExcalidrawWrapper = () => { const [excalidrawAPI, excalidrawRefCallback] = useCallbackRefState(); - const collabAPI = useContext(CollabContext)?.api; + const [collabAPI] = useAtom(collabAPIAtom); + const [, setCollabDialogShown] = useAtom(collabDialogShownAtom); + const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { + return isCollaborationLink(window.location.href); + }); useHandleLibrary({ excalidrawAPI, @@ -320,21 +333,44 @@ const ExcalidrawWrapper = () => { } }; - initializeScene({ collabAPI }).then((data) => { + initializeScene({ collabAPI }).then(async (data) => { loadImages(data, /* isInitialLoad */ true); - initialStatePromiseRef.current.promise.resolve(data.scene); + + initialStatePromiseRef.current.promise.resolve({ + ...data.scene, + // at this point the state may have already been updated (e.g. when + // collaborating, we may have received updates from other clients) + appState: restoreAppState( + data.scene?.appState, + excalidrawAPI.getAppState(), + ), + elements: reconcileElements( + data.scene?.elements || [], + excalidrawAPI.getSceneElementsIncludingDeleted(), + excalidrawAPI.getAppState(), + ), + }); }); const onHashChange = async (event: HashChangeEvent) => { event.preventDefault(); const libraryUrlTokens = parseLibraryTokensFromUrl(); if (!libraryUrlTokens) { + if ( + collabAPI.isCollaborating() && + !isCollaborationLink(window.location.href) + ) { + collabAPI.stopCollaboration(false); + } + excalidrawAPI.updateScene({ appState: { isLoading: true } }); + initializeScene({ collabAPI }).then((data) => { loadImages(data); if (data.scene) { excalidrawAPI.updateScene({ ...data.scene, - appState: restoreAppState(data.scene.appState, null), + ...restore(data.scene, null, null), + commitToHistory: true, }); } }); @@ -636,23 +672,19 @@ const ExcalidrawWrapper = () => { localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems); }; - const onRoomClose = useCallback(() => { - LocalData.fileStorage.reset(); - }, []); - return (
setCollabDialogShown(true)} + isCollaborating={isCollaborating} onPointerUpdate={collabAPI?.onPointerUpdate} UIOptions={{ canvasActions: { @@ -686,12 +718,7 @@ const ExcalidrawWrapper = () => { onLibraryChange={onLibraryChange} autoFocus={true} /> - {excalidrawAPI && ( - - )} + {excalidrawAPI && } {errorMessage && ( { const ExcalidrawApp = () => { return ( - + jotaiStore}> - + ); }; diff --git a/src/jotai.ts b/src/jotai.ts index e26bab1d3..c730cbbc1 100644 --- a/src/jotai.ts +++ b/src/jotai.ts @@ -1,4 +1,27 @@ -import { unstable_createStore } from "jotai"; +import { unstable_createStore, useAtom, WritableAtom } from "jotai"; +import { useLayoutEffect } from "react"; export const jotaiScope = Symbol(); export const jotaiStore = unstable_createStore(); + +export const useAtomWithInitialValue = < + T extends unknown, + A extends WritableAtom, +>( + atom: A, + initialValue: T | (() => T), +) => { + const [value, setValue] = useAtom(atom); + + useLayoutEffect(() => { + if (typeof initialValue === "function") { + // @ts-ignore + setValue(initialValue()); + } else { + setValue(initialValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return [value, setValue] as const; +}; diff --git a/src/tests/collab.test.tsx b/src/tests/collab.test.tsx index 84fd9e3a9..42ad571a3 100644 --- a/src/tests/collab.test.tsx +++ b/src/tests/collab.test.tsx @@ -50,6 +50,7 @@ jest.mock("socket.io-client", () => { return { close: () => {}, on: () => {}, + once: () => {}, off: () => {}, emit: () => {}, }; @@ -77,7 +78,7 @@ describe("collaboration", () => { ]); expect(API.getStateHistory().length).toBe(1); }); - window.collab.openPortal(); + window.collab.startCollaboration(null); await waitFor(() => { expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); expect(API.getStateHistory().length).toBe(1); From 76a5bb060e93359c635fa68d6ba42c6c81caab7c Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 5 Jul 2022 21:43:59 +0530 Subject: [PATCH 31/31] feat: make toast closable and allow custom duration (#5308) * feat: make toast closable and allow custom duration * use Infinity to keep prevent auto close * rename to DEFAULT_TOAST_TIMEOUT and move to toast.tsx * fix * set closable as false by default and fix design * tweak css * reuse variables Co-authored-by: dwelle --- src/components/Toast.scss | 24 +++++++++++++++++---- src/components/Toast.tsx | 45 ++++++++++++++++++++++++++++++--------- src/constants.ts | 1 - 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/components/Toast.scss b/src/components/Toast.scss index 63d949a6b..d236edd1f 100644 --- a/src/components/Toast.scss +++ b/src/components/Toast.scss @@ -2,6 +2,9 @@ .excalidraw { .Toast { + $closeButtonSize: 1.2rem; + $closeButtonPadding: 0.4rem; + animation: fade-in 0.5s; background-color: var(--button-gray-1); border-radius: 4px; @@ -15,11 +18,24 @@ text-align: center; width: 300px; z-index: 999999; - } - .Toast__message { - color: var(--popup-text-color); - white-space: pre-wrap; + .Toast__message { + padding: 0 $closeButtonSize + ($closeButtonPadding); + color: var(--popup-text-color); + white-space: pre-wrap; + } + + .close { + position: absolute; + top: 0; + right: 0; + padding: $closeButtonPadding; + + .ToolIcon__icon { + width: $closeButtonSize; + height: $closeButtonSize; + } + } } @keyframes fade-in { diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 22b627508..e6fd3648b 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -1,34 +1,59 @@ import { useCallback, useEffect, useRef } from "react"; -import { TOAST_TIMEOUT } from "../constants"; +import { close } from "./icons"; import "./Toast.scss"; +import { ToolButton } from "./ToolButton"; + +const DEFAULT_TOAST_TIMEOUT = 5000; export const Toast = ({ message, clearToast, + closable = false, + // To prevent autoclose, pass duration as Infinity + duration = DEFAULT_TOAST_TIMEOUT, }: { message: string; clearToast: () => void; + closable?: boolean; + duration?: number; }) => { const timerRef = useRef(0); - - const scheduleTimeout = useCallback( - () => - (timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)), - [clearToast], - ); + const shouldAutoClose = duration !== Infinity; + const scheduleTimeout = useCallback(() => { + if (!shouldAutoClose) { + return; + } + timerRef.current = window.setTimeout(() => clearToast(), duration); + }, [clearToast, duration, shouldAutoClose]); useEffect(() => { + if (!shouldAutoClose) { + return; + } scheduleTimeout(); return () => clearTimeout(timerRef.current); - }, [scheduleTimeout, message]); + }, [scheduleTimeout, message, duration, shouldAutoClose]); + const onMouseEnter = shouldAutoClose + ? () => clearTimeout(timerRef?.current) + : undefined; + const onMouseLeave = shouldAutoClose ? scheduleTimeout : undefined; return (
clearTimeout(timerRef?.current)} - onMouseLeave={scheduleTimeout} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} >

{message}

+ {closable && ( + + )}
); }; diff --git a/src/constants.ts b/src/constants.ts index 9bbacd8f7..b1638f39f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -116,7 +116,6 @@ export const IMAGE_RENDER_TIMEOUT = 500; export const TAP_TWICE_TIMEOUT = 300; export const TOUCH_CTX_MENU_TIMEOUT = 500; export const TITLE_TIMEOUT = 10000; -export const TOAST_TIMEOUT = 5000; export const VERSION_TIMEOUT = 30000; export const SCROLL_TIMEOUT = 100; export const ZOOM_STEP = 0.1;