From 95d669390fde2550c42d46bd47b19440569fa538 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Sun, 18 Dec 2022 22:23:30 +0100 Subject: [PATCH 001/252] fix: PWA not working after CRA@5 update (#6012) * fix: PWA not working after CRA@5 update * fix: fallback to default locale when fetch fails --- package.json | 22 ++- public/service-worker.js | 81 ---------- scripts/prebuild.js | 21 --- src/excalidraw-app/pwa.ts | 2 +- src/i18n.ts | 11 +- src/service-worker.ts | 147 ++++++++++++++++++ ...orker.tsx => serviceWorkerRegistration.ts} | 0 yarn.lock | 126 ++++++++++++--- 8 files changed, 282 insertions(+), 128 deletions(-) delete mode 100644 public/service-worker.js delete mode 100644 scripts/prebuild.js create mode 100644 src/service-worker.ts rename src/{serviceWorker.tsx => serviceWorkerRegistration.ts} (100%) diff --git a/package.json b/package.json index 60a831e14..bf9414c44 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/socket.io-client": "1.4.36", "browser-fs-access": "0.29.1", "clsx": "1.1.1", + "cross-env": "7.0.3", "fake-indexeddb": "3.1.7", "firebase": "8.3.3", "i18next-browser-languagedetector": "6.1.4", @@ -54,7 +55,19 @@ "roughjs": "4.5.2", "sass": "1.51.0", "socket.io-client": "2.3.1", - "typescript": "4.5.5" + "typescript": "4.5.5", + "workbox-background-sync": "^6.5.4", + "workbox-broadcast-update": "^6.5.4", + "workbox-cacheable-response": "^6.5.4", + "workbox-core": "^6.5.4", + "workbox-expiration": "^6.5.4", + "workbox-google-analytics": "^6.5.4", + "workbox-navigation-preload": "^6.5.4", + "workbox-precaching": "^6.5.4", + "workbox-range-requests": "^6.5.4", + "workbox-routing": "^6.5.4", + "workbox-strategies": "^6.5.4", + "workbox-streams": "^6.5.4" }, "devDependencies": { "@excalidraw/eslint-config": "1.0.0", @@ -67,6 +80,7 @@ "dotenv": "16.0.1", "eslint-config-prettier": "8.5.0", "eslint-plugin-prettier": "3.3.1", + "http-server": "14.1.1", "husky": "7.0.4", "jest-canvas-mock": "2.4.0", "lint-staged": "12.3.7", @@ -90,10 +104,9 @@ "scripts": { "build-node": "node ./scripts/build-node.js", "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:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build", "build:version": "node ./scripts/build-version.js", - "build:prebuild": "node ./scripts/prebuild.js", - "build": "yarn build:prebuild && yarn build:app && yarn build:version", + "build": "yarn build:app && yarn build:version", "eject": "react-scripts eject", "fix:code": "yarn test:code --fix", "fix:other": "yarn prettier --write", @@ -103,6 +116,7 @@ "prepare": "husky install", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "start": "react-scripts start", + "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false", "test:app": "react-scripts test --passWithNoTests", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", diff --git a/public/service-worker.js b/public/service-worker.js deleted file mode 100644 index cbbdb82b1..000000000 --- a/public/service-worker.js +++ /dev/null @@ -1,81 +0,0 @@ -// eslint-disable-next-line no-restricted-globals -// eslint-disable-next-line no-unused-expressions - -/* eslint-disable no-restricted-globals */ -/* global importScripts, workbox */ - -/** - * Welcome to your Workbox-powered service worker! - * - * You'll need to register this file in your web app and you should - * disable HTTP caching for this file too. - * See https://goo.gl/nhQhGp - * - * The rest of the code is auto-generated. Please don't update this file - * directly; instead, make changes to your Workbox build configuration - * and re-run your build process. - * See https://goo.gl/2aRDsh - */ - -// in dev, `process` is undefined because this file is not compiled until build -const IS_DEVELOPMENT = - typeof process === "undefined" || process.env.NODE_ENV !== "production"; - -if (IS_DEVELOPMENT) { - importScripts( - "https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js", - ); - workbox.setConfig({ - debug: true, - }); -} else { - importScripts("/workbox/workbox-sw.js"); - workbox.setConfig({ - modulePathPrefix: "/workbox/", - }); -} - -self.addEventListener("message", (event) => { - if (event.data && event.data.type === "SKIP_WAITING") { - self.skipWaiting(); - } -}); - -workbox.core.clientsClaim(); - -if (!IS_DEVELOPMENT) { - workbox.precaching.precacheAndRoute(self.__WB_MANIFEST); - - workbox.routing.registerNavigationRoute( - workbox.precaching.getCacheKeyForURL("./index.html"), - { - blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/], - }, - ); -} - -// Cache relevant font files -workbox.routing.registerRoute( - new RegExp("/(fonts.css|.+.(ttf|woff2|otf))"), - new workbox.strategies.StaleWhileRevalidate({ - cacheName: "fonts", - plugins: [new workbox.expiration.Plugin({ maxEntries: 10 })], - }), -); - -self.addEventListener("fetch", (event) => { - if ( - event.request.method === "POST" && - event.request.url.endsWith("/web-share-target") - ) { - return event.respondWith( - (async () => { - const formData = await event.request.formData(); - const file = formData.get("file"); - const webShareTargetCache = await caches.open("web-share-target"); - await webShareTargetCache.put("shared-file", new Response(file)); - return Response.redirect("/?web-share-target", 303); - })(), - ); - } -}); diff --git a/scripts/prebuild.js b/scripts/prebuild.js deleted file mode 100644 index 64b4a256b..000000000 --- a/scripts/prebuild.js +++ /dev/null @@ -1,21 +0,0 @@ -const fs = require("fs"); -const path = require("path"); - -// for development purposes we want to have the service-worker.js file -// accessible from the public folder. On build though, we need to compile it -// and CRA expects that file to be in src/ folder. -const moveServiceWorkerScript = () => { - const oldPath = path.resolve(__dirname, "../public/service-worker.js"); - const newPath = path.resolve(__dirname, "../src/service-worker.js"); - - fs.rename(oldPath, newPath, (error) => { - if (error) { - throw error; - } - console.info("public/service-worker.js moved to src/"); - }); -}; - -// ----------------------------------------------------------------------------- - -moveServiceWorkerScript(); diff --git a/src/excalidraw-app/pwa.ts b/src/excalidraw-app/pwa.ts index bdf92552f..69cb33ecb 100644 --- a/src/excalidraw-app/pwa.ts +++ b/src/excalidraw-app/pwa.ts @@ -1,4 +1,4 @@ -import { register as registerServiceWorker } from "../serviceWorker"; +import { register as registerServiceWorker } from "../serviceWorkerRegistration"; import { EVENT } from "../constants"; // On Apple mobile devices add the proprietary app icon and splashscreen markup. diff --git a/src/i18n.ts b/src/i18n.ts index efc00f20d..2d4e52f13 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -90,9 +90,14 @@ export const setLanguage = async (lang: Language) => { if (lang.code.startsWith(TEST_LANG_CODE)) { currentLangData = {}; } else { - currentLangData = await import( - /* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json` - ); + try { + currentLangData = await import( + /* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json` + ); + } catch (error: any) { + console.error(`Failed to load language ${lang.code}:`, error.message); + currentLangData = fallbackLangData; + } } }; diff --git a/src/service-worker.ts b/src/service-worker.ts new file mode 100644 index 000000000..3e3a4355f --- /dev/null +++ b/src/service-worker.ts @@ -0,0 +1,147 @@ +/// +/* eslint-disable no-restricted-globals */ + +// This service worker can be customized! +// See https://developers.google.com/web/tools/workbox/modules +// for the list of available Workbox modules, or add any other +// code you'd like. +// You can also remove this file if you'd prefer not to use a +// service worker, and the Workbox build step will be skipped. + +import { clientsClaim } from "workbox-core"; +import { ExpirationPlugin } from "workbox-expiration"; +import { precacheAndRoute, createHandlerBoundToURL } from "workbox-precaching"; +import { registerRoute } from "workbox-routing"; +import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies"; + +declare const self: ServiceWorkerGlobalScope; + +clientsClaim(); + +// Precache assets generated by your build process. +// +// Their URLs are injected into the __WB_MANIFEST during build (by workbox). +// +// This variable must be present somewhere in your service worker file, +// even if you decide not to use precaching. See https://cra.link/PWA. +// +// We don't want to precache i18n files so we filter them out +// (normally this should be configured in a webpack workbox plugin, but we don't +// have access to it in CRA) — this is because all users will use at most +// one or two languages, so there's no point fetching all of them. (They'll +// be cached as you load them.) +const manifest = self.__WB_MANIFEST.filter((entry) => { + return !/locales\/[\w-]+json/.test( + typeof entry === "string" ? entry : entry.url, + ); +}); + +precacheAndRoute(manifest); + +// Set up App Shell-style routing, so that all navigation requests +// are fulfilled with your index.html shell. Learn more at +// https://developer.chrome.com/docs/workbox/app-shell-model/ +// +// below is copied verbatim from CRA@5 +const fileExtensionRegexp = new RegExp("/[^/?]+\\.[^/]+$"); +registerRoute( + // Return false to exempt requests from being fulfilled by index.html. + ({ request, url }: { request: Request; url: URL }) => { + // If this isn't a navigation, skip. + if (request.mode !== "navigate") { + return false; + } + + // If this is a URL that starts with /_, skip. + if (url.pathname.startsWith("/_")) { + return false; + } + + // If this looks like a URL for a resource, because it contains + // a file extension, skip. + if (url.pathname.match(fileExtensionRegexp)) { + return false; + } + + // Return true to signal that we want to use the handler. + return true; + }, + createHandlerBoundToURL(`${process.env.PUBLIC_URL}/index.html`), +); + +// Cache resources that aren't being precached +// ----------------------------------------------------------------------------- + +registerRoute( + new RegExp("/fonts.css"), + new StaleWhileRevalidate({ + cacheName: "fonts", + plugins: [ + // Ensure that once this runtime cache reaches a maximum size the + // least-recently used images are removed. + new ExpirationPlugin({ maxEntries: 50 }), + ], + }), +); + +// since we serve fonts from, don't forget to append new ?v= param when +// updating fonts (glyphs) without changing the filename +registerRoute( + new RegExp("/.+.(ttf|woff2|otf)"), + new CacheFirst({ + cacheName: "fonts", + plugins: [ + // Ensure that once this runtime cache reaches a maximum size the + // least-recently used images are removed. + new ExpirationPlugin({ + maxEntries: 50, + // 90 days + maxAgeSeconds: 7776000000, + }), + ], + }), +); + +registerRoute( + new RegExp("/locales\\/[\\w-]+json"), + // Customize this strategy as needed, e.g., by changing to CacheFirst. + new CacheFirst({ + cacheName: "locales", + plugins: [ + // Ensure that once this runtime cache reaches a maximum size the + // least-recently used images are removed. + new ExpirationPlugin({ + maxEntries: 50, + // 30 days + maxAgeSeconds: 2592000000, + }), + ], + }), +); + +// ----------------------------------------------------------------------------- + +self.addEventListener("fetch", (event) => { + if ( + event.request.method === "POST" && + event.request.url.endsWith("/web-share-target") + ) { + return event.respondWith( + (async () => { + const formData = await event.request.formData(); + const file = formData.get("file"); + const webShareTargetCache = await caches.open("web-share-target"); + await webShareTargetCache.put("shared-file", new Response(file)); + return Response.redirect("/?web-share-target", 303); + })(), + ); + } +}); + +// This allows the web app to trigger skipWaiting via +// registration.waiting.postMessage({type: 'SKIP_WAITING'}) +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "SKIP_WAITING") { + self.skipWaiting(); + } +}); diff --git a/src/serviceWorker.tsx b/src/serviceWorkerRegistration.ts similarity index 100% rename from src/serviceWorker.tsx rename to src/serviceWorkerRegistration.ts diff --git a/yarn.lock b/yarn.lock index ccb509009..1e2d24b96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3407,6 +3407,13 @@ async-limiter@~1.0.0: resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== +async@^2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" + integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA== + dependencies: + lodash "^4.17.14" + async@^3.2.3: version "3.2.4" resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" @@ -3610,6 +3617,13 @@ base64-arraybuffer@0.1.4: resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812" integrity sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg== +basic-auth@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" + integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== + dependencies: + safe-buffer "5.1.2" + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -4149,6 +4163,11 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +corser@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/corser/-/corser-2.0.1.tgz#8eda252ecaab5840dcd975ceb90d9370c819ff87" + integrity sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ== + cosmiconfig@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" @@ -4176,7 +4195,14 @@ crc-32@^0.3.0: resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e" integrity sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA== -cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-env@7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -5909,6 +5935,13 @@ html-encoding-sniffer@^2.0.1: dependencies: whatwg-encoding "^1.0.5" +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== + dependencies: + whatwg-encoding "^2.0.0" + html-entities@^2.1.0, html-entities@^2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46" @@ -6013,6 +6046,25 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" +http-server@14.1.1: + version "14.1.1" + resolved "https://registry.yarnpkg.com/http-server/-/http-server-14.1.1.tgz#d60fbb37d7c2fdff0f0fbff0d0ee6670bd285e2e" + integrity sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A== + dependencies: + basic-auth "^2.0.1" + chalk "^4.1.2" + corser "^2.0.1" + he "^1.2.0" + html-encoding-sniffer "^3.0.0" + http-proxy "^1.18.1" + mime "^1.6.0" + minimist "^1.2.6" + opener "^1.5.1" + portfinder "^1.0.28" + secure-compare "3.0.1" + union "~0.5.0" + url-join "^4.0.1" + https-proxy-agent@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -6045,7 +6097,7 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@^0.6.3: +iconv-lite@0.6.3, iconv-lite@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -7358,7 +7410,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== -lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -7499,7 +7551,7 @@ mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, dependencies: mime-db "1.52.0" -mime@1.6.0: +mime@1.6.0, mime@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -7545,7 +7597,7 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== -mkdirp@~0.5.1: +mkdirp@^0.5.6, mkdirp@~0.5.1: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -7826,6 +7878,11 @@ open@^8.0.9, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +opener@^1.5.1: + version "1.5.2" + resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" + integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -8111,6 +8168,15 @@ points-on-path@^0.2.1: path-data-parser "0.1.0" points-on-curve "0.2.0" +portfinder@^1.0.28: + version "1.0.32" + resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81" + integrity sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg== + dependencies: + async "^2.6.4" + debug "^3.2.7" + mkdirp "^0.5.6" + postcss-attribute-case-insensitive@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz#03d761b24afc04c09e757e92ff53716ae8ea2741" @@ -8830,7 +8896,7 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw== -qs@6.11.0: +qs@6.11.0, qs@^6.4.0: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== @@ -9417,6 +9483,11 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.0.0" +secure-compare@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/secure-compare/-/secure-compare-3.0.1.tgz#f1a0329b308b221fae37b9974f3d578d0ca999e3" + integrity sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -10277,6 +10348,13 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== +union@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/union/-/union-0.5.0.tgz#b2c11be84f60538537b846edb9ba266ba0090075" + integrity sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA== + dependencies: + qs "^6.4.0" + unique-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" @@ -10324,6 +10402,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-join@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.1.tgz#b642e21a2646808ffa178c4c5fda39844e12cde7" + integrity sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA== + url-parse@^1.5.3: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" @@ -10563,6 +10646,13 @@ whatwg-encoding@^1.0.5: dependencies: iconv-lite "0.4.24" +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== + dependencies: + iconv-lite "0.6.3" + whatwg-fetch@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" @@ -10648,7 +10738,7 @@ word-wrap@^1.2.3, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -workbox-background-sync@6.5.4: +workbox-background-sync@6.5.4, workbox-background-sync@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz#3141afba3cc8aa2ae14c24d0f6811374ba8ff6a9" integrity sha512-0r4INQZMyPky/lj4Ou98qxcThrETucOde+7mRGJl13MPJugQNKeZQOdIJe/1AchOP23cTqHcN/YVpD6r8E6I8g== @@ -10656,7 +10746,7 @@ workbox-background-sync@6.5.4: idb "^7.0.1" workbox-core "6.5.4" -workbox-broadcast-update@6.5.4: +workbox-broadcast-update@6.5.4, workbox-broadcast-update@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-6.5.4.tgz#8441cff5417cd41f384ba7633ca960a7ffe40f66" integrity sha512-I/lBERoH1u3zyBosnpPEtcAVe5lwykx9Yg1k6f8/BGEPGaMMgZrwVrqL1uA9QZ1NGGFoyE6t9i7lBjOlDhFEEw== @@ -10706,19 +10796,19 @@ workbox-build@6.5.4: workbox-sw "6.5.4" workbox-window "6.5.4" -workbox-cacheable-response@6.5.4: +workbox-cacheable-response@6.5.4, workbox-cacheable-response@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-6.5.4.tgz#a5c6ec0c6e2b6f037379198d4ef07d098f7cf137" integrity sha512-DCR9uD0Fqj8oB2TSWQEm1hbFs/85hXXoayVwFKLVuIuxwJaihBsLsp4y7J9bvZbqtPJ1KlCkmYVGQKrBU4KAug== dependencies: workbox-core "6.5.4" -workbox-core@6.5.4: +workbox-core@6.5.4, workbox-core@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-6.5.4.tgz#df48bf44cd58bb1d1726c49b883fb1dffa24c9ba" integrity sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q== -workbox-expiration@6.5.4: +workbox-expiration@6.5.4, workbox-expiration@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.5.4.tgz#501056f81e87e1d296c76570bb483ce5e29b4539" integrity sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ== @@ -10726,7 +10816,7 @@ workbox-expiration@6.5.4: idb "^7.0.1" workbox-core "6.5.4" -workbox-google-analytics@6.5.4: +workbox-google-analytics@6.5.4, workbox-google-analytics@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-6.5.4.tgz#c74327f80dfa4c1954cbba93cd7ea640fe7ece7d" integrity sha512-8AU1WuaXsD49249Wq0B2zn4a/vvFfHkpcFfqAFHNHwln3jK9QUYmzdkKXGIZl9wyKNP+RRX30vcgcyWMcZ9VAg== @@ -10736,14 +10826,14 @@ workbox-google-analytics@6.5.4: workbox-routing "6.5.4" workbox-strategies "6.5.4" -workbox-navigation-preload@6.5.4: +workbox-navigation-preload@6.5.4, workbox-navigation-preload@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-6.5.4.tgz#ede56dd5f6fc9e860a7e45b2c1a8f87c1c793212" integrity sha512-IIwf80eO3cr8h6XSQJF+Hxj26rg2RPFVUmJLUlM0+A2GzB4HFbQyKkrgD5y2d84g2IbJzP4B4j5dPBRzamHrng== dependencies: workbox-core "6.5.4" -workbox-precaching@6.5.4: +workbox-precaching@6.5.4, workbox-precaching@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.5.4.tgz#740e3561df92c6726ab5f7471e6aac89582cab72" integrity sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg== @@ -10752,7 +10842,7 @@ workbox-precaching@6.5.4: workbox-routing "6.5.4" workbox-strategies "6.5.4" -workbox-range-requests@6.5.4: +workbox-range-requests@6.5.4, workbox-range-requests@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz#86b3d482e090433dab38d36ae031b2bb0bd74399" integrity sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg== @@ -10771,21 +10861,21 @@ workbox-recipes@6.5.4: workbox-routing "6.5.4" workbox-strategies "6.5.4" -workbox-routing@6.5.4: +workbox-routing@6.5.4, workbox-routing@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-6.5.4.tgz#6a7fbbd23f4ac801038d9a0298bc907ee26fe3da" integrity sha512-apQswLsbrrOsBUWtr9Lf80F+P1sHnQdYodRo32SjiByYi36IDyL2r7BH1lJtFX8fwNHDa1QOVY74WKLLS6o5Pg== dependencies: workbox-core "6.5.4" -workbox-strategies@6.5.4: +workbox-strategies@6.5.4, workbox-strategies@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.5.4.tgz#4edda035b3c010fc7f6152918370699334cd204d" integrity sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw== dependencies: workbox-core "6.5.4" -workbox-streams@6.5.4: +workbox-streams@6.5.4, workbox-streams@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.5.4.tgz#1cb3c168a6101df7b5269d0353c19e36668d7d69" integrity sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg== From 539505affd68fdb75b12e65fb5b22dcad7040c60 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Sun, 18 Dec 2022 23:06:01 +0100 Subject: [PATCH 002/252] fix: resize sometimes throwing on missing null-checks (#6013) --- src/element/resizeElements.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 46e1c0d1a..3782c232c 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -162,11 +162,12 @@ const rotateSingleElement = ( mutateElement(element, { angle }); if (boundTextElementId) { - const textElement = Scene.getScene(element)!.getElement( - boundTextElementId, - ) as ExcalidrawTextElementWithContainer; + const textElement = + Scene.getScene(element)?.getElement( + boundTextElementId, + ); - if (!isArrowElement(element)) { + if (textElement && !isArrowElement(element)) { mutateElement(textElement, { angle }); } } @@ -201,8 +202,10 @@ const measureFontSizeFromWH = ( const hasContainer = isBoundToContainer(element); if (hasContainer) { - const container = getContainerElement(element)!; - width = getMaxContainerWidth(container); + const container = getContainerElement(element); + if (container) { + width = getMaxContainerWidth(container); + } } const nextFontSize = element.fontSize * (nextWidth / width); if (nextFontSize < MIN_FONT_SIZE) { @@ -211,7 +214,7 @@ const measureFontSizeFromWH = ( const metrics = measureText( element.text, getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), - hasContainer ? width : null, + element.containerId ? width : null, ); return { size: nextFontSize, @@ -765,10 +768,11 @@ const rotateMultipleElements = ( }); const boundTextElementId = getBoundTextElementId(element); if (boundTextElementId) { - const textElement = Scene.getScene(element)!.getElement( - boundTextElementId, - ) as ExcalidrawTextElementWithContainer; - if (!isArrowElement(element)) { + const textElement = + Scene.getScene(element)?.getElement( + boundTextElementId, + ); + if (textElement && !isArrowElement(element)) { mutateElement(textElement, { x: textElement.x + (rotatedCX - cx), y: textElement.y + (rotatedCY - cy), From 6ab3f0eb74a4968189a774aa57a47be1dab128df Mon Sep 17 00:00:00 2001 From: David Luzar Date: Tue, 20 Dec 2022 13:22:20 +0100 Subject: [PATCH 003/252] fix: showing `grabbing` cursor when holding `spacebar` (#6015) --- src/components/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 347bb6fe9..edefacb01 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2120,7 +2120,7 @@ class App extends React.Component { } if (event.key === KEYS.SPACE && gesture.pointers.size === 0) { isHoldingSpace = true; - setCursor(this.canvas, CURSOR_TYPE.GRABBING); + setCursor(this.canvas, CURSOR_TYPE.GRAB); event.preventDefault(); } From d2e371cdf0ce25988c94fe67b518b215896f0b9b Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 21 Dec 2022 12:32:43 +0530 Subject: [PATCH 004/252] fix: don't push whitespace to next line when exceeding max width during wrapping and make sure to use same width of text editor on DOM when measuring dimensions (#5996) * fix: don't push whitespace to next line when exceeding max width during wrapping * add a helper function and never push empty line * use width same as in text area so dimensions are same * add tests * make sure dom element has exact same width as text editor --- src/element/textElement.test.ts | 43 ++++++++++++++++++- src/element/textElement.ts | 35 ++++++++------- src/element/textWysiwyg.test.tsx | 6 +-- .../data/__snapshots__/restore.test.ts.snap | 2 +- src/tests/linearElementEditor.test.tsx | 4 +- 5 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index e2c8ee43e..c9027205e 100644 --- a/src/element/textElement.test.ts +++ b/src/element/textElement.test.ts @@ -1,10 +1,17 @@ import { BOUND_TEXT_PADDING } from "../constants"; -import { wrapText } from "./textElement"; +import { measureText, wrapText } from "./textElement"; import { FontString } from "./types"; describe("Test wrapText", () => { const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; + it("shouldn't add new lines for trailing spaces", () => { + const text = "Hello whats up "; + const maxWidth = 200 - BOUND_TEXT_PADDING * 2; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello whats up "); + }); + describe("When text doesn't contain new lines", () => { const text = "Hello whats up"; [ @@ -139,3 +146,37 @@ break it now`, }); }); }); + +describe("Test measureText", () => { + const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; + const text = "Hello World"; + + it("should add correct attributes when maxWidth is passed", () => { + const maxWidth = 200 - BOUND_TEXT_PADDING * 2; + const res = measureText(text, font, maxWidth); + + expect(res.container).toMatchInlineSnapshot(` +
+ +
+ `); + }); + + it("should add correct attributes when maxWidth is not passed", () => { + const res = measureText(text, font); + + expect(res.container).toMatchInlineSnapshot(` +
+ +
+ `); + }); +}); diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 4f39d3612..3a03f936a 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -49,11 +49,7 @@ export const redrawTextBoundingBox = ( maxWidth, ); } - const metrics = measureText( - textElement.originalText, - getFontString(textElement), - maxWidth, - ); + const metrics = measureText(text, getFontString(textElement), maxWidth); let coordY = textElement.y; let coordX = textElement.x; // Resize container and vertically center align the text @@ -272,7 +268,7 @@ export const measureText = ( container.style.minHeight = "1em"; if (maxWidth) { const lineHeight = getApproxLineHeight(font); - container.style.maxWidth = `${String(maxWidth)}px`; + container.style.width = `${String(maxWidth + 1)}px`; container.style.overflow = "hidden"; container.style.wordBreak = "break-word"; container.style.lineHeight = `${String(lineHeight)}px`; @@ -290,10 +286,12 @@ export const measureText = ( // Baseline is important for positioning text on canvas const baseline = span.offsetTop + span.offsetHeight; // Since span adds 1px extra width to the container - const width = container.offsetWidth + 1; + const width = container.offsetWidth - 1; const height = container.offsetHeight; - document.body.removeChild(container); + if (isTestEnv()) { + return { width, height, baseline, container }; + } return { width, height, baseline }; }; @@ -331,6 +329,12 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { const lines: Array = []; const originalLines = text.split("\n"); const spaceWidth = getTextWidth(" ", font); + + const push = (str: string) => { + if (str.trim()) { + lines.push(str); + } + }; originalLines.forEach((originalLine) => { const words = originalLine.split(" "); // This means its newline so push it @@ -348,9 +352,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { if (currentWordWidth >= maxWidth) { // push current line since the current word exceeds the max width // so will be appended in next line - if (currentLine) { - lines.push(currentLine); - } + push(currentLine); currentLine = ""; currentLineWidthTillNow = 0; while (words[index].length > 0) { @@ -364,7 +366,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { if (currentLine.slice(-1) === " ") { currentLine = currentLine.slice(0, -1); } - lines.push(currentLine); + push(currentLine); currentLine = currentChar; currentLineWidthTillNow = width; if (currentLineWidthTillNow === maxWidth) { @@ -377,7 +379,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { } // push current line if appending space exceeds max width if (currentLineWidthTillNow + spaceWidth >= maxWidth) { - lines.push(currentLine); + push(currentLine); currentLine = ""; currentLineWidthTillNow = 0; } else { @@ -396,7 +398,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { currentLineWidthTillNow = getTextWidth(currentLine + word, font); if (currentLineWidthTillNow >= maxWidth) { - lines.push(currentLine); + push(currentLine); currentLineWidthTillNow = 0; currentLine = ""; @@ -407,7 +409,8 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { // Push the word if appending space exceeds max width if (currentLineWidthTillNow + spaceWidth >= maxWidth) { - lines.push(currentLine.slice(0, -1)); + const word = currentLine.slice(0, -1); + push(word); currentLine = ""; currentLineWidthTillNow = 0; break; @@ -424,7 +427,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { if (currentLine.slice(-1) === " ") { currentLine = currentLine.slice(0, -1); } - lines.push(currentLine); + push(currentLine); } } }); diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index f2ef40b58..d06d9236e 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -861,7 +861,7 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 109.5, + 110.5, 17, ] `); @@ -909,7 +909,7 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 424, + 426, -539, ] `); @@ -1026,7 +1026,7 @@ describe("textWysiwyg", () => { mouse.up(rectangle.x + 100, rectangle.y + 50); expect(rectangle.x).toBe(80); expect(rectangle.y).toBe(85); - expect(text.x).toBe(89.5); + expect(text.x).toBe(90.5); expect(text.y).toBe(90); Keyboard.withModifierKeys({ ctrl: true }, () => { diff --git a/src/tests/data/__snapshots__/restore.test.ts.snap b/src/tests/data/__snapshots__/restore.test.ts.snap index 8af4f83c9..444bc0ea2 100644 --- a/src/tests/data/__snapshots__/restore.test.ts.snap +++ b/src/tests/data/__snapshots__/restore.test.ts.snap @@ -312,7 +312,7 @@ Object { "versionNonce": 0, "verticalAlign": "middle", "width": 100, - "x": -0.5, + "x": 0.5, "y": 0, } `; diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index c3366406f..68d971710 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/src/tests/linearElementEditor.test.tsx @@ -1027,7 +1027,7 @@ describe("Test Linear Elements", () => { expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` Object { - "x": 386.5, + "x": 387.5, "y": 70, } `); @@ -1086,7 +1086,7 @@ describe("Test Linear Elements", () => { expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` Object { - "x": 189.5, + "x": 190.5, "y": 20, } `); From b704705ed84205b3bb3983ad20a6970b4eb33d15 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 21 Dec 2022 14:29:06 +0530 Subject: [PATCH 005/252] feat: render footer as a component instead of render prop (#5970) * feat: render footer as a component instead of render prop * Export FooterCenter as footer * remove useDevice export * revert some changes * remove * add spec * update specs * parse children into a dictionary * factor app footer components into a single file * Add docs * split app footer components Co-authored-by: dwelle --- src/components/App.tsx | 13 ++- src/components/LayerUI.tsx | 29 +++++-- src/components/MobileMenu.tsx | 7 +- src/components/WelcomeScreen.tsx | 6 +- src/components/{ => footer}/Footer.tsx | 37 ++++---- src/components/footer/FooterCenter.tsx | 19 +++++ src/constants.ts | 4 + .../components/EncryptedIcon.tsx | 10 +-- .../components/ExcalidrawPlusAppLink.tsx | 17 ++++ src/excalidraw-app/index.tsx | 56 +++--------- src/packages/excalidraw/CHANGELOG.md | 8 ++ src/packages/excalidraw/README.md | 34 ++++++-- src/packages/excalidraw/example/App.tsx | 85 +++++++++---------- src/packages/excalidraw/index.tsx | 9 +- src/tests/packages/excalidraw.test.tsx | 27 +++++- src/types.ts | 8 +- src/utils.ts | 23 +++++ 17 files changed, 232 insertions(+), 160 deletions(-) rename src/components/{ => footer}/Footer.tsx (77%) create mode 100644 src/components/footer/FooterCenter.tsx rename src/{ => excalidraw-app}/components/EncryptedIcon.tsx (63%) create mode 100644 src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx diff --git a/src/components/App.tsx b/src/components/App.tsx index edefacb01..da9fba57b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -534,12 +534,8 @@ class App extends React.Component { this.scene.getNonDeletedElements(), this.state, ); - const { - onCollabButtonClick, - renderTopRightUI, - renderFooter, - renderCustomStats, - } = this.props; + const { onCollabButtonClick, renderTopRightUI, renderCustomStats } = + this.props; return (
{ langCode={getLanguage().code} isCollaborating={this.props.isCollaborating} renderTopRightUI={renderTopRightUI} - renderCustomFooter={renderFooter} renderCustomStats={renderCustomStats} renderCustomSidebar={this.props.renderSidebar} showExitZenModeBtn={ @@ -601,7 +596,9 @@ class App extends React.Component { this.state.activeTool.type === "selection" && !this.scene.getElementsIncludingDeleted().length } - /> + > + {this.props.children} +
{selectedElement.length === 1 && diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 2cefeed56..2d754c9ba 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -8,8 +8,14 @@ import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { ExportType } from "../scene/types"; -import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; -import { muteFSAbortError } from "../utils"; +import { + AppProps, + AppState, + ExcalidrawProps, + BinaryFiles, + UIChildrenComponents, +} from "../types"; +import { muteFSAbortError, ReactChildrenToObject } from "../utils"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import CollabButton from "./CollabButton"; import { ErrorDialog } from "./ErrorDialog"; @@ -38,7 +44,7 @@ import { trackEvent } from "../analytics"; import { isMenuOpenAtom, useDevice } from "../components/App"; import { Stats } from "./Stats"; import { actionToggleStats } from "../actions/actionToggleStats"; -import Footer from "./Footer"; +import Footer from "./footer/Footer"; import { ExportImageIcon, HamburgerMenuIcon, @@ -71,7 +77,6 @@ interface LayerUIProps { langCode: Language["code"]; isCollaborating: boolean; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; - renderCustomFooter?: ExcalidrawProps["renderFooter"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; @@ -81,7 +86,9 @@ interface LayerUIProps { id: string; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; renderWelcomeScreen: boolean; + children?: React.ReactNode; } + const LayerUI = ({ actionManager, appState, @@ -96,7 +103,7 @@ const LayerUI = ({ showExitZenModeBtn, isCollaborating, renderTopRightUI, - renderCustomFooter, + renderCustomStats, renderCustomSidebar, libraryReturnUrl, @@ -106,9 +113,13 @@ const LayerUI = ({ id, onImageAction, renderWelcomeScreen, + children, }: LayerUIProps) => { const device = useDevice(); + const childrenComponents = + ReactChildrenToObject(children); + const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { return null; @@ -481,7 +492,6 @@ const LayerUI = ({ onPenModeToggle={onPenModeToggle} canvas={canvas} isCollaborating={isCollaborating} - renderCustomFooter={renderCustomFooter} onImageAction={onImageAction} renderTopRightUI={renderTopRightUI} renderCustomStats={renderCustomStats} @@ -514,9 +524,11 @@ const LayerUI = ({ renderWelcomeScreen={renderWelcomeScreen} appState={appState} actionManager={actionManager} - renderCustomFooter={renderCustomFooter} showExitZenModeBtn={showExitZenModeBtn} - /> + > + {childrenComponents.FooterCenter} + + {appState.showStats && ( { const keys = Object.keys(prevAppState) as (keyof Partial)[]; return ( - prev.renderCustomFooter === next.renderCustomFooter && prev.renderTopRightUI === next.renderTopRightUI && prev.renderCustomStats === next.renderCustomStats && prev.renderCustomSidebar === next.renderCustomSidebar && diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 59dd299d7..37b32cc0a 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -36,10 +36,7 @@ type MobileMenuProps = { onPenModeToggle: () => void; canvas: HTMLCanvasElement | null; isCollaborating: boolean; - renderCustomFooter?: ( - isMobile: boolean, - appState: AppState, - ) => JSX.Element | null; + onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; renderTopRightUI?: ( isMobile: boolean, @@ -63,7 +60,6 @@ export const MobileMenu = ({ onPenModeToggle, canvas, isCollaborating, - renderCustomFooter, onImageAction, renderTopRightUI, renderCustomStats, @@ -253,7 +249,6 @@ export const MobileMenu = ({
{renderCanvasActions()} - {renderCustomFooter?.(true, appState)} {appState.collaborators.size > 0 && (
{t("labels.collaborators")} diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index 6649346df..66993d4d7 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -2,7 +2,7 @@ import { useAtom } from "jotai"; import { actionLoadScene, actionShortcuts } from "../actions"; import { ActionManager } from "../actions/manager"; import { getShortcutFromShortcutName } from "../actions/shortcuts"; -import { COOKIES } from "../constants"; +import { isExcalidrawPlusSignedUser } from "../constants"; import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab"; import { t } from "../i18n"; import { AppState } from "../types"; @@ -15,10 +15,6 @@ import { } from "./icons"; import "./WelcomeScreen.scss"; -const isExcalidrawPlusSignedUser = document.cookie.includes( - COOKIES.AUTH_STATE_COOKIE, -); - const WelcomeScreenItem = ({ label, shortcut, diff --git a/src/components/Footer.tsx b/src/components/footer/Footer.tsx similarity index 77% rename from src/components/Footer.tsx rename to src/components/footer/Footer.tsx index 825226011..cd28f7c27 100644 --- a/src/components/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -1,35 +1,37 @@ import clsx from "clsx"; -import { ActionManager } from "../actions/manager"; -import { t } from "../i18n"; -import { AppState, ExcalidrawProps } from "../types"; +import { ActionManager } from "../../actions/manager"; +import { t } from "../../i18n"; +import { AppState } from "../../types"; import { ExitZenModeAction, FinalizeAction, UndoRedoActions, ZoomActions, -} from "./Actions"; -import { useDevice } from "./App"; -import { WelcomeScreenHelpArrow } from "./icons"; -import { Section } from "./Section"; -import Stack from "./Stack"; -import WelcomeScreenDecor from "./WelcomeScreenDecor"; +} from "../Actions"; +import { useDevice } from "../App"; +import { WelcomeScreenHelpArrow } from "../icons"; +import { Section } from "../Section"; +import Stack from "../Stack"; +import WelcomeScreenDecor from "../WelcomeScreenDecor"; +import FooterCenter from "./FooterCenter"; const Footer = ({ appState, actionManager, - renderCustomFooter, showExitZenModeBtn, renderWelcomeScreen, + children, }: { appState: AppState; actionManager: ActionManager; - renderCustomFooter?: ExcalidrawProps["renderFooter"]; showExitZenModeBtn: boolean; renderWelcomeScreen: boolean; + children?: React.ReactNode; }) => { const device = useDevice(); const showFinalize = !appState.viewModeEnabled && appState.multiElement && device.isTouchScreen; + return (
-
- {renderCustomFooter?.(false, appState)} -
+ {children}
{ + const appState = useExcalidrawAppState(); + return ( +
+ {children} +
+ ); +}; + +export default FooterCenter; +FooterCenter.displayName = "FooterCenter"; diff --git a/src/constants.ts b/src/constants.ts index c492f27f6..47ddf2b29 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -243,3 +243,7 @@ export const COOKIES = { /** key containt id of precedeing elemnt id we use in reconciliation during * collaboration */ export const PRECEDING_ELEMENT_KEY = "__precedingElement__"; + +export const isExcalidrawPlusSignedUser = document.cookie.includes( + COOKIES.AUTH_STATE_COOKIE, +); diff --git a/src/components/EncryptedIcon.tsx b/src/excalidraw-app/components/EncryptedIcon.tsx similarity index 63% rename from src/components/EncryptedIcon.tsx rename to src/excalidraw-app/components/EncryptedIcon.tsx index 12a936c34..a3e6ff0ba 100644 --- a/src/components/EncryptedIcon.tsx +++ b/src/excalidraw-app/components/EncryptedIcon.tsx @@ -1,8 +1,8 @@ -import { t } from "../i18n"; -import { shield } from "./icons"; -import { Tooltip } from "./Tooltip"; +import { shield } from "../../components/icons"; +import { Tooltip } from "../../components/Tooltip"; +import { t } from "../../i18n"; -const EncryptedIcon = () => ( +export const EncryptedIcon = () => ( ( ); - -export default EncryptedIcon; diff --git a/src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx b/src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx new file mode 100644 index 000000000..febb66d51 --- /dev/null +++ b/src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx @@ -0,0 +1,17 @@ +import { isExcalidrawPlusSignedUser } from "../../constants"; + +export const ExcalidrawPlusAppLink = () => { + if (!isExcalidrawPlusSignedUser) { + return null; + } + return ( + + Go to Excalidraw+ + + ); +}; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 8cffe69ac..b12a41e31 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -7,7 +7,6 @@ import { ErrorDialog } from "../components/ErrorDialog"; import { TopErrorBoundary } from "../components/TopErrorBoundary"; import { APP_NAME, - COOKIES, EVENT, THEME, TITLE_TIMEOUT, @@ -22,7 +21,7 @@ import { } from "../element/types"; import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { t } from "../i18n"; -import { Excalidraw, defaultLang } from "../packages/excalidraw/index"; +import { Excalidraw, defaultLang, Footer } from "../packages/excalidraw/index"; import { AppState, LibraryItems, @@ -50,7 +49,6 @@ import Collab, { collabDialogShownAtom, isCollaboratingAtom, } from "./collab/Collab"; -import { LanguageList } from "./components/LanguageList"; import { exportToBackend, getCollaborationLinkData, @@ -79,15 +77,12 @@ import { atom, Provider, useAtom } from "jotai"; import { jotaiStore, useAtomWithInitialValue } from "../jotai"; import { reconcileElements } from "./collab/reconciliation"; import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; -import EncryptedIcon from "../components/EncryptedIcon"; +import { EncryptedIcon } from "./components/EncryptedIcon"; +import { ExcalidrawPlusAppLink } from "./components/ExcalidrawPlusAppLink"; polyfill(); window.EXCALIDRAW_THROTTLE_RENDER = true; -const isExcalidrawPlusSignedUser = document.cookie.includes( - COOKIES.AUTH_STATE_COOKIE, -); - const languageDetector = new LanguageDetector(); languageDetector.init({ languageUtils: {}, @@ -577,41 +572,6 @@ const ExcalidrawWrapper = () => { } }; - const renderFooter = (isMobile: boolean) => { - const renderLanguageList = () => ; - if (isMobile) { - return ( -
-
- {t("labels.language")} -
-
{renderLanguageList()}
-
- ); - } - - return ( -
- {isExcalidrawPlusSignedUser && ( - - Go to Excalidraw+ - - )} - -
- ); - }; - const renderCustomStats = ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, @@ -672,7 +632,6 @@ const ExcalidrawWrapper = () => { }, }, }} - renderFooter={renderFooter} langCode={langCode} renderCustomStats={renderCustomStats} detectScroll={false} @@ -680,7 +639,14 @@ const ExcalidrawWrapper = () => { onLibraryChange={onLibraryChange} autoFocus={true} theme={theme} - /> + > +
+
+ + +
+
+ {excalidrawAPI && } {errorMessage && ( ; +const App = () => { + return ( + +
+ +
+
+ ); +}; +``` + ### Props | Name | Type | Default | Description | @@ -392,7 +417,6 @@ No, Excalidraw package doesn't come with collaboration built in, since the imple | [`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. | | [`renderSIdebar`](#renderSIdebar) | Function | | Render function that renders custom sidebar. | | [`viewModeEnabled`](#viewModeEnabled) | boolean | | This implies if the app is in view mode. | @@ -613,14 +637,6 @@ import { defaultLang, languages } from "@excalidraw/excalidraw"; 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. diff --git a/src/packages/excalidraw/example/App.tsx b/src/packages/excalidraw/example/App.tsx index 613605340..600f65d4e 100644 --- a/src/packages/excalidraw/example/App.tsx +++ b/src/packages/excalidraw/example/App.tsx @@ -68,6 +68,7 @@ const { viewportCoordsToSceneCoords, restoreElements, Sidebar, + Footer, } = window.ExcalidrawLib; const COMMENT_SVG = ( @@ -160,49 +161,6 @@ export default function App() { fetchData(); }, [excalidrawAPI]); - const renderFooter = () => { - return ( - <> - {" "} - - - - ); - }; - const loadSceneOrLibrary = async () => { const file = await fileOpen({ description: "Excalidraw or library file" }); const contents = await loadSceneOrLibraryFromBlob(file, null, null); @@ -712,12 +670,49 @@ export default function App() { name="Custom name of drawing" UIOptions={{ canvasActions: { loadScene: false } }} renderTopRightUI={renderTopRightUI} - renderFooter={renderFooter} onLinkOpen={onLinkOpen} onPointerDown={onPointerDown} onScrollChange={rerenderCommentIcons} renderSidebar={renderSidebar} - /> + > +
+ + +
+ {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()} {comment && renderComment()}
diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 4ac071592..51af0a03e 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -10,6 +10,7 @@ import { defaultLang } from "../../i18n"; import { DEFAULT_UI_OPTIONS } from "../../constants"; import { Provider } from "jotai"; import { jotaiScope, jotaiStore } from "../../jotai"; +import Footer from "../../components/footer/FooterCenter"; const ExcalidrawBase = (props: ExcalidrawProps) => { const { @@ -20,7 +21,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { isCollaborating = false, onPointerUpdate, renderTopRightUI, - renderFooter, renderSidebar, langCode = defaultLang.code, viewModeEnabled, @@ -39,6 +39,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { onLinkOpen, onPointerDown, onScrollChange, + children, } = props; const canvasActions = props.UIOptions?.canvasActions; @@ -93,7 +94,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { isCollaborating={isCollaborating} onPointerUpdate={onPointerUpdate} renderTopRightUI={renderTopRightUI} - renderFooter={renderFooter} langCode={langCode} viewModeEnabled={viewModeEnabled} zenModeEnabled={zenModeEnabled} @@ -113,7 +113,9 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { onPointerDown={onPointerDown} onScrollChange={onScrollChange} renderSidebar={renderSidebar} - /> + > + {children} + ); @@ -236,3 +238,4 @@ export { } from "../../utils"; export { Sidebar } from "../../components/Sidebar/Sidebar"; +export { Footer }; diff --git a/src/tests/packages/excalidraw.test.tsx b/src/tests/packages/excalidraw.test.tsx index 2957fd476..3610ac1c9 100644 --- a/src/tests/packages/excalidraw.test.tsx +++ b/src/tests/packages/excalidraw.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, GlobalTestState, render } from "../test-utils"; -import { Excalidraw } from "../../packages/excalidraw/index"; +import { Excalidraw, Footer } from "../../packages/excalidraw/index"; import { queryByText, queryByTestId } from "@testing-library/react"; import { GRID_SIZE, THEME } from "../../constants"; import { t } from "../../i18n"; @@ -49,6 +49,31 @@ describe("", () => { }); }); + it("should render the footer only when Footer is passed as children", async () => { + //Footer not passed hence it will not render the footer + let { container } = await render( + +
This is a custom footer
+
, + ); + expect( + container.querySelector(".layer-ui__wrapper__footer-center"), + ).toBeEmptyDOMElement(); + + // Footer passed hence it will render the footer + ({ container } = await render( + +
+
This is a custom footer
+
+
, + )); + expect( + container.querySelector(".layer-ui__wrapper__footer-center")?.innerHTML, + ).toMatchInlineSnapshot( + `""`, + ); + }); describe("Test gridModeEnabled prop", () => { it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => { const { container } = await render(); diff --git a/src/types.ts b/src/types.ts index d87174a5a..e83a4226a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -295,7 +295,6 @@ export interface ExcalidrawProps { isMobile: boolean, appState: AppState, ) => JSX.Element | null; - renderFooter?: (isMobile: boolean, appState: AppState) => JSX.Element | null; langCode?: Language["code"]; viewModeEnabled?: boolean; zenModeEnabled?: boolean; @@ -331,6 +330,7 @@ export interface ExcalidrawProps { * Render function that renders custom component. */ renderSidebar?: () => JSX.Element | null; + children?: React.ReactNode; } export type SceneData = { @@ -507,3 +507,9 @@ export type Device = Readonly<{ isTouchScreen: boolean; canDeviceFitSidebar: boolean; }>; + +export type UIChildrenComponents = { + [k in "FooterCenter"]?: + | React.ReactPortal + | React.ReactElement>; +}; diff --git a/src/utils.ts b/src/utils.ts index aef6a7d57..0f991c437 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -15,6 +15,7 @@ import { AppState, DataURL, LastActiveToolBeforeEraser, Zoom } from "./types"; import { unstable_batchedUpdates } from "react-dom"; import { isDarwin } from "./keys"; import { SHAPES } from "./shapes"; +import React from "react"; let mockDateTime: string | null = null; @@ -686,3 +687,25 @@ export const queryFocusableElements = (container: HTMLElement | null) => { ) : []; }; + +export const ReactChildrenToObject = < + T extends { + [k in string]?: + | React.ReactPortal + | React.ReactElement>; + }, +>( + children: React.ReactNode, +) => { + return React.Children.toArray(children).reduce((acc, child) => { + if ( + React.isValidElement(child) && + typeof child.type !== "string" && + child?.type.name + ) { + // @ts-ignore + acc[child.type.name] = child; + } + return acc; + }, {} as Partial); +}; From 7e135c4e22e373a7004586e6c7ea200e4aa2088b Mon Sep 17 00:00:00 2001 From: David Luzar Date: Wed, 21 Dec 2022 12:47:09 +0100 Subject: [PATCH 006/252] feat: move contextMenu into the component tree and control via appState (#6021) --- src/actions/actionClipboard.tsx | 30 + src/actions/actionToggleGridMode.tsx | 3 + src/actions/actionToggleLock.ts | 12 +- src/actions/actionToggleViewMode.tsx | 3 + src/actions/actionToggleZenMode.tsx | 3 + src/actions/types.ts | 2 + src/appState.ts | 2 + src/components/App.tsx | 379 ++--- src/components/ContextMenu.scss | 22 +- src/components/ContextMenu.tsx | 224 ++- .../__snapshots__/contextmenu.test.tsx.snap | 1406 ++++++++++++++++- .../regressionTests.test.tsx.snap | 53 + src/tests/elementLocking.test.tsx | 2 +- .../packages/__snapshots__/utils.test.ts.snap | 1 + src/types.ts | 8 + 15 files changed, 1752 insertions(+), 398 deletions(-) diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx index 62a89a38f..5be391d35 100644 --- a/src/actions/actionClipboard.tsx +++ b/src/actions/actionClipboard.tsx @@ -3,6 +3,7 @@ import { register } from "./register"; import { copyTextToSystemClipboard, copyToClipboard, + probablySupportsClipboardBlob, probablySupportsClipboardWriteText, } from "../clipboard"; import { actionDeleteSelected } from "./actionDeleteSelected"; @@ -23,11 +24,31 @@ export const actionCopy = register({ commitToHistory: false, }; }, + contextItemPredicate: (elements, appState, appProps, app) => { + return app.device.isMobile && !!navigator.clipboard; + }, contextItemLabel: "labels.copy", // don't supply a shortcut since we handle this conditionally via onCopy event keyTest: undefined, }); +export const actionPaste = register({ + name: "paste", + trackEvent: { category: "element" }, + perform: (elements: any, appStates: any, data, app) => { + app.pasteFromClipboard(null); + return { + commitToHistory: false, + }; + }, + contextItemPredicate: (elements, appState, appProps, app) => { + return app.device.isMobile && !!navigator.clipboard; + }, + contextItemLabel: "labels.paste", + // don't supply a shortcut since we handle this conditionally via onCopy event + keyTest: undefined, +}); + export const actionCut = register({ name: "cut", trackEvent: { category: "element" }, @@ -35,6 +56,9 @@ export const actionCut = register({ actionCopy.perform(elements, appState, data, app); return actionDeleteSelected.perform(elements, appState); }, + contextItemPredicate: (elements, appState, appProps, app) => { + return app.device.isMobile && !!navigator.clipboard; + }, contextItemLabel: "labels.cut", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, }); @@ -77,6 +101,9 @@ export const actionCopyAsSvg = register({ }; } }, + contextItemPredicate: (elements) => { + return probablySupportsClipboardWriteText && elements.length > 0; + }, contextItemLabel: "labels.copyAsSvg", }); @@ -131,6 +158,9 @@ export const actionCopyAsPng = register({ }; } }, + contextItemPredicate: (elements) => { + return probablySupportsClipboardBlob && elements.length > 0; + }, contextItemLabel: "labels.copyAsPng", keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, }); diff --git a/src/actions/actionToggleGridMode.tsx b/src/actions/actionToggleGridMode.tsx index c3617398b..f8336a4bf 100644 --- a/src/actions/actionToggleGridMode.tsx +++ b/src/actions/actionToggleGridMode.tsx @@ -20,6 +20,9 @@ export const actionToggleGridMode = register({ }; }, checked: (appState: AppState) => appState.gridSize !== null, + contextItemPredicate: (element, appState, props) => { + return typeof props.gridModeEnabled === "undefined"; + }, contextItemLabel: "labels.showGrid", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, }); diff --git a/src/actions/actionToggleLock.ts b/src/actions/actionToggleLock.ts index c944c37c3..c44bd5700 100644 --- a/src/actions/actionToggleLock.ts +++ b/src/actions/actionToggleLock.ts @@ -41,15 +41,9 @@ export const actionToggleLock = register({ : "labels.elementLock.lock"; } - if (selected.length > 1) { - return getOperation(selected) === "lock" - ? "labels.elementLock.lockAll" - : "labels.elementLock.unlockAll"; - } - - throw new Error( - "Unexpected zero elements to lock/unlock. This should never happen.", - ); + return getOperation(selected) === "lock" + ? "labels.elementLock.lockAll" + : "labels.elementLock.unlockAll"; }, keyTest: (event, appState, elements) => { return ( diff --git a/src/actions/actionToggleViewMode.tsx b/src/actions/actionToggleViewMode.tsx index 4b1adf476..b2f529c1c 100644 --- a/src/actions/actionToggleViewMode.tsx +++ b/src/actions/actionToggleViewMode.tsx @@ -18,6 +18,9 @@ export const actionToggleViewMode = register({ }; }, checked: (appState) => appState.viewModeEnabled, + contextItemPredicate: (elements, appState, appProps) => { + return typeof appProps.viewModeEnabled === "undefined"; + }, contextItemLabel: "labels.viewMode", keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R, diff --git a/src/actions/actionToggleZenMode.tsx b/src/actions/actionToggleZenMode.tsx index 5ed191eba..7578c02ed 100644 --- a/src/actions/actionToggleZenMode.tsx +++ b/src/actions/actionToggleZenMode.tsx @@ -18,6 +18,9 @@ export const actionToggleZenMode = register({ }; }, checked: (appState) => appState.zenModeEnabled, + contextItemPredicate: (elements, appState, appProps) => { + return typeof appProps.zenModeEnabled === "undefined"; + }, contextItemLabel: "buttons.zenMode", keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z, diff --git a/src/actions/types.ts b/src/actions/types.ts index 0ec27ec5c..93e29cfc1 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -143,6 +143,8 @@ export interface Action { contextItemPredicate?: ( elements: readonly ExcalidrawElement[], appState: AppState, + appProps: ExcalidrawProps, + app: AppClassProperties, ) => boolean; checked?: (appState: Readonly) => boolean; trackEvent: diff --git a/src/appState.ts b/src/appState.ts index 497922130..d1cbe92f0 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -64,6 +64,7 @@ export const getDefaultAppState = (): Omit< lastPointerDownWith: "mouse", multiElement: null, name: `${t("labels.untitled")}-${getDateTime()}`, + contextMenu: null, openMenu: null, openPopup: null, openSidebar: null, @@ -157,6 +158,7 @@ const APP_STATE_STORAGE_CONF = (< name: { browser: true, export: false, server: false }, offsetLeft: { browser: false, export: false, server: false }, offsetTop: { browser: false, export: false, server: false }, + contextMenu: { browser: false, export: false, server: false }, openMenu: { browser: true, export: false, server: false }, openPopup: { browser: false, export: false, server: false }, openSidebar: { browser: true, export: false, server: false }, diff --git a/src/components/App.tsx b/src/components/App.tsx index da9fba57b..daa33d0ea 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -42,11 +42,7 @@ import { actions } from "../actions/register"; import { ActionResult } from "../actions/types"; import { trackEvent } from "../analytics"; import { getDefaultAppState, isEraserActive } from "../appState"; -import { - parseClipboard, - probablySupportsClipboardBlob, - probablySupportsClipboardWriteText, -} from "../clipboard"; +import { parseClipboard } from "../clipboard"; import { APP_NAME, CURSOR_TYPE, @@ -227,7 +223,11 @@ import { updateActiveTool, getShortcutKey, } from "../utils"; -import ContextMenu, { ContextMenuOption } from "./ContextMenu"; +import { + ContextMenu, + ContextMenuItems, + CONTEXT_MENU_SEPARATOR, +} from "./ContextMenu"; import LayerUI from "./LayerUI"; import { Toast } from "./Toast"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; @@ -274,6 +274,7 @@ import { import { shouldShowBoundingBox } from "../element/transformHandles"; import { atom } from "jotai"; import { Fonts } from "../scene/Fonts"; +import { actionPaste } from "../actions/actionClipboard"; export const isMenuOpenAtom = atom(false); export const isDropdownOpenAtom = atom(false); @@ -383,7 +384,6 @@ class App extends React.Component { hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDown: React.PointerEvent | null = null; lastPointerUp: React.PointerEvent | PointerEvent | null = null; - contextMenuOpen: boolean = false; lastScenePointer: { x: number; y: number } | null = null; constructor(props: AppProps) { @@ -602,6 +602,7 @@ class App extends React.Component {
{selectedElement.length === 1 && + !this.state.contextMenu && this.state.showHyperlinkPopup && ( { closable={this.state.toast.closable} /> )} + {this.state.contextMenu && ( + + )}
{this.renderCanvas()}
{" "} @@ -644,8 +653,6 @@ class App extends React.Component { private syncActionResult = withBatchedUpdates( (actionResult: ActionResult) => { - // Since context menu closes when action triggered so setting to false - this.contextMenuOpen = false; if (this.unmounted || actionResult === false) { return; } @@ -674,7 +681,7 @@ class App extends React.Component { this.addNewImagesToImageCache(); } - if (actionResult.appState || editingElement) { + if (actionResult.appState || editingElement || this.state.contextMenu) { if (actionResult.commitToHistory) { this.history.resumeRecording(); } @@ -700,12 +707,17 @@ class App extends React.Component { if (typeof this.props.name !== "undefined") { name = this.props.name; } + this.setState( (state) => { // using Object.assign instead of spread to fool TS 4.2.2+ into // regarding the resulting type as not containing undefined // (which the following expression will never contain) return Object.assign(actionResult.appState || {}, { + // NOTE this will prevent opening context menu using an action + // or programmatically from the host, so it will need to be + // rewritten later + contextMenu: null, editingElement: editingElement || actionResult.appState?.editingElement || null, viewModeEnabled, @@ -1462,7 +1474,7 @@ class App extends React.Component { } }; - private pasteFromClipboard = withBatchedUpdates( + public pasteFromClipboard = withBatchedUpdates( async (event: ClipboardEvent | null) => { const isPlainPaste = !!(IS_PLAIN_PASTE && event); @@ -1470,7 +1482,7 @@ class App extends React.Component { const target = document.activeElement; const isExcalidrawActive = this.excalidrawContainerRef.current?.contains(target); - if (!isExcalidrawActive) { + if (event && !isExcalidrawActive) { return; } @@ -1744,10 +1756,11 @@ class App extends React.Component { this.history.resumeRecording(); } - // Collaboration - - setAppState: React.Component["setState"] = (state) => { - this.setState(state); + setAppState: React.Component["setState"] = ( + state, + callback, + ) => { + this.setState(state, callback); }; removePointer = (event: React.PointerEvent | PointerEvent) => { @@ -3101,7 +3114,7 @@ class App extends React.Component { hitElement && hitElement.link && this.state.selectedElementIds[hitElement.id] && - !this.contextMenuOpen && + !this.state.contextMenu && !this.state.showHyperlinkPopup ) { this.setState({ showHyperlinkPopup: "info" }); @@ -3323,6 +3336,14 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + // since contextMenu options are potentially evaluated on each render, + // and an contextMenu action may depend on selection state, we must + // close the contextMenu before we update the selection on pointerDown + // (e.g. resetting selection) + if (this.state.contextMenu) { + this.setState({ contextMenu: null }); + } + // remove any active selection when we start to interact with canvas // (mainly, we care about removing selection outside the component which // would prevent our copy handling otherwise) @@ -3389,8 +3410,6 @@ class App extends React.Component { return; } - // Since context menu closes on pointer down so setting to false - this.contextMenuOpen = false; this.clearSelectionIfNotUsingSelection(); this.updateBindingEnabledOnPointerMove(event); @@ -5949,7 +5968,17 @@ class App extends React.Component { includeLockedElements: true, }); - const type = element ? "element" : "canvas"; + const selectedElements = getSelectedElements( + this.scene.getNonDeletedElements(), + this.state, + ); + const isHittignCommonBoundBox = + this.isHittingCommonBoundingBoxOfSelectedElements( + { x, y }, + selectedElements, + ); + + const type = element || isHittignCommonBoundBox ? "element" : "canvas"; const container = this.excalidrawContainerRef.current!; const { top: offsetTop, left: offsetLeft } = @@ -5957,25 +5986,30 @@ class App extends React.Component { const left = event.clientX - offsetLeft; const top = event.clientY - offsetTop; - if (element && !this.state.selectedElementIds[element.id]) { - this.setState( - selectGroupsForSelectedElements( - { - ...this.state, - selectedElementIds: { [element.id]: true }, - selectedLinearElement: isLinearElement(element) - ? new LinearElementEditor(element, this.scene) - : null, - }, - this.scene.getNonDeletedElements(), - ), - () => { - this._openContextMenu({ top, left }, type); - }, - ); - } else { - this._openContextMenu({ top, left }, type); - } + trackEvent("contextMenu", "openContextMenu", type); + + this.setState( + { + ...(element && !this.state.selectedElementIds[element.id] + ? selectGroupsForSelectedElements( + { + ...this.state, + selectedElementIds: { [element.id]: true }, + selectedLinearElement: isLinearElement(element) + ? new LinearElementEditor(element, this.scene) + : null, + }, + this.scene.getNonDeletedElements(), + ) + : this.state), + showHyperlinkPopup: false, + }, + () => { + this.setState({ + contextMenu: { top, left, items: this.getContextMenuItems(type) }, + }); + }, + ); }; private maybeDragNewGenericElement = ( @@ -6083,215 +6117,84 @@ class App extends React.Component { return false; }; - /** @private use this.handleCanvasContextMenu */ - private _openContextMenu = ( - { - left, - top, - }: { - left: number; - top: number; - }, + private getContextMenuItems = ( type: "canvas" | "element", - ) => { - trackEvent("contextMenu", "openContextMenu", type); - if (this.state.showHyperlinkPopup) { - this.setState({ showHyperlinkPopup: false }); - } - this.contextMenuOpen = true; - const maybeGroupAction = actionGroup.contextItemPredicate!( - this.actionManager.getElementsIncludingDeleted(), - this.actionManager.getAppState(), - ); + ): ContextMenuItems => { + const options: ContextMenuItems = []; - const maybeUngroupAction = actionUngroup.contextItemPredicate!( - this.actionManager.getElementsIncludingDeleted(), - this.actionManager.getAppState(), - ); + options.push(actionCopyAsPng, actionCopyAsSvg); - const maybeFlipHorizontal = actionFlipHorizontal.contextItemPredicate!( - this.actionManager.getElementsIncludingDeleted(), - this.actionManager.getAppState(), - ); + // canvas contextMenu + // ------------------------------------------------------------------------- - const maybeFlipVertical = actionFlipVertical.contextItemPredicate!( - this.actionManager.getElementsIncludingDeleted(), - this.actionManager.getAppState(), - ); - - const mayBeAllowUnbinding = actionUnbindText.contextItemPredicate( - this.actionManager.getElementsIncludingDeleted(), - this.actionManager.getAppState(), - ); - - const mayBeAllowBinding = actionBindText.contextItemPredicate( - this.actionManager.getElementsIncludingDeleted(), - this.actionManager.getAppState(), - ); - - const mayBeAllowToggleLineEditing = - actionToggleLinearEditor.contextItemPredicate( - this.actionManager.getElementsIncludingDeleted(), - this.actionManager.getAppState(), - ); - - const separator = "separator"; - - const elements = this.scene.getNonDeletedElements(); - - const selectedElements = getSelectedElements( - this.scene.getNonDeletedElements(), - this.state, - ); - - const options: ContextMenuOption[] = []; - if (probablySupportsClipboardBlob && elements.length > 0) { - options.push(actionCopyAsPng); - } - - if (probablySupportsClipboardWriteText && elements.length > 0) { - options.push(actionCopyAsSvg); - } - - if ( - type === "element" && - copyText.contextItemPredicate(elements, this.state) && - probablySupportsClipboardWriteText - ) { - options.push(copyText); - } if (type === "canvas") { - const viewModeOptions = [ - ...options, - typeof this.props.gridModeEnabled === "undefined" && + if (this.state.viewModeEnabled) { + return [ + ...options, actionToggleGridMode, - typeof this.props.zenModeEnabled === "undefined" && actionToggleZenMode, - typeof this.props.viewModeEnabled === "undefined" && + actionToggleZenMode, actionToggleViewMode, + actionToggleStats, + ]; + } + + return [ + actionPaste, + CONTEXT_MENU_SEPARATOR, + actionCopyAsPng, + actionCopyAsSvg, + copyText, + CONTEXT_MENU_SEPARATOR, + actionSelectAll, + CONTEXT_MENU_SEPARATOR, + actionToggleGridMode, + actionToggleZenMode, + actionToggleViewMode, actionToggleStats, ]; - - if (this.state.viewModeEnabled) { - ContextMenu.push({ - options: viewModeOptions, - top, - left, - actionManager: this.actionManager, - appState: this.state, - container: this.excalidrawContainerRef.current!, - elements, - }); - } else { - ContextMenu.push({ - options: [ - this.device.isMobile && - navigator.clipboard && { - trackEvent: false, - name: "paste", - perform: (elements, appStates) => { - this.pasteFromClipboard(null); - return { - commitToHistory: false, - }; - }, - contextItemLabel: "labels.paste", - }, - this.device.isMobile && navigator.clipboard && separator, - probablySupportsClipboardBlob && - elements.length > 0 && - actionCopyAsPng, - probablySupportsClipboardWriteText && - elements.length > 0 && - actionCopyAsSvg, - probablySupportsClipboardWriteText && - selectedElements.length > 0 && - copyText, - ((probablySupportsClipboardBlob && elements.length > 0) || - (probablySupportsClipboardWriteText && elements.length > 0)) && - separator, - actionSelectAll, - separator, - typeof this.props.gridModeEnabled === "undefined" && - actionToggleGridMode, - typeof this.props.zenModeEnabled === "undefined" && - actionToggleZenMode, - typeof this.props.viewModeEnabled === "undefined" && - actionToggleViewMode, - actionToggleStats, - ], - top, - left, - actionManager: this.actionManager, - appState: this.state, - container: this.excalidrawContainerRef.current!, - elements, - }); - } - } else if (type === "element") { - if (this.state.viewModeEnabled) { - ContextMenu.push({ - options: [navigator.clipboard && actionCopy, ...options], - top, - left, - actionManager: this.actionManager, - appState: this.state, - container: this.excalidrawContainerRef.current!, - elements, - }); - } else { - ContextMenu.push({ - options: [ - this.device.isMobile && actionCut, - this.device.isMobile && navigator.clipboard && actionCopy, - this.device.isMobile && - navigator.clipboard && { - name: "paste", - trackEvent: false, - perform: (elements, appStates) => { - this.pasteFromClipboard(null); - return { - commitToHistory: false, - }; - }, - contextItemLabel: "labels.paste", - }, - this.device.isMobile && separator, - ...options, - separator, - actionCopyStyles, - actionPasteStyles, - separator, - maybeGroupAction && actionGroup, - mayBeAllowUnbinding && actionUnbindText, - mayBeAllowBinding && actionBindText, - maybeUngroupAction && actionUngroup, - (maybeGroupAction || maybeUngroupAction) && separator, - actionAddToLibrary, - separator, - actionSendBackward, - actionBringForward, - actionSendToBack, - actionBringToFront, - separator, - maybeFlipHorizontal && actionFlipHorizontal, - maybeFlipVertical && actionFlipVertical, - (maybeFlipHorizontal || maybeFlipVertical) && separator, - mayBeAllowToggleLineEditing && actionToggleLinearEditor, - actionLink.contextItemPredicate(elements, this.state) && actionLink, - actionDuplicateSelection, - actionToggleLock, - separator, - actionDeleteSelected, - ], - top, - left, - actionManager: this.actionManager, - appState: this.state, - container: this.excalidrawContainerRef.current!, - elements, - }); - } } + + // element contextMenu + // ------------------------------------------------------------------------- + + options.push(copyText); + + if (this.state.viewModeEnabled) { + return [actionCopy, ...options]; + } + + return [ + actionCut, + actionCopy, + actionPaste, + CONTEXT_MENU_SEPARATOR, + ...options, + CONTEXT_MENU_SEPARATOR, + actionCopyStyles, + actionPasteStyles, + CONTEXT_MENU_SEPARATOR, + actionGroup, + actionUnbindText, + actionBindText, + actionUngroup, + CONTEXT_MENU_SEPARATOR, + actionAddToLibrary, + CONTEXT_MENU_SEPARATOR, + actionSendBackward, + actionBringForward, + actionSendToBack, + actionBringToFront, + CONTEXT_MENU_SEPARATOR, + actionFlipHorizontal, + actionFlipVertical, + CONTEXT_MENU_SEPARATOR, + actionToggleLinearEditor, + actionLink, + actionDuplicateSelection, + actionToggleLock, + CONTEXT_MENU_SEPARATOR, + actionDeleteSelected, + ]; }; private handleWheel = withBatchedUpdates((event: WheelEvent) => { diff --git a/src/components/ContextMenu.scss b/src/components/ContextMenu.scss index df8db7fba..579763119 100644 --- a/src/components/ContextMenu.scss +++ b/src/components/ContextMenu.scss @@ -19,7 +19,7 @@ color: var(--popup-text-color); } - .context-menu-option { + .context-menu-item { position: relative; width: 100%; min-width: 9.5rem; @@ -43,16 +43,16 @@ } &.dangerous { - .context-menu-option__label { + .context-menu-item__label { color: $oc-red-7; } } - .context-menu-option__label { + .context-menu-item__label { justify-self: start; margin-inline-end: 20px; } - .context-menu-option__shortcut { + .context-menu-item__shortcut { justify-self: end; opacity: 0.6; font-family: inherit; @@ -60,37 +60,37 @@ } } - .context-menu-option:hover { + .context-menu-item:hover { color: var(--popup-bg-color); background-color: var(--select-highlight-color); &.dangerous { - .context-menu-option__label { + .context-menu-item__label { color: var(--popup-bg-color); } background-color: $oc-red-6; } } - .context-menu-option:focus { + .context-menu-item:focus { z-index: 1; } @include isMobile { - .context-menu-option { + .context-menu-item { display: block; - .context-menu-option__label { + .context-menu-item__label { margin-inline-end: 0; } - .context-menu-option__shortcut { + .context-menu-item__shortcut { display: none; } } } - .context-menu-option-separator { + .context-menu-item-separator { border: none; border-top: 1px solid $oc-gray-5; } diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 4b4a5f2f8..2ec72e5ea 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,4 +1,3 @@ -import { createRoot, Root } from "react-dom/client"; import clsx from "clsx"; import { Popover } from "./Popover"; import { t } from "../i18n"; @@ -10,135 +9,116 @@ import { } from "../actions/shortcuts"; import { Action } from "../actions/types"; import { ActionManager } from "../actions/manager"; -import { AppState } from "../types"; -import { NonDeletedExcalidrawElement } from "../element/types"; +import { + useExcalidrawAppState, + useExcalidrawElements, + useExcalidrawSetAppState, +} from "./App"; +import React from "react"; -export type ContextMenuOption = "separator" | Action; +export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action; + +export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[]; type ContextMenuProps = { - options: ContextMenuOption[]; - onCloseRequest?(): void; + actionManager: ActionManager; + items: ContextMenuItems; top: number; left: number; - actionManager: ActionManager; - appState: Readonly; - elements: readonly NonDeletedExcalidrawElement[]; }; -const ContextMenu = ({ - options, - onCloseRequest, - top, - left, - actionManager, - appState, - elements, -}: ContextMenuProps) => { - return ( - -
    event.preventDefault()} - > - {options.map((option, idx) => { - if (option === "separator") { - return
    ; - } +export const CONTEXT_MENU_SEPARATOR = "separator"; - const actionName = option.name; - let label = ""; - if (option.contextItemLabel) { - if (typeof option.contextItemLabel === "function") { - label = t(option.contextItemLabel(elements, appState)); - } else { - label = t(option.contextItemLabel); - } - } - return ( -
  • - -
  • - ); - })} -
-
- ); -}; +export const ContextMenu = React.memo( + ({ actionManager, items, top, left }: ContextMenuProps) => { + const appState = useExcalidrawAppState(); + const setAppState = useExcalidrawSetAppState(); + const elements = useExcalidrawElements(); -const contextMenuRoots = new WeakMap(); - -const getContextMenuRoot = (container: HTMLElement): Root => { - let contextMenuRoot = contextMenuRoots.get(container); - if (contextMenuRoot) { - return contextMenuRoot; - } - contextMenuRoot = createRoot( - container.querySelector(".excalidraw-contextMenuContainer")!, - ); - contextMenuRoots.set(container, contextMenuRoot); - return contextMenuRoot; -}; - -const handleClose = (container: HTMLElement) => { - const contextMenuRoot = contextMenuRoots.get(container); - if (contextMenuRoot) { - contextMenuRoot.unmount(); - contextMenuRoots.delete(container); - } -}; - -export default { - push(params: { - options: (ContextMenuOption | false | null | undefined)[]; - top: ContextMenuProps["top"]; - left: ContextMenuProps["left"]; - actionManager: ContextMenuProps["actionManager"]; - appState: Readonly; - container: HTMLElement; - elements: readonly NonDeletedExcalidrawElement[]; - }) { - const options = Array.of(); - params.options.forEach((option) => { - if (option) { - options.push(option); + const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { + if ( + item && + (item === CONTEXT_MENU_SEPARATOR || + !item.contextItemPredicate || + item.contextItemPredicate( + elements, + appState, + actionManager.app.props, + actionManager.app, + )) + ) { + acc.push(item); } - }); - if (options.length) { - getContextMenuRoot(params.container).render( - handleClose(params.container)} - actionManager={params.actionManager} - appState={params.appState} - elements={params.elements} - />, - ); - } + return acc; + }, []); + + return ( + setAppState({ contextMenu: null })} + top={top} + left={left} + fitInViewport={true} + offsetLeft={appState.offsetLeft} + offsetTop={appState.offsetTop} + viewportWidth={appState.width} + viewportHeight={appState.height} + > +
    event.preventDefault()} + > + {filteredItems.map((item, idx) => { + if (item === CONTEXT_MENU_SEPARATOR) { + if ( + !filteredItems[idx - 1] || + filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR + ) { + return null; + } + return
    ; + } + + const actionName = item.name; + let label = ""; + if (item.contextItemLabel) { + if (typeof item.contextItemLabel === "function") { + label = t(item.contextItemLabel(elements, appState)); + } else { + label = t(item.contextItemLabel); + } + } + + return ( +
  • { + // we need update state before executing the action in case + // the action uses the appState it's being passed (that still + // contains a defined contextMenu) to return the next state. + setAppState({ contextMenu: null }, () => { + actionManager.executeAction(item, "contextMenu"); + }); + }} + > + +
  • + ); + })} +
+
+ ); }, -}; +); diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 1c3ccccae..ed03cd999 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap @@ -9,6 +9,257 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": Object { + "items": Array [ + Object { + "contextItemLabel": "labels.cut", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "cut", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copy", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "copy", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.paste", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "paste", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyAsPng", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "copyAsPng", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyAsSvg", + "contextItemPredicate": [Function], + "name": "copyAsSvg", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyText", + "contextItemPredicate": [Function], + "name": "copyText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyStyles", + "keyTest": [Function], + "name": "copyStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.pasteStyles", + "keyTest": [Function], + "name": "pasteStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.group", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "group", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.unbindText", + "contextItemPredicate": [Function], + "name": "unbindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.bindText", + "contextItemPredicate": [Function], + "name": "bindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.ungroup", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "ungroup", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.addToLibrary", + "name": "addToLibrary", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendBackward", + "keyPriority": 40, + "keyTest": [Function], + "name": "sendBackward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringForward", + "keyPriority": 40, + "keyTest": [Function], + "name": "bringForward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendToBack", + "keyTest": [Function], + "name": "sendToBack", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringToFront", + "keyTest": [Function], + "name": "bringToFront", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.flipHorizontal", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipHorizontal", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.flipVertical", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipVertical", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "name": "toggleLinearEditor", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "hyperlink", + "perform": [Function], + "trackEvent": Object { + "action": "click", + "category": "hyperlink", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.duplicateSelection", + "keyTest": [Function], + "name": "duplicateSelection", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": [Function], + "keyTest": [Function], + "name": "toggleLock", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.delete", + "keyTest": [Function], + "name": "deleteSelectedElements", + "perform": [Function], + "trackEvent": Object { + "action": "delete", + "category": "element", + }, + }, + ], + "left": 30, + "top": 40, + }, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -177,7 +428,7 @@ Object { exports[`contextMenu element right-clicking on a group should select whole group: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element right-clicking on a group should select whole group: [end of test] number of renders 1`] = `6`; +exports[`contextMenu element right-clicking on a group should select whole group: [end of test] number of renders 1`] = `7`; exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] appState 1`] = ` Object { @@ -188,6 +439,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -362,7 +614,7 @@ Object { exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of elements 1`] = `1`; -exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `11`; +exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `14`; exports[`contextMenu element selecting 'Bring forward' in context menu brings element forward: [end of test] appState 1`] = ` Object { @@ -373,6 +625,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -716,7 +969,7 @@ Object { exports[`contextMenu element selecting 'Bring forward' in context menu brings element forward: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element selecting 'Bring forward' in context menu brings element forward: [end of test] number of renders 1`] = `16`; +exports[`contextMenu element selecting 'Bring forward' in context menu brings element forward: [end of test] number of renders 1`] = `18`; exports[`contextMenu element selecting 'Bring to front' in context menu brings element to front: [end of test] appState 1`] = ` Object { @@ -727,6 +980,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -1070,7 +1324,7 @@ Object { exports[`contextMenu element selecting 'Bring to front' in context menu brings element to front: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element selecting 'Bring to front' in context menu brings element to front: [end of test] number of renders 1`] = `16`; +exports[`contextMenu element selecting 'Bring to front' in context menu brings element to front: [end of test] number of renders 1`] = `18`; exports[`contextMenu element selecting 'Copy styles' in context menu copies styles: [end of test] appState 1`] = ` Object { @@ -1081,6 +1335,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -1255,7 +1510,7 @@ Object { exports[`contextMenu element selecting 'Copy styles' in context menu copies styles: [end of test] number of elements 1`] = `1`; -exports[`contextMenu element selecting 'Copy styles' in context menu copies styles: [end of test] number of renders 1`] = `11`; +exports[`contextMenu element selecting 'Copy styles' in context menu copies styles: [end of test] number of renders 1`] = `14`; exports[`contextMenu element selecting 'Delete' in context menu deletes element: [end of test] appState 1`] = ` Object { @@ -1266,6 +1521,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -1476,7 +1732,7 @@ Object { exports[`contextMenu element selecting 'Delete' in context menu deletes element: [end of test] number of elements 1`] = `1`; -exports[`contextMenu element selecting 'Delete' in context menu deletes element: [end of test] number of renders 1`] = `11`; +exports[`contextMenu element selecting 'Delete' in context menu deletes element: [end of test] number of renders 1`] = `14`; exports[`contextMenu element selecting 'Duplicate' in context menu duplicates element: [end of test] appState 1`] = ` Object { @@ -1487,6 +1743,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -1760,7 +2017,7 @@ Object { exports[`contextMenu element selecting 'Duplicate' in context menu duplicates element: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element selecting 'Duplicate' in context menu duplicates element: [end of test] number of renders 1`] = `11`; +exports[`contextMenu element selecting 'Duplicate' in context menu duplicates element: [end of test] number of renders 1`] = `14`; exports[`contextMenu element selecting 'Group selection' in context menu groups selected elements: [end of test] appState 1`] = ` Object { @@ -1771,6 +2028,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -2132,7 +2390,7 @@ Object { exports[`contextMenu element selecting 'Group selection' in context menu groups selected elements: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element selecting 'Group selection' in context menu groups selected elements: [end of test] number of renders 1`] = `17`; +exports[`contextMenu element selecting 'Group selection' in context menu groups selected elements: [end of test] number of renders 1`] = `20`; exports[`contextMenu element selecting 'Paste styles' in context menu pastes styles: [end of test] appState 1`] = ` Object { @@ -2143,6 +2401,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "#e64980", "currentItemEndArrowhead": "arrow", @@ -2978,7 +3237,7 @@ Object { exports[`contextMenu element selecting 'Paste styles' in context menu pastes styles: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element selecting 'Paste styles' in context menu pastes styles: [end of test] number of renders 1`] = `28`; +exports[`contextMenu element selecting 'Paste styles' in context menu pastes styles: [end of test] number of renders 1`] = `33`; exports[`contextMenu element selecting 'Send backward' in context menu sends element backward: [end of test] appState 1`] = ` Object { @@ -2989,6 +3248,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -3332,7 +3592,7 @@ Object { exports[`contextMenu element selecting 'Send backward' in context menu sends element backward: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element selecting 'Send backward' in context menu sends element backward: [end of test] number of renders 1`] = `15`; +exports[`contextMenu element selecting 'Send backward' in context menu sends element backward: [end of test] number of renders 1`] = `18`; exports[`contextMenu element selecting 'Send to back' in context menu sends element to back: [end of test] appState 1`] = ` Object { @@ -3343,6 +3603,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -3686,7 +3947,7 @@ Object { exports[`contextMenu element selecting 'Send to back' in context menu sends element to back: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element selecting 'Send to back' in context menu sends element to back: [end of test] number of renders 1`] = `15`; +exports[`contextMenu element selecting 'Send to back' in context menu sends element to back: [end of test] number of renders 1`] = `18`; exports[`contextMenu element selecting 'Ungroup selection' in context menu ungroups selected group: [end of test] appState 1`] = ` Object { @@ -3697,6 +3958,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -4124,7 +4386,7 @@ Object { exports[`contextMenu element selecting 'Ungroup selection' in context menu ungroups selected group: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element selecting 'Ungroup selection' in context menu ungroups selected group: [end of test] number of renders 1`] = `18`; +exports[`contextMenu element selecting 'Ungroup selection' in context menu ungroups selected group: [end of test] number of renders 1`] = `21`; exports[`contextMenu element shows 'Group selection' in context menu for multiple selected elements: [end of test] appState 1`] = ` Object { @@ -4135,6 +4397,257 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": Object { + "items": Array [ + Object { + "contextItemLabel": "labels.cut", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "cut", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copy", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "copy", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.paste", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "paste", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyAsPng", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "copyAsPng", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyAsSvg", + "contextItemPredicate": [Function], + "name": "copyAsSvg", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyText", + "contextItemPredicate": [Function], + "name": "copyText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyStyles", + "keyTest": [Function], + "name": "copyStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.pasteStyles", + "keyTest": [Function], + "name": "pasteStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.group", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "group", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.unbindText", + "contextItemPredicate": [Function], + "name": "unbindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.bindText", + "contextItemPredicate": [Function], + "name": "bindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.ungroup", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "ungroup", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.addToLibrary", + "name": "addToLibrary", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendBackward", + "keyPriority": 40, + "keyTest": [Function], + "name": "sendBackward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringForward", + "keyPriority": 40, + "keyTest": [Function], + "name": "bringForward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendToBack", + "keyTest": [Function], + "name": "sendToBack", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringToFront", + "keyTest": [Function], + "name": "bringToFront", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.flipHorizontal", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipHorizontal", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.flipVertical", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipVertical", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "name": "toggleLinearEditor", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "hyperlink", + "perform": [Function], + "trackEvent": Object { + "action": "click", + "category": "hyperlink", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.duplicateSelection", + "keyTest": [Function], + "name": "duplicateSelection", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": [Function], + "keyTest": [Function], + "name": "toggleLock", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.delete", + "keyTest": [Function], + "name": "deleteSelectedElements", + "perform": [Function], + "trackEvent": Object { + "action": "delete", + "category": "element", + }, + }, + ], + "left": -19, + "top": -9, + }, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -4414,7 +4927,7 @@ Object { exports[`contextMenu element shows 'Group selection' in context menu for multiple selected elements: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element shows 'Group selection' in context menu for multiple selected elements: [end of test] number of renders 1`] = `18`; +exports[`contextMenu element shows 'Group selection' in context menu for multiple selected elements: [end of test] number of renders 1`] = `20`; exports[`contextMenu element shows 'Ungroup selection' in context menu for group inside selected elements: [end of test] appState 1`] = ` Object { @@ -4425,6 +4938,257 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": Object { + "items": Array [ + Object { + "contextItemLabel": "labels.cut", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "cut", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copy", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "copy", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.paste", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "paste", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyAsPng", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "copyAsPng", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyAsSvg", + "contextItemPredicate": [Function], + "name": "copyAsSvg", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyText", + "contextItemPredicate": [Function], + "name": "copyText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyStyles", + "keyTest": [Function], + "name": "copyStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.pasteStyles", + "keyTest": [Function], + "name": "pasteStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.group", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "group", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.unbindText", + "contextItemPredicate": [Function], + "name": "unbindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.bindText", + "contextItemPredicate": [Function], + "name": "bindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.ungroup", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "ungroup", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.addToLibrary", + "name": "addToLibrary", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendBackward", + "keyPriority": 40, + "keyTest": [Function], + "name": "sendBackward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringForward", + "keyPriority": 40, + "keyTest": [Function], + "name": "bringForward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendToBack", + "keyTest": [Function], + "name": "sendToBack", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringToFront", + "keyTest": [Function], + "name": "bringToFront", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.flipHorizontal", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipHorizontal", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.flipVertical", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipVertical", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "name": "toggleLinearEditor", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "hyperlink", + "perform": [Function], + "trackEvent": Object { + "action": "click", + "category": "hyperlink", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.duplicateSelection", + "keyTest": [Function], + "name": "duplicateSelection", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": [Function], + "keyTest": [Function], + "name": "toggleLock", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.delete", + "keyTest": [Function], + "name": "deleteSelectedElements", + "perform": [Function], + "trackEvent": Object { + "action": "delete", + "category": "element", + }, + }, + ], + "left": -19, + "top": -9, + }, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -4789,7 +5553,7 @@ Object { exports[`contextMenu element shows 'Ungroup selection' in context menu for group inside selected elements: [end of test] number of elements 1`] = `2`; -exports[`contextMenu element shows 'Ungroup selection' in context menu for group inside selected elements: [end of test] number of renders 1`] = `19`; +exports[`contextMenu element shows 'Ungroup selection' in context menu for group inside selected elements: [end of test] number of renders 1`] = `21`; exports[`contextMenu element shows context menu for canvas: [end of test] appState 1`] = ` Object { @@ -4800,6 +5564,112 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": Object { + "items": Array [ + Object { + "contextItemLabel": "labels.paste", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "paste", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyAsPng", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "copyAsPng", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyAsSvg", + "contextItemPredicate": [Function], + "name": "copyAsSvg", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyText", + "contextItemPredicate": [Function], + "name": "copyText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.selectAll", + "keyTest": [Function], + "name": "selectAll", + "perform": [Function], + "trackEvent": Object { + "category": "canvas", + }, + }, + "separator", + Object { + "checked": [Function], + "contextItemLabel": "labels.showGrid", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "gridMode", + "perform": [Function], + "trackEvent": Object { + "category": "canvas", + "predicate": [Function], + }, + "viewMode": true, + }, + Object { + "checked": [Function], + "contextItemLabel": "buttons.zenMode", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "zenMode", + "perform": [Function], + "trackEvent": Object { + "category": "canvas", + "predicate": [Function], + }, + "viewMode": true, + }, + Object { + "checked": [Function], + "contextItemLabel": "labels.viewMode", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "viewMode", + "perform": [Function], + "trackEvent": Object { + "category": "canvas", + "predicate": [Function], + }, + "viewMode": true, + }, + Object { + "checked": [Function], + "contextItemLabel": "stats.title", + "keyTest": [Function], + "name": "stats", + "perform": [Function], + "trackEvent": Object { + "category": "menu", + }, + "viewMode": true, + }, + ], + "left": -19, + "top": -9, + }, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -4897,7 +5767,7 @@ Object { exports[`contextMenu element shows context menu for canvas: [end of test] number of elements 1`] = `0`; -exports[`contextMenu element shows context menu for canvas: [end of test] number of renders 1`] = `4`; +exports[`contextMenu element shows context menu for canvas: [end of test] number of renders 1`] = `6`; exports[`contextMenu element shows context menu for element: [end of test] appState 1`] = ` Object { @@ -4908,6 +5778,257 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": Object { + "items": Array [ + Object { + "contextItemLabel": "labels.cut", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "cut", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copy", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "copy", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.paste", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "paste", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyAsPng", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "copyAsPng", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyAsSvg", + "contextItemPredicate": [Function], + "name": "copyAsSvg", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyText", + "contextItemPredicate": [Function], + "name": "copyText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyStyles", + "keyTest": [Function], + "name": "copyStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.pasteStyles", + "keyTest": [Function], + "name": "pasteStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.group", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "group", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.unbindText", + "contextItemPredicate": [Function], + "name": "unbindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.bindText", + "contextItemPredicate": [Function], + "name": "bindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.ungroup", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "ungroup", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.addToLibrary", + "name": "addToLibrary", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendBackward", + "keyPriority": 40, + "keyTest": [Function], + "name": "sendBackward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringForward", + "keyPriority": 40, + "keyTest": [Function], + "name": "bringForward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendToBack", + "keyTest": [Function], + "name": "sendToBack", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringToFront", + "keyTest": [Function], + "name": "bringToFront", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.flipHorizontal", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipHorizontal", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.flipVertical", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipVertical", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "name": "toggleLinearEditor", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "hyperlink", + "perform": [Function], + "trackEvent": Object { + "action": "click", + "category": "hyperlink", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.duplicateSelection", + "keyTest": [Function], + "name": "duplicateSelection", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": [Function], + "keyTest": [Function], + "name": "toggleLock", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.delete", + "keyTest": [Function], + "name": "deleteSelectedElements", + "perform": [Function], + "trackEvent": Object { + "action": "delete", + "category": "element", + }, + }, + ], + "left": -19, + "top": -9, + }, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -4994,6 +6115,257 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": Object { + "items": Array [ + Object { + "contextItemLabel": "labels.cut", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "cut", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copy", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "copy", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.paste", + "contextItemPredicate": [Function], + "keyTest": undefined, + "name": "paste", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyAsPng", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "copyAsPng", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyAsSvg", + "contextItemPredicate": [Function], + "name": "copyAsSvg", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.copyText", + "contextItemPredicate": [Function], + "name": "copyText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.copyStyles", + "keyTest": [Function], + "name": "copyStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.pasteStyles", + "keyTest": [Function], + "name": "pasteStyles", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.group", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "group", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.unbindText", + "contextItemPredicate": [Function], + "name": "unbindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.bindText", + "contextItemPredicate": [Function], + "name": "bindText", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.ungroup", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "ungroup", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.addToLibrary", + "name": "addToLibrary", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendBackward", + "keyPriority": 40, + "keyTest": [Function], + "name": "sendBackward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringForward", + "keyPriority": 40, + "keyTest": [Function], + "name": "bringForward", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.sendToBack", + "keyTest": [Function], + "name": "sendToBack", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.bringToFront", + "keyTest": [Function], + "name": "bringToFront", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": "labels.flipHorizontal", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipHorizontal", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": "labels.flipVertical", + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "flipVertical", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "name": "toggleLinearEditor", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": [Function], + "contextItemPredicate": [Function], + "keyTest": [Function], + "name": "hyperlink", + "perform": [Function], + "trackEvent": Object { + "action": "click", + "category": "hyperlink", + }, + }, + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.duplicateSelection", + "keyTest": [Function], + "name": "duplicateSelection", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + Object { + "contextItemLabel": [Function], + "keyTest": [Function], + "name": "toggleLock", + "perform": [Function], + "trackEvent": Object { + "category": "element", + }, + }, + "separator", + Object { + "PanelComponent": [Function], + "contextItemLabel": "labels.delete", + "keyTest": [Function], + "name": "deleteSelectedElements", + "perform": [Function], + "trackEvent": Object { + "action": "delete", + "category": "element", + }, + }, + ], + "left": 80, + "top": 90, + }, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -5250,6 +6622,6 @@ exports[`contextMenu element shows context menu for element: [end of test] numbe exports[`contextMenu element shows context menu for element: [end of test] number of elements 2`] = `2`; -exports[`contextMenu element shows context menu for element: [end of test] number of renders 1`] = `10`; +exports[`contextMenu element shows context menu for element: [end of test] number of renders 1`] = `12`; -exports[`contextMenu element shows context menu for element: [end of test] number of renders 2`] = `7`; +exports[`contextMenu element shows context menu for element: [end of test] number of renders 2`] = `11`; diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index ca181ddbf..f842f48c5 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -9,6 +9,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -544,6 +545,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -1085,6 +1087,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -1991,6 +1994,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -2220,6 +2224,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -2752,6 +2757,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -3040,6 +3046,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -3223,6 +3230,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -3738,6 +3746,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "#fa5252", "currentItemEndArrowhead": "arrow", @@ -4005,6 +4014,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -4234,6 +4244,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -4509,6 +4520,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -4796,6 +4808,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -5213,6 +5226,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -5553,6 +5567,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -5866,6 +5881,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -6103,6 +6119,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -6288,6 +6305,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -6815,6 +6833,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -7179,6 +7198,7 @@ Object { "type": "freedraw", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -9530,6 +9550,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -9948,6 +9969,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "#fa5252", "currentItemEndArrowhead": "arrow", @@ -10236,6 +10258,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -10483,6 +10506,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -10803,6 +10827,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -10986,6 +11011,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -11169,6 +11195,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -11352,6 +11379,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -11588,6 +11616,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -11824,6 +11853,7 @@ Object { "type": "freedraw", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -12051,6 +12081,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -12287,6 +12318,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -12470,6 +12502,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -12706,6 +12739,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -12889,6 +12923,7 @@ Object { "type": "freedraw", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -13116,6 +13151,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -13299,6 +13335,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -14137,6 +14174,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -14425,6 +14463,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -14535,6 +14574,7 @@ Object { "type": "rectangle", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -14643,6 +14683,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -14829,6 +14870,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -15196,6 +15238,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -15826,6 +15869,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "#fa5252", "currentItemEndArrowhead": "arrow", @@ -16051,6 +16095,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -17013,6 +17058,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -17121,6 +17167,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -17979,6 +18026,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -18450,6 +18498,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -18790,6 +18839,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -18900,6 +18950,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -19470,6 +19521,7 @@ Object { "type": "text", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", @@ -19578,6 +19630,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", diff --git a/src/tests/elementLocking.test.tsx b/src/tests/elementLocking.test.tsx index 6b98aa1ea..9cf0968d4 100644 --- a/src/tests/elementLocking.test.tsx +++ b/src/tests/elementLocking.test.tsx @@ -152,7 +152,7 @@ describe("element locking", () => { expect(contextMenu).not.toBeNull(); expect( contextMenu?.querySelector( - `li[data-testid="toggleLock"] .context-menu-option__label`, + `li[data-testid="toggleLock"] .context-menu-item__label`, ), ).toHaveTextContent(t("labels.elementLock.unlock")); }); diff --git a/src/tests/packages/__snapshots__/utils.test.ts.snap b/src/tests/packages/__snapshots__/utils.test.ts.snap index da3d8439b..a74da5dc8 100644 --- a/src/tests/packages/__snapshots__/utils.test.ts.snap +++ b/src/tests/packages/__snapshots__/utils.test.ts.snap @@ -9,6 +9,7 @@ Object { "type": "selection", }, "collaborators": Map {}, + "contextMenu": null, "currentChartType": "bar", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", diff --git a/src/types.ts b/src/types.ts index e83a4226a..218ca4dee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,7 @@ import { MaybeTransformHandleType } from "./element/transformHandles"; import Library from "./data/library"; import type { FileSystemHandle } from "./data/filesystem"; import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; +import { ContextMenuItems } from "./components/ContextMenu"; export type Point = Readonly; @@ -92,6 +93,11 @@ export type LastActiveToolBeforeEraser = | null; export type AppState = { + contextMenu: { + items: ContextMenuItems; + top: number; + left: number; + } | null; showWelcomeScreen: boolean; isLoading: boolean; errorMessage: string | null; @@ -147,6 +153,7 @@ export type AppState = { isResizing: boolean; isRotating: boolean; zoom: Zoom; + // mobile-only openMenu: "canvas" | "shape" | null; openPopup: | "canvasColorPicker" @@ -407,6 +414,7 @@ export type AppClassProperties = { files: BinaryFiles; device: App["device"]; scene: App["scene"]; + pasteFromClipboard: App["pasteFromClipboard"]; }; export type PointerDownState = Readonly<{ From 6273d5652448329107f8d480362019696e12ad1a Mon Sep 17 00:00:00 2001 From: zsviczian Date: Thu, 22 Dec 2022 13:53:49 +0100 Subject: [PATCH 007/252] fix: ColorPicker getColor (#5949) Co-authored-by: dwelle --- src/components/ColorPicker.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 03ab4a5be..cb05cf446 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -66,10 +66,13 @@ const getColor = (color: string): string | null => { return color; } - return isValidColor(color) - ? color - : isValidColor(`#${color}`) + // testing for `#` first fixes a bug on Electron (more specfically, an + // Obsidian popout window), where a hex color without `#` is (incorrectly) + // considered valid + return isValidColor(`#${color}`) ? `#${color}` + : isValidColor(color) + ? color : null; }; From 9086674b271d57682f2d246d5301eabc7a9e06dd Mon Sep 17 00:00:00 2001 From: David Luzar Date: Thu, 22 Dec 2022 19:32:21 +0100 Subject: [PATCH 008/252] chore: bump typescript @ 4.9.4 (#6024) --- package.json | 2 +- src/data/library.ts | 3 ++- yarn.lock | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index bf9414c44..2923ddd54 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "roughjs": "4.5.2", "sass": "1.51.0", "socket.io-client": "2.3.1", - "typescript": "4.5.5", + "typescript": "4.9.4", "workbox-background-sync": "^6.5.4", "workbox-broadcast-update": "^6.5.4", "workbox-cacheable-response": "^6.5.4", diff --git a/src/data/library.ts b/src/data/library.ts index e1b4fde7c..564ccc7d3 100644 --- a/src/data/library.ts +++ b/src/data/library.ts @@ -154,7 +154,8 @@ class Library { return this.setLibrary(() => { return new Promise(async (resolve, reject) => { try { - const source = await (typeof libraryItems === "function" + const source = await (typeof libraryItems === "function" && + !(libraryItems instanceof Blob) ? libraryItems(this.lastLibraryItems) : libraryItems); diff --git a/yarn.lock b/yarn.lock index 1e2d24b96..2bc160d69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10296,10 +10296,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@4.5.5: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@4.9.4: + version "4.9.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" + integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" From 8ec5f7b982f1842a19c32322fac73b9a5bef9cb5 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 23 Dec 2022 11:57:48 +0530 Subject: [PATCH 009/252] feat: support shrinking text containers to original height when text removed (#6025) * fix:cache bind text containers height so that it could autoshrink to original height when text deleted * revert * rename * reset cache when resized * safe check * restore original containr height when text is unbind * update cache when redrawing bounding box * reset cache when unbind * make type-safe * add specs * skip one test * remoe mock * fix Co-authored-by: dwelle --- src/actions/actionBoundText.tsx | 12 ++++ src/element/textElement.ts | 7 +- src/element/textWysiwyg.test.tsx | 112 ++++++++++++++++++++++++++++++- src/element/textWysiwyg.tsx | 88 +++++++++++++++++++----- 4 files changed, 200 insertions(+), 19 deletions(-) diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index 07a56d873..812e10b3c 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -6,6 +6,10 @@ import { measureText, redrawTextBoundingBox, } from "../element/textElement"; +import { + getOriginalContainerHeightFromCache, + resetOriginalContainerCache, +} from "../element/textWysiwyg"; import { hasBoundTextElement, isTextBindableContainer, @@ -38,6 +42,11 @@ export const actionUnbindText = register({ boundTextElement.originalText, getFontString(boundTextElement), ); + const originalContainerHeight = getOriginalContainerHeightFromCache( + element.id, + ); + resetOriginalContainerCache(element.id); + mutateElement(boundTextElement as ExcalidrawTextElement, { containerId: null, width, @@ -49,6 +58,9 @@ export const actionUnbindText = register({ boundElements: element.boundElements?.filter( (ele) => ele.id !== boundTextElement.id, ), + height: originalContainerHeight + ? originalContainerHeight + : element.height, }); } }); diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 3a03f936a..47632bb5d 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -24,6 +24,10 @@ import { isTextBindableContainer } from "./typeChecks"; import { getElementAbsoluteCoords } from "../element"; import { getSelectedElements } from "../scene"; import { isHittingElementNotConsideringBoundingBox } from "./collision"; +import { + resetOriginalContainerCache, + updateOriginalContainerCache, +} from "./textWysiwyg"; export const normalizeText = (text: string) => { return ( @@ -84,7 +88,7 @@ export const redrawTextBoundingBox = ( } else { coordX = container.x + containerDims.width / 2 - metrics.width / 2; } - + updateOriginalContainerCache(container.id, nextHeight); mutateElement(container, { height: nextHeight }); } else { const centerX = textElement.x + textElement.width / 2; @@ -149,6 +153,7 @@ export const handleBindTextResize = ( if (!boundTextElementId) { return; } + resetOriginalContainerCache(container.id); let textElement = Scene.getScene(container)!.getElement( boundTextElementId, ) as ExcalidrawTextElement; diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index d06d9236e..59ed28295 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -15,6 +15,7 @@ import * as textElementUtils from "./textElement"; import { API } from "../tests/helpers/api"; import { mutateElement } from "./mutateElement"; import { resize } from "../tests/utils"; +import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -1019,7 +1020,6 @@ describe("textWysiwyg", () => { const originalRectY = rectangle.y; const originalTextX = text.x; const originalTextY = text.y; - mouse.select(rectangle); mouse.downAt(rectangle.x, rectangle.y); mouse.moveTo(rectangle.x + 100, rectangle.y + 50); @@ -1055,5 +1055,115 @@ describe("textWysiwyg", () => { expect(rectangle.boundElements).toStrictEqual([]); expect(h.elements[1].isDeleted).toBe(true); }); + + it("should restore original container height and clear cache once text is unbind", async () => { + jest + .spyOn(textElementUtils, "measureText") + .mockImplementation((text, font, maxWidth) => { + let width = INITIAL_WIDTH; + let height = APPROX_LINE_HEIGHT; + let baseline = 10; + if (!text) { + return { + width, + height, + baseline, + }; + } + baseline = 30; + width = DUMMY_WIDTH; + height = APPROX_LINE_HEIGHT * 5; + + return { + width, + height, + baseline, + }; + }); + const originalRectHeight = rectangle.height; + expect(rectangle.height).toBe(originalRectHeight); + + Keyboard.keyPress(KEYS.ENTER); + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + await new Promise((r) => setTimeout(r, 0)); + + fireEvent.change(editor, { + target: { value: "Online whiteboard collaboration made easy" }, + }); + editor.blur(); + expect(rectangle.height).toBe(135); + mouse.select(rectangle); + fireEvent.contextMenu(GlobalTestState.canvas, { + button: 2, + clientX: 20, + clientY: 30, + }); + const contextMenu = document.querySelector(".context-menu"); + fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!); + expect(h.elements[0].boundElements).toEqual([]); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); + + expect(rectangle.height).toBe(originalRectHeight); + }); + + it("should reset the container height cache when resizing", async () => { + Keyboard.keyPress(KEYS.ENTER); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + let editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + await new Promise((r) => setTimeout(r, 0)); + fireEvent.change(editor, { target: { value: "Hello" } }); + editor.blur(); + + resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); + expect(rectangle.height).toBe(215); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); + + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + + editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + await new Promise((r) => setTimeout(r, 0)); + editor.blur(); + expect(rectangle.height).toBe(215); + // cache updated again + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215); + }); + + //@todo fix this test later once measureText is mocked correctly + it.skip("should reset the container height cache when font properties updated", async () => { + Keyboard.keyPress(KEYS.ENTER); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + await new Promise((r) => setTimeout(r, 0)); + fireEvent.change(editor, { target: { value: "Hello World!" } }); + editor.blur(); + + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + + fireEvent.click(screen.getByTitle(/code/i)); + + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, + ).toEqual(FONT_FAMILY.Cascadia); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + + fireEvent.click(screen.getByTitle(/Very large/i)); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, + ).toEqual(36); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + }); }); }); diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index a9737b461..3f4623258 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -17,6 +17,7 @@ import { ExcalidrawLinearElement, ExcalidrawTextElementWithContainer, ExcalidrawTextElement, + ExcalidrawTextContainer, } from "./types"; import { AppState } from "../types"; import { mutateElement } from "./mutateElement"; @@ -60,6 +61,38 @@ const getTransform = ( return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; }; +const originalContainerCache: { + [id: ExcalidrawTextContainer["id"]]: + | { + height: ExcalidrawTextContainer["height"]; + } + | undefined; +} = {}; + +export const updateOriginalContainerCache = ( + id: ExcalidrawTextContainer["id"], + height: ExcalidrawTextContainer["height"], +) => { + const data = + originalContainerCache[id] || (originalContainerCache[id] = { height }); + data.height = height; + return data; +}; + +export const resetOriginalContainerCache = ( + id: ExcalidrawTextContainer["id"], +) => { + if (originalContainerCache[id]) { + delete originalContainerCache[id]; + } +}; + +export const getOriginalContainerHeightFromCache = ( + id: ExcalidrawTextContainer["id"], +) => { + return originalContainerCache[id]?.height ?? null; +}; + export const textWysiwyg = ({ id, onChange, @@ -87,6 +120,9 @@ export const textWysiwyg = ({ updatedTextElement: ExcalidrawTextElement, editable: HTMLTextAreaElement, ) => { + if (!editable.style.fontFamily || !editable.style.fontSize) { + return false; + } const currentFont = editable.style.fontFamily.replace(/"/g, ""); if ( getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !== @@ -99,7 +135,6 @@ export const textWysiwyg = ({ } return false; }; - let originalContainerHeight: number; const updateWysiwygStyle = () => { const appState = app.state; @@ -123,7 +158,7 @@ export const textWysiwyg = ({ const width = updatedTextElement.width; // Set to element height by default since that's // what is going to be used for unbounded text - let height = updatedTextElement.height; + let textElementHeight = updatedTextElement.height; if (container && updatedTextElement.containerId) { if (isArrowElement(container)) { const boundTextCoords = @@ -142,34 +177,52 @@ export const textWysiwyg = ({ // using editor.style.height to get the accurate height of text editor const editorHeight = Number(editable.style.height.slice(0, -2)); if (editorHeight > 0) { - height = editorHeight; + textElementHeight = editorHeight; } if (propertiesUpdated) { - originalContainerHeight = containerDims.height; - // update height of the editor after properties updated - height = updatedTextElement.height; + textElementHeight = updatedTextElement.height; } - if (!originalContainerHeight) { - originalContainerHeight = containerDims.height; + + let originalContainerData; + if (propertiesUpdated) { + originalContainerData = updateOriginalContainerCache( + container.id, + containerDims.height, + ); + } else { + originalContainerData = originalContainerCache[container.id]; + if (!originalContainerData) { + originalContainerData = updateOriginalContainerCache( + container.id, + containerDims.height, + ); + } } + maxWidth = getMaxContainerWidth(container); maxHeight = getMaxContainerHeight(container); // autogrow container height if text exceeds - if (!isArrowElement(container) && height > maxHeight) { - const diff = Math.min(height - maxHeight, approxLineHeight); + if (!isArrowElement(container) && textElementHeight > maxHeight) { + const diff = Math.min( + textElementHeight - maxHeight, + approxLineHeight, + ); mutateElement(container, { height: containerDims.height + diff }); return; } else if ( // autoshrink container height until original container height // is reached when text is removed !isArrowElement(container) && - containerDims.height > originalContainerHeight && - height < maxHeight + containerDims.height > originalContainerData.height && + textElementHeight < maxHeight ) { - const diff = Math.min(maxHeight - height, approxLineHeight); + const diff = Math.min( + maxHeight - textElementHeight, + approxLineHeight, + ); mutateElement(container, { height: containerDims.height - diff }); } // Start pushing text upward until a diff of 30px (padding) @@ -178,14 +231,15 @@ export const textWysiwyg = ({ // vertically center align the text if (verticalAlign === VERTICAL_ALIGN.MIDDLE) { if (!isArrowElement(container)) { - coordY = container.y + containerDims.height / 2 - height / 2; + coordY = + container.y + containerDims.height / 2 - textElementHeight / 2; } } if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { coordY = container.y + containerDims.height - - height - + textElementHeight - getBoundTextElementOffset(updatedTextElement); } } @@ -226,12 +280,12 @@ export const textWysiwyg = ({ // must be defined *after* font ¯\_(ツ)_/¯ lineHeight: `${lineHeight}px`, width: `${Math.min(width, maxWidth)}px`, - height: `${height}px`, + height: `${textElementHeight}px`, left: `${viewportX}px`, top: `${viewportY}px`, transform: getTransform( width, - height, + textElementHeight, getTextElementAngle(updatedTextElement), appState, maxWidth, From 2595e0de826d1e1cb2f0594fcf3a1b2d48f77de4 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Fri, 23 Dec 2022 11:48:14 +0100 Subject: [PATCH 010/252] fix: restoring deleted bindings (#6029) * fix: restoring deleted bindings * add tests * add one more test * merge restore tests files --- src/data/restore.ts | 15 ++- src/tests/data/restore.test.ts | 185 +++++++++++++++++++++++++++++++-- 2 files changed, 191 insertions(+), 9 deletions(-) diff --git a/src/data/restore.ts b/src/data/restore.ts index be33b7f20..976a0551b 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -273,6 +273,14 @@ const repairContainerElement = ( ) => { const boundElement = elementsMap.get(binding.id); if (boundElement && !boundIds.has(binding.id)) { + boundIds.add(binding.id); + + if (boundElement.isDeleted) { + return acc; + } + + acc.push(binding); + if ( isTextElement(boundElement) && // being slightly conservative here, preserving existing containerId @@ -282,9 +290,6 @@ const repairContainerElement = ( (boundElement as Mutable).containerId = container.id; } - - acc.push(binding); - boundIds.add(binding.id); } return acc; }, @@ -312,6 +317,10 @@ const repairBoundElement = ( return; } + if (boundElement.isDeleted) { + return; + } + if ( container.boundElements && !container.boundElements.find((binding) => binding.id === boundElement.id) diff --git a/src/tests/data/restore.test.ts b/src/tests/data/restore.test.ts index 6e6bf0a3e..ef0f1a115 100644 --- a/src/tests/data/restore.test.ts +++ b/src/tests/data/restore.test.ts @@ -13,13 +13,17 @@ import { NormalizedZoomValue } from "../../types"; import { FONT_FAMILY, ROUNDNESS } from "../../constants"; import { newElementWith } from "../../element/mutateElement"; -const mockSizeHelper = jest.spyOn(sizeHelpers, "isInvisiblySmallElement"); - -beforeEach(() => { - mockSizeHelper.mockReset(); -}); - describe("restoreElements", () => { + const mockSizeHelper = jest.spyOn(sizeHelpers, "isInvisiblySmallElement"); + + beforeEach(() => { + mockSizeHelper.mockReset(); + }); + + afterAll(() => { + mockSizeHelper.mockRestore(); + }); + it("should return empty array when element is null", () => { expect(restore.restoreElements(null, null)).toStrictEqual([]); }); @@ -528,3 +532,172 @@ describe("restore", () => { ]); }); }); + +describe("repairing bindings", () => { + it("should repair container boundElements", () => { + const container = API.createElement({ + type: "rectangle", + boundElements: [], + }); + const boundElement = API.createElement({ + type: "text", + containerId: container.id, + }); + + expect(container.boundElements).toEqual([]); + + const restoredElements = restore.restoreElements( + [container, boundElement], + null, + ); + + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: container.id, + boundElements: [{ type: boundElement.type, id: boundElement.id }], + }), + expect.objectContaining({ + id: boundElement.id, + containerId: container.id, + }), + ]); + }); + + it("should repair containerId of boundElements", () => { + const boundElement = API.createElement({ + type: "text", + containerId: null, + }); + const container = API.createElement({ + type: "rectangle", + boundElements: [{ type: boundElement.type, id: boundElement.id }], + }); + + const restoredElements = restore.restoreElements( + [container, boundElement], + null, + ); + + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: container.id, + boundElements: [{ type: boundElement.type, id: boundElement.id }], + }), + expect.objectContaining({ + id: boundElement.id, + containerId: container.id, + }), + ]); + }); + + it("should ignore bound element if deleted", () => { + const container = API.createElement({ + type: "rectangle", + boundElements: [], + }); + const boundElement = API.createElement({ + type: "text", + containerId: container.id, + isDeleted: true, + }); + + expect(container.boundElements).toEqual([]); + + const restoredElements = restore.restoreElements( + [container, boundElement], + null, + ); + + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: container.id, + boundElements: [], + }), + expect.objectContaining({ + id: boundElement.id, + containerId: container.id, + }), + ]); + }); + + it("should remove bindings of deleted elements from boundElements", () => { + const container = API.createElement({ + type: "rectangle", + boundElements: [], + }); + const boundElement = API.createElement({ + type: "text", + containerId: container.id, + isDeleted: true, + }); + const invisibleBoundElement = API.createElement({ + type: "text", + containerId: container.id, + width: 0, + height: 0, + }); + + const obsoleteBinding = { type: boundElement.type, id: boundElement.id }; + const invisibleBinding = { + type: invisibleBoundElement.type, + id: invisibleBoundElement.id, + }; + const nonExistentBinding = { type: "text", id: "non-existent" }; + // @ts-ignore + container.boundElements = [ + obsoleteBinding, + invisibleBinding, + nonExistentBinding, + ]; + + expect(container.boundElements).toEqual([ + obsoleteBinding, + invisibleBinding, + nonExistentBinding, + ]); + + const restoredElements = restore.restoreElements( + [container, invisibleBoundElement, boundElement], + null, + ); + + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: container.id, + boundElements: [], + }), + expect.objectContaining({ + id: boundElement.id, + containerId: container.id, + }), + ]); + }); + + it("should remove containerId if container not exists", () => { + const boundElement = API.createElement({ + type: "text", + containerId: "non-existent", + }); + const boundElementDeleted = API.createElement({ + type: "text", + containerId: "non-existent", + isDeleted: true, + }); + + const restoredElements = restore.restoreElements( + [boundElement, boundElementDeleted], + null, + ); + + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: boundElement.id, + containerId: null, + }), + expect.objectContaining({ + id: boundElementDeleted.id, + containerId: null, + }), + ]); + }); +}); From af3b93c4103d1c9e42f1ab79d8087d4551e0a9bd Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 23 Dec 2022 21:45:49 +0530 Subject: [PATCH 011/252] fix: use canvas measureText to calculate width in measureText (#6030) * fix: use canvas measureText to calculate width in measureText * calculate multiline width correctly using canvas measure text and rename functions * set correct width when pasting in bound container * take existing value + new pasted * remove debugger :p * fix snaps --- src/element/textElement.test.ts | 2 +- src/element/textElement.ts | 32 +++++++++++++------ src/element/textWysiwyg.test.tsx | 6 ++-- src/element/textWysiwyg.tsx | 28 ++++++++++++++++ .../data/__snapshots__/restore.test.ts.snap | 2 +- src/tests/linearElementEditor.test.tsx | 4 +-- 6 files changed, 58 insertions(+), 16 deletions(-) diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index c9027205e..c61db05f2 100644 --- a/src/element/textElement.test.ts +++ b/src/element/textElement.test.ts @@ -157,7 +157,7 @@ describe("Test measureText", () => { expect(res.container).toMatchInlineSnapshot(`
maxWidth) { + width = width - 1; + } const height = container.offsetHeight; document.body.removeChild(container); if (isTestEnv()) { @@ -312,7 +318,7 @@ export const getApproxLineHeight = (font: FontString) => { }; let canvas: HTMLCanvasElement | undefined; -const getTextWidth = (text: string, font: FontString) => { +const getLineWidth = (text: string, font: FontString) => { if (!canvas) { canvas = document.createElement("canvas"); } @@ -330,10 +336,18 @@ const getTextWidth = (text: string, font: FontString) => { return metrics.width; }; +export const getTextWidth = (text: string, font: FontString) => { + const lines = text.split("\n"); + let width = 0; + lines.forEach((line) => { + width = Math.max(width, getLineWidth(line, font)); + }); + return width; +}; export const wrapText = (text: string, font: FontString, maxWidth: number) => { const lines: Array = []; const originalLines = text.split("\n"); - const spaceWidth = getTextWidth(" ", font); + const spaceWidth = getLineWidth(" ", font); const push = (str: string) => { if (str.trim()) { @@ -351,7 +365,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { let index = 0; while (index < words.length) { - const currentWordWidth = getTextWidth(words[index], font); + const currentWordWidth = getLineWidth(words[index], font); // Start breaking longer words exceeding max width if (currentWordWidth >= maxWidth) { @@ -400,7 +414,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { // Start appending words in a line till max width reached while (currentLineWidthTillNow < maxWidth && index < words.length) { const word = words[index]; - currentLineWidthTillNow = getTextWidth(currentLine + word, font); + currentLineWidthTillNow = getLineWidth(currentLine + word, font); if (currentLineWidthTillNow >= maxWidth) { push(currentLine); @@ -448,7 +462,7 @@ export const charWidth = (() => { cachedCharWidth[font] = []; } if (!cachedCharWidth[font][ascii]) { - const width = getTextWidth(char, font); + const width = getLineWidth(char, font); cachedCharWidth[font][ascii] = width; } @@ -508,7 +522,7 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => { while (widthTillNow <= width) { const batch = dummyText.substr(index, index + batchLength); str += batch; - widthTillNow += getTextWidth(str, font); + widthTillNow += getLineWidth(str, font); if (index === dummyText.length - 1) { index = 0; } @@ -517,7 +531,7 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => { while (widthTillNow > width) { str = str.substr(0, str.length - 1); - widthTillNow = getTextWidth(str, font); + widthTillNow = getLineWidth(str, font); } return str.length; }; diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index 59ed28295..cb24386e4 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -862,7 +862,7 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 110.5, + 110, 17, ] `); @@ -910,7 +910,7 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 426, + 425, -539, ] `); @@ -1026,7 +1026,7 @@ describe("textWysiwyg", () => { mouse.up(rectangle.x + 100, rectangle.y + 50); expect(rectangle.x).toBe(80); expect(rectangle.y).toBe(85); - expect(text.x).toBe(90.5); + expect(text.x).toBe(90); expect(text.y).toBe(90); Keyboard.withModifierKeys({ ctrl: true }, () => { diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 3f4623258..9cb961380 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -28,6 +28,7 @@ import { getContainerDims, getContainerElement, getTextElementAngle, + getTextWidth, normalizeText, wrapText, } from "./textElement"; @@ -39,6 +40,7 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import App from "../components/App"; import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; import { LinearElementEditor } from "./linearElementEditor"; +import { parseClipboard } from "../clipboard"; const getTransform = ( width: number, @@ -348,6 +350,32 @@ export const textWysiwyg = ({ updateWysiwygStyle(); if (onChange) { + editable.onpaste = async (event) => { + const clipboardData = await parseClipboard(event, true); + if (!clipboardData.text) { + return; + } + const data = normalizeText(clipboardData.text); + if (!data) { + return; + } + const container = getContainerElement(element); + + const font = getFontString({ + fontSize: app.state.currentItemFontSize, + fontFamily: app.state.currentItemFontFamily, + }); + if (container) { + const wrappedText = wrapText( + `${editable.value}${data}`, + font, + getMaxContainerWidth(container), + ); + const width = getTextWidth(wrappedText, font); + editable.style.width = `${width}px`; + } + }; + editable.oninput = () => { const updatedTextElement = Scene.getScene(element)?.getElement( id, diff --git a/src/tests/data/__snapshots__/restore.test.ts.snap b/src/tests/data/__snapshots__/restore.test.ts.snap index 444bc0ea2..20cf1e7d0 100644 --- a/src/tests/data/__snapshots__/restore.test.ts.snap +++ b/src/tests/data/__snapshots__/restore.test.ts.snap @@ -312,7 +312,7 @@ Object { "versionNonce": 0, "verticalAlign": "middle", "width": 100, - "x": 0.5, + "x": 0, "y": 0, } `; diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index 68d971710..af9b76a13 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/src/tests/linearElementEditor.test.tsx @@ -1027,7 +1027,7 @@ describe("Test Linear Elements", () => { expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` Object { - "x": 387.5, + "x": 387, "y": 70, } `); @@ -1086,7 +1086,7 @@ describe("Test Linear Elements", () => { expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` Object { - "x": 190.5, + "x": 190, "y": 20, } `); From 5fcf6a48452b1805323b31e1a7773eec9193ff9c Mon Sep 17 00:00:00 2001 From: David Luzar Date: Fri, 23 Dec 2022 19:40:52 +0100 Subject: [PATCH 012/252] fix: remove background from wysiwyg when editing arrow label (#6033) Co-authored-by: Aakansha Doshi --- src/element/textWysiwyg.tsx | 4 +--- .../__snapshots__/linearElementEditor.test.tsx.snap | 12 ++++++++++++ src/tests/linearElementEditor.test.tsx | 9 +++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 src/tests/__snapshots__/linearElementEditor.test.tsx.snap diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 9cb961380..d525221aa 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -325,8 +325,6 @@ export const textWysiwyg = ({ whiteSpace = "pre-wrap"; wordBreak = "break-word"; } - const isContainerArrow = isArrowElement(getContainerElement(element)); - const background = isContainerArrow ? "#fff" : "transparent"; Object.assign(editable.style, { position: "absolute", display: "inline-block", @@ -337,7 +335,7 @@ export const textWysiwyg = ({ border: 0, outline: 0, resize: "none", - background, + background: "transparent", overflow: "hidden", // must be specified because in dark mode canvas creates a stacking context zIndex: "var(--zIndex-wysiwyg)", diff --git a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap new file mode 100644 index 000000000..afc175cb9 --- /dev/null +++ b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Test Linear Elements Test bound text element should match styles for text editor 1`] = ` +