diff --git a/.gitignore b/.gitignore index 4a3f6f367..e637a8c0f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ src/packages/excalidraw/types src/packages/excalidraw/example/public/bundle.js src/packages/excalidraw/example/public/excalidraw-assets-dev src/packages/excalidraw/example/public/excalidraw.development.js +coverage diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx index a37843c76..08e807907 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx @@ -1,6 +1,19 @@ # ref +
-createRef | useRef | callbackRef | 
{ current: { readyPromise: resolvablePromise } } + + createRef + {" "} + |{" "} + useRef{" "} + |{" "} + + callbackRef + {" "} + |
+ { current: { readyPromise: + resolvablePromise + } }
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs: @@ -139,7 +152,9 @@ function App() { return (

Click to update the scene

- + setExcalidrawAPI(api)} />
); @@ -187,7 +202,8 @@ function App() { return (

Click to update the library items

-
); diff --git a/src/global.d.ts b/src/global.d.ts index 4ccd8f3fe..4a70443d4 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -50,36 +50,6 @@ interface Clipboard extends EventTarget { write(data: any[]): Promise; } -type Mutable = { - -readonly [P in keyof T]: T[P]; -}; - -type ValueOf = T[keyof T]; - -type Merge = Omit & N; - -/** utility type to assert that the second type is a subtype of the first type. - * Returns the subtype. */ -type SubtypeOf = Subtype; - -type ResolutionType any> = T extends ( - ...args: any -) => Promise - ? R - : any; - -// https://github.com/krzkaczor/ts-essentials -type MarkOptional = Omit & Partial>; - -type MarkRequired = Exclude & - Required>; - -type MarkNonNullable = { - [P in K]-?: P extends K ? NonNullable : T[P]; -} & { [P in keyof T]: T[P] }; - -type NonOptional = Exclude; - // PNG encoding/decoding // ----------------------------------------------------------------------------- type TEXtChunk = { name: "tEXt"; data: Uint8Array }; @@ -101,23 +71,6 @@ declare module "png-chunks-extract" { } // ----------------------------------------------------------------------------- -// ----------------------------------------------------------------------------- -// type getter for interface's callable type -// src: https://stackoverflow.com/a/58658851/927631 -// ----------------------------------------------------------------------------- -type SignatureType = T extends (...args: infer R) => any ? R : never; -type CallableType any> = ( - ...args: SignatureType -) => ReturnType; -// --------------------------------------------------------------------------— - -// Type for React.forwardRef --- supply only the first generic argument T -type ForwardRef = Parameters< - CallableType> ->[1]; - -// --------------------------------------------------------------------------— - interface Blob { handle?: import("browser-fs-acces").FileSystemHandle; name?: string; @@ -165,5 +118,3 @@ declare module "image-blob-reduce" { const reduce: ImageBlobReduce.ImageBlobReduceStatic; export = reduce; } - -type ExtractSetType> = T extends Set ? U : never; diff --git a/src/history.ts b/src/history.ts index cc620cae1..d102a7ecc 100644 --- a/src/history.ts +++ b/src/history.ts @@ -2,6 +2,7 @@ import { AppState } from "./types"; import { ExcalidrawElement } from "./element/types"; import { isLinearElement } from "./element/typeChecks"; import { deepCopyElement } from "./element/newElement"; +import { Mutable } from "./utility-types"; export interface HistoryEntry { appState: ReturnType; diff --git a/src/locales/en.json b/src/locales/en.json index 19bab5709..daef452f9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -120,7 +120,6 @@ "edit": "Edit line", "exit": "Exit line editor" }, - "elementLock": { "lock": "Lock", "unlock": "Unlock", @@ -207,7 +206,22 @@ "importLibraryError": "Couldn't load library", "collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.", "collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.", - "imageToolNotSupported": "Images are disabled." + "imageToolNotSupported": "Images are disabled.", + "brave_measure_text_error": { + "start": "Looks like you are using Brave browser with the", + "aggressive_block_fingerprint": "Aggressively Block Fingerprinting", + "setting_enabled": "setting enabled", + "break": "This could result in breaking the", + "text_elements": "Text Elements", + "in_your_drawings": "in your drawings", + "strongly_recommend": "We strongly recommend disabling this setting. You can follow", + "steps": "these steps", + "how": "on how to do so", + "disable_setting": " If disabling this setting doesn't fix the display of text elements, please open an", + "issue": "issue", + "write": "on our GitHub, or write us on", + "discord": "Discord" + } }, "toolBar": { "selection": "Selection", diff --git a/src/math.ts b/src/math.ts index cfa28e230..602fe976c 100644 --- a/src/math.ts +++ b/src/math.ts @@ -12,6 +12,7 @@ import { } from "./element/types"; import { getShapeForElement } from "./renderer/renderElement"; import { getCurvePathOps } from "./element/bounds"; +import { Mutable } from "./utility-types"; export const rotate = ( x1: number, diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index c2ba8451c..628177a4a 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section. ### Features +- [`ExcalidrawAPI.scrolToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319) + - Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224) - [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes diff --git a/src/packages/excalidraw/package.json b/src/packages/excalidraw/package.json index a23a3b1e8..be4e61d27 100644 --- a/src/packages/excalidraw/package.json +++ b/src/packages/excalidraw/package.json @@ -64,7 +64,7 @@ "terser-webpack-plugin": "5.3.3", "ts-loader": "9.3.1", "typescript": "4.7.4", - "webpack": "5.73.0", + "webpack": "5.76.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.10.0", "webpack-dev-server": "4.9.3", diff --git a/src/packages/excalidraw/yarn.lock b/src/packages/excalidraw/yarn.lock index 320b26a54..339cda939 100644 --- a/src/packages/excalidraw/yarn.lock +++ b/src/packages/excalidraw/yarn.lock @@ -1393,10 +1393,10 @@ acorn-walk@^8.0.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3" integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A== -acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0: - version "8.7.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" - integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== +acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1: + version "8.8.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" + integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== ajv-formats@^2.1.1: version "2.1.1" @@ -2068,10 +2068,10 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3: - version "5.10.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6" - integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ== +enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0: + version "5.12.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" + integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -3751,10 +3751,10 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= -watchpack@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" - integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== +watchpack@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -3858,21 +3858,21 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@5.73.0: - version "5.73.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38" - integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA== +webpack@5.76.0: + version "5.76.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c" + integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^0.0.51" "@webassemblyjs/ast" "1.11.1" "@webassemblyjs/wasm-edit" "1.11.1" "@webassemblyjs/wasm-parser" "1.11.1" - acorn "^8.4.1" + acorn "^8.7.1" acorn-import-assertions "^1.7.6" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.9.3" + enhanced-resolve "^5.10.0" es-module-lexer "^0.9.0" eslint-scope "5.1.1" events "^3.2.0" @@ -3885,7 +3885,7 @@ webpack@5.73.0: schema-utils "^3.1.0" tapable "^2.1.1" terser-webpack-plugin "^5.1.3" - watchpack "^2.3.1" + watchpack "^2.4.0" webpack-sources "^3.2.3" websocket-driver@>=0.5.1, websocket-driver@^0.7.4: diff --git a/src/packages/utils/package.json b/src/packages/utils/package.json index b8aea2b6d..7375e8b58 100644 --- a/src/packages/utils/package.json +++ b/src/packages/utils/package.json @@ -48,7 +48,7 @@ "file-loader": "6.2.0", "sass-loader": "13.0.2", "ts-loader": "9.3.1", - "webpack": "5.73.0", + "webpack": "5.76.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.10.0" }, diff --git a/src/packages/utils/yarn.lock b/src/packages/utils/yarn.lock index 483399b2f..c5d00fd23 100644 --- a/src/packages/utils/yarn.lock +++ b/src/packages/utils/yarn.lock @@ -1187,10 +1187,10 @@ acorn-walk@^8.0.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3" integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A== -acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0: - version "8.7.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" - integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== +acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1: + version "8.8.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" + integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== ajv-keywords@^3.5.2: version "3.5.2" @@ -1383,18 +1383,7 @@ braces@^3.0.1: dependencies: fill-range "^7.0.1" -browserslist@^4.14.5: - version "4.19.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.3.tgz#29b7caad327ecf2859485f696f9604214bedd383" - integrity sha512-XK3X4xtKJ+Txj8G5c30B4gsm71s69lqXlkYui4s6EkKxuv49qjYlY6oVd+IFJ73d4YymtM3+djvvt/R/iJwwDg== - dependencies: - caniuse-lite "^1.0.30001312" - electron-to-chromium "^1.4.71" - escalade "^3.1.1" - node-releases "^2.0.2" - picocolors "^1.0.0" - -browserslist@^4.20.2, browserslist@^4.21.2: +browserslist@^4.14.5, browserslist@^4.20.2, browserslist@^4.21.2: version "4.21.2" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.2.tgz#59a400757465535954946a400b841ed37e2b4ecf" integrity sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA== @@ -1417,11 +1406,6 @@ call-bind@^1.0.0: function-bind "^1.1.1" get-intrinsic "^1.0.2" -caniuse-lite@^1.0.30001312: - version "1.0.30001312" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001312.tgz#e11eba4b87e24d22697dae05455d5aea28550d5f" - integrity sha512-Wiz1Psk2MEK0pX3rUzWaunLTZzqS2JYZFzNKqAiJGiuxIjRPLgV6+VDPOg6lQOUxmDwhTlh198JsTTi8Hzw6aQ== - caniuse-lite@^1.0.30001366: version "1.0.30001367" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001367.tgz#2b97fe472e8fa29c78c5970615d7cd2ee414108a" @@ -1601,20 +1585,15 @@ electron-to-chromium@^1.4.188: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.195.tgz#139b2d95a42a3f17df217589723a1deac71d1473" integrity sha512-vefjEh0sk871xNmR5whJf9TEngX+KTKS3hOHpjoMpauKkwlGwtMz1H8IaIjAT/GNnX0TbGwAdmVoXCAzXf+PPg== -electron-to-chromium@^1.4.71: - version "1.4.75" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.75.tgz#d1ad9bb46f2f1bf432118c2be21d27ffeae82fdd" - integrity sha512-LxgUNeu3BVU7sXaKjUDD9xivocQLxFtq6wgERrutdY/yIOps3ODOZExK1jg8DTEg4U8TUCb5MLGeWFOYuxjF3Q== - emojis-list@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3: - version "5.9.3" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz#44a342c012cbc473254af5cc6ae20ebd0aae5d88" - integrity sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow== +enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0: + version "5.12.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" + integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -2011,11 +1990,6 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== -node-releases@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01" - integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg== - node-releases@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" @@ -2494,10 +2468,10 @@ util-deprecate@^1.0.2: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= -watchpack@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" - integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== +watchpack@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -2548,21 +2522,21 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@5.73.0: - version "5.73.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38" - integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA== +webpack@5.76.0: + version "5.76.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c" + integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^0.0.51" "@webassemblyjs/ast" "1.11.1" "@webassemblyjs/wasm-edit" "1.11.1" "@webassemblyjs/wasm-parser" "1.11.1" - acorn "^8.4.1" + acorn "^8.7.1" acorn-import-assertions "^1.7.6" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.9.3" + enhanced-resolve "^5.10.0" es-module-lexer "^0.9.0" eslint-scope "5.1.1" events "^3.2.0" @@ -2575,7 +2549,7 @@ webpack@5.73.0: schema-utils "^3.1.0" tapable "^2.1.1" terser-webpack-plugin "^5.1.3" - watchpack "^2.3.1" + watchpack "^2.4.0" webpack-sources "^3.2.3" which@^2.0.1: diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index e49a1f465..66d2096cf 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -34,16 +34,17 @@ import { AppState, BinaryFiles, Zoom } from "../types"; import { getDefaultAppState } from "../appState"; import { BOUND_TEXT_PADDING, + FONT_FAMILY, MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS, } from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; import { - getApproxLineHeight, getBoundTextElement, getContainerCoords, getContainerElement, + getLineHeightInPx, getMaxContainerHeight, getMaxContainerWidth, } from "../element/textElement"; @@ -279,22 +280,31 @@ const drawElementOnCanvas = ( // Canvas does not support multiline text by default const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeight = element.containerId - ? getApproxLineHeight(getFontString(element)) - : element.height / lines.length; + const horizontalOffset = element.textAlign === "center" ? element.width / 2 : element.textAlign === "right" ? element.width : 0; - context.textBaseline = "bottom"; + + // FIXME temporary hack + context.textBaseline = + element.fontFamily === FONT_FAMILY.Virgil || + element.fontFamily === FONT_FAMILY.Cascadia + ? "ideographic" + : "bottom"; + + const lineHeightPx = getLineHeightInPx( + element.fontSize, + element.lineHeight, + ); for (let index = 0; index < lines.length; index++) { context.fillText( lines[index], horizontalOffset, - (index + 1) * lineHeight, + (index + 1) * lineHeightPx, ); } context.restore(); @@ -1313,7 +1323,10 @@ export const renderElementToSvg = ( }) rotate(${degree} ${cx} ${cy})`, ); const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeight = element.height / lines.length; + const lineHeightPx = getLineHeightInPx( + element.fontSize, + element.lineHeight, + ); const horizontalOffset = element.textAlign === "center" ? element.width / 2 @@ -1331,7 +1344,7 @@ export const renderElementToSvg = ( const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); text.textContent = lines[i]; text.setAttribute("x", `${horizontalOffset}`); - text.setAttribute("y", `${i * lineHeight}`); + text.setAttribute("y", `${i * lineHeightPx}`); text.setAttribute("font-family", getFontFamilyString(element)); text.setAttribute("font-size", `${element.fontSize}px`); text.setAttribute("fill", element.strokeColor); diff --git a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap index 5ea0ab4b1..a2f142b66 100644 --- a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te class="excalidraw-wysiwyg" data-type="wysiwyg" dir="auto" - style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 24px; left: 35px; top: 8px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;" + style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;" tabindex="0" wrap="off" /> diff --git a/src/tests/binding.test.tsx b/src/tests/binding.test.tsx index 52bfad100..c615eb925 100644 --- a/src/tests/binding.test.tsx +++ b/src/tests/binding.test.tsx @@ -4,6 +4,7 @@ import { UI, Pointer, Keyboard } from "./helpers/ui"; import { getTransformHandles } from "../element/transformHandles"; import { API } from "./helpers/api"; import { KEYS } from "../keys"; +import { actionCreateContainerFromText } from "../actions/actionBoundText"; const { h } = window; @@ -209,4 +210,103 @@ describe("element binding", () => { ).toBe(null); expect(arrow.endBinding?.elementId).toBe(text.id); }); + + it("should update binding when text containerized", async () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + width: 100, + height: 100, + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + points: [ + [0, 0], + [0, -87.45777932247563], + ], + startBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + }, + endBinding: { + elementId: "text1", + focus: 0.2, + gap: 7, + }, + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + points: [ + [0, 0], + [0, -87.45777932247563], + ], + startBinding: { + elementId: "text1", + focus: 0.2, + gap: 7, + }, + endBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + }, + }); + + const text1 = API.createElement({ + type: "text", + id: "text1", + text: "ola", + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + h.elements = [rectangle1, arrow1, arrow2, text1]; + + API.setSelectedElements([text1]); + + expect(h.state.selectedElementIds[text1.id]).toBe(true); + + h.app.actionManager.executeAction(actionCreateContainerFromText); + + // new text container will be placed before the text element + const container = h.elements.at(-2)!; + + expect(container.type).toBe("rectangle"); + expect(container.id).not.toBe(rectangle1.id); + + expect(container).toEqual( + expect.objectContaining({ + boundElements: expect.arrayContaining([ + { + type: "text", + id: text1.id, + }, + { + type: "arrow", + id: arrow1.id, + }, + { + type: "arrow", + id: arrow2.id, + }, + ]), + }), + ); + + expect(arrow1.startBinding?.elementId).toBe(rectangle1.id); + expect(arrow1.endBinding?.elementId).toBe(container.id); + expect(arrow2.startBinding?.elementId).toBe(container.id); + expect(arrow2.endBinding?.elementId).toBe(rectangle1.id); + }); }); diff --git a/src/tests/clipboard.test.tsx b/src/tests/clipboard.test.tsx index 26fcf4f0b..1fdc0f452 100644 --- a/src/tests/clipboard.test.tsx +++ b/src/tests/clipboard.test.tsx @@ -3,8 +3,10 @@ import { render, waitFor, GlobalTestState } from "./test-utils"; import { Pointer, Keyboard } from "./helpers/ui"; import ExcalidrawApp from "../excalidraw-app"; import { KEYS } from "../keys"; -import { getApproxLineHeight } from "../element/textElement"; -import { getFontString } from "../utils"; +import { + getDefaultLineHeight, + getLineHeightInPx, +} from "../element/textElement"; import { getElementBounds } from "../element"; import { NormalizedZoomValue } from "../types"; @@ -118,12 +120,10 @@ describe("paste text as single lines", () => { it("should space items correctly", async () => { const text = "hkhkjhki\njgkjhffjh\njgkjhffjh"; - const lineHeight = - getApproxLineHeight( - getFontString({ - fontSize: h.app.state.currentItemFontSize, - fontFamily: h.app.state.currentItemFontFamily, - }), + const lineHeightPx = + getLineHeightInPx( + h.app.state.currentItemFontSize, + getDefaultLineHeight(h.state.currentItemFontFamily), ) + 10 / h.app.state.zoom.value; mouse.moveTo(100, 100); @@ -135,19 +135,17 @@ describe("paste text as single lines", () => { for (let i = 1; i < h.elements.length; i++) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [fx, elY] = getElementBounds(h.elements[i]); - expect(elY).toEqual(firstElY + lineHeight * i); + expect(elY).toEqual(firstElY + lineHeightPx * i); } }); }); it("should leave a space for blank new lines", async () => { const text = "hkhkjhki\n\njgkjhffjh"; - const lineHeight = - getApproxLineHeight( - getFontString({ - fontSize: h.app.state.currentItemFontSize, - fontFamily: h.app.state.currentItemFontFamily, - }), + const lineHeightPx = + getLineHeightInPx( + h.app.state.currentItemFontSize, + getDefaultLineHeight(h.state.currentItemFontFamily), ) + 10 / h.app.state.zoom.value; mouse.moveTo(100, 100); @@ -158,7 +156,7 @@ describe("paste text as single lines", () => { const [fx, firstElY] = getElementBounds(h.elements[0]); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [lx, lastElY] = getElementBounds(h.elements[1]); - expect(lastElY).toEqual(firstElY + lineHeight * 2); + expect(lastElY).toEqual(firstElY + lineHeightPx * 2); }); }); }); @@ -224,7 +222,7 @@ describe("Paste bound text container", () => { await sleep(1); expect(h.elements.length).toEqual(2); const container = h.elements[0]; - expect(container.height).toBe(354); + expect(container.height).toBe(368); expect(container.width).toBe(166); }); }); @@ -247,7 +245,7 @@ describe("Paste bound text container", () => { await sleep(1); expect(h.elements.length).toEqual(2); const container = h.elements[0]; - expect(container.height).toBe(740); + expect(container.height).toBe(770); expect(container.width).toBe(166); }); }); diff --git a/src/tests/data/__snapshots__/restore.test.ts.snap b/src/tests/data/__snapshots__/restore.test.ts.snap index b88803cd4..e9a0da005 100644 --- a/src/tests/data/__snapshots__/restore.test.ts.snap +++ b/src/tests/data/__snapshots__/restore.test.ts.snap @@ -291,6 +291,7 @@ Object { "height": 100, "id": "id-text01", "isDeleted": false, + "lineHeight": 1.25, "link": null, "locked": false, "opacity": 100, @@ -312,7 +313,7 @@ Object { "verticalAlign": "middle", "width": 100, "x": -20, - "y": -8.4, + "y": -8.75, } `; @@ -329,6 +330,7 @@ Object { "height": 100, "id": "id-text01", "isDeleted": false, + "lineHeight": 1.25, "link": null, "locked": false, "opacity": 100, diff --git a/src/tests/fitToContent.test.tsx b/src/tests/fitToContent.test.tsx new file mode 100644 index 000000000..6fce7cdcd --- /dev/null +++ b/src/tests/fitToContent.test.tsx @@ -0,0 +1,189 @@ +import { render } from "./test-utils"; +import { API } from "./helpers/api"; + +import ExcalidrawApp from "../excalidraw-app"; + +const { h } = window; + +describe("fitToContent", () => { + it("should zoom to fit the selected element", async () => { + await render(); + + h.state.width = 10; + h.state.height = 10; + + const rectElement = API.createElement({ + width: 50, + height: 100, + x: 50, + y: 100, + }); + + expect(h.state.zoom.value).toBe(1); + + h.app.scrollToContent(rectElement, { fitToContent: true }); + + // element is 10x taller than the viewport size, + // zoom should be at least 1/10 + expect(h.state.zoom.value).toBeLessThanOrEqual(0.1); + }); + + it("should zoom to fit multiple elements", async () => { + await render(); + + const topLeft = API.createElement({ + width: 20, + height: 20, + x: 0, + y: 0, + }); + + const bottomRight = API.createElement({ + width: 20, + height: 20, + x: 80, + y: 80, + }); + + h.state.width = 10; + h.state.height = 10; + + expect(h.state.zoom.value).toBe(1); + + h.app.scrollToContent([topLeft, bottomRight], { + fitToContent: true, + }); + + // elements take 100x100, which is 10x bigger than the viewport size, + // zoom should be at least 1/10 + expect(h.state.zoom.value).toBeLessThanOrEqual(0.1); + }); + + it("should scroll the viewport to the selected element", async () => { + await render(); + + h.state.width = 10; + h.state.height = 10; + + const rectElement = API.createElement({ + width: 100, + height: 100, + x: 100, + y: 100, + }); + + expect(h.state.zoom.value).toBe(1); + expect(h.state.scrollX).toBe(0); + expect(h.state.scrollY).toBe(0); + + h.app.scrollToContent(rectElement); + + // zoom level should stay the same + expect(h.state.zoom.value).toBe(1); + + // state should reflect some scrolling + expect(h.state.scrollX).not.toBe(0); + expect(h.state.scrollY).not.toBe(0); + }); +}); + +const waitForNextAnimationFrame = () => { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(resolve); + }); + }); +}; + +describe("fitToContent animated", () => { + beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should ease scroll the viewport to the selected element", async () => { + await render(); + + h.state.width = 10; + h.state.height = 10; + + const rectElement = API.createElement({ + width: 100, + height: 100, + x: -100, + y: -100, + }); + + h.app.scrollToContent(rectElement, { animate: true }); + + expect(window.requestAnimationFrame).toHaveBeenCalled(); + + // Since this is an animation, we expect values to change through time. + // We'll verify that the scroll values change at 50ms and 100ms + expect(h.state.scrollX).toBe(0); + expect(h.state.scrollY).toBe(0); + + await waitForNextAnimationFrame(); + + const prevScrollX = h.state.scrollX; + const prevScrollY = h.state.scrollY; + + expect(h.state.scrollX).not.toBe(0); + expect(h.state.scrollY).not.toBe(0); + + await waitForNextAnimationFrame(); + + expect(h.state.scrollX).not.toBe(prevScrollX); + expect(h.state.scrollY).not.toBe(prevScrollY); + }); + + it("should animate the scroll but not the zoom", async () => { + await render(); + + h.state.width = 50; + h.state.height = 50; + + const rectElement = API.createElement({ + width: 100, + height: 100, + x: 100, + y: 100, + }); + + expect(h.state.scrollX).toBe(0); + expect(h.state.scrollY).toBe(0); + + h.app.scrollToContent(rectElement, { animate: true, fitToContent: true }); + + expect(window.requestAnimationFrame).toHaveBeenCalled(); + + // Since this is an animation, we expect values to change through time. + // We'll verify that the zoom/scroll values change in each animation frame + + // zoom is not animated, it should be set to its final value, which in our + // case zooms out to 50% so that th element is fully visible (it's 2x large + // as the canvas) + expect(h.state.zoom.value).toBeLessThanOrEqual(0.5); + + // FIXME I think this should be [-100, -100] so we may have a bug in our zoom + // hadnling, alas + expect(h.state.scrollX).toBe(25); + expect(h.state.scrollY).toBe(25); + + await waitForNextAnimationFrame(); + + const prevScrollX = h.state.scrollX; + const prevScrollY = h.state.scrollY; + + expect(h.state.scrollX).not.toBe(0); + expect(h.state.scrollY).not.toBe(0); + + await waitForNextAnimationFrame(); + + expect(h.state.scrollX).not.toBe(prevScrollX); + expect(h.state.scrollY).not.toBe(prevScrollY); + }); +}); diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 7f3e958ca..bc8bfc8a9 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -19,6 +19,7 @@ import { newFreeDrawElement, newImageElement } from "../../element/newElement"; import { Point } from "../../types"; import { getSelectedElements } from "../../scene/selection"; import { isLinearElementType } from "../../element/typeChecks"; +import { Mutable } from "../../utility-types"; const readFile = util.promisify(fs.readFile); @@ -110,6 +111,9 @@ export class API { fileId?: T extends "image" ? string : never; scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; status?: T extends "image" ? ExcalidrawImageElement["status"] : never; + startBinding?: T extends "arrow" + ? ExcalidrawLinearElement["startBinding"] + : never; endBinding?: T extends "arrow" ? ExcalidrawLinearElement["endBinding"] : never; @@ -177,11 +181,13 @@ export class API { }); break; case "text": + const fontSize = rest.fontSize ?? appState.currentItemFontSize; + const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily; element = newTextElement({ ...base, text: rest.text || "test", - fontSize: rest.fontSize ?? appState.currentItemFontSize, - fontFamily: rest.fontFamily ?? appState.currentItemFontFamily, + fontSize, + fontFamily, textAlign: rest.textAlign ?? appState.currentItemTextAlign, verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN, containerId: rest.containerId ?? undefined, @@ -220,6 +226,10 @@ export class API { }); break; } + if (element.type === "arrow") { + element.startBinding = rest.startBinding ?? null; + element.endBinding = rest.endBinding ?? null; + } if (id) { element.id = id; } diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index a606fb384..ac4d801bc 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/src/tests/linearElementEditor.test.tsx @@ -1031,7 +1031,7 @@ describe("Test Linear Elements", () => { expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` Object { - "height": 128, + "height": 130, "width": 367, } `); @@ -1040,7 +1040,7 @@ describe("Test Linear Elements", () => { .toMatchInlineSnapshot(` Object { "x": 272, - "y": 46, + "y": 45, } `); expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) @@ -1052,11 +1052,11 @@ describe("Test Linear Elements", () => { .toMatchInlineSnapshot(` Array [ 20, - 36, + 35, 502, - 94, + 95, 205.9061448421403, - 53, + 52.5, ] `); }); @@ -1090,7 +1090,7 @@ describe("Test Linear Elements", () => { expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` Object { - "height": 128, + "height": 130, "width": 340, } `); @@ -1099,7 +1099,7 @@ describe("Test Linear Elements", () => { .toMatchInlineSnapshot(` Object { "x": 75, - "y": -4, + "y": -5, } `); expect(textElement.text).toMatchInlineSnapshot(` @@ -1179,5 +1179,17 @@ describe("Test Linear Elements", () => { easy" `); }); + + it("should not render horizontal align tool when element selected", () => { + createTwoPointerLinearElement("arrow"); + const arrow = h.elements[0] as ExcalidrawLinearElement; + + createBoundTextElement(DEFAULT_TEXT, arrow); + API.setSelectedElements([arrow]); + + expect(queryByTestId(container, "align-left")).toBeNull(); + expect(queryByTestId(container, "align-horizontal-center")).toBeNull(); + expect(queryByTestId(container, "align-right")).toBeNull(); + }); }); }); diff --git a/src/types.ts b/src/types.ts index 073a251d8..58d310a7d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,8 @@ 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"; +import { Merge, ForwardRef } from "./utility-types"; +import React from "react"; export type Point = Readonly; @@ -100,7 +102,7 @@ export type AppState = { } | null; showWelcomeScreen: boolean; isLoading: boolean; - errorMessage: string | null; + errorMessage: React.ReactNode; draggingElement: NonDeletedExcalidrawElement | null; resizingElement: NonDeletedExcalidrawElement | null; multiElement: NonDeleted | null; diff --git a/src/utility-types.ts b/src/utility-types.ts new file mode 100644 index 000000000..b84eb1994 --- /dev/null +++ b/src/utility-types.ts @@ -0,0 +1,49 @@ +export type Mutable = { + -readonly [P in keyof T]: T[P]; +}; + +export type ValueOf = T[keyof T]; + +export type Merge = Omit & N; + +/** utility type to assert that the second type is a subtype of the first type. + * Returns the subtype. */ +export type SubtypeOf = Subtype; + +export type ResolutionType any> = T extends ( + ...args: any +) => Promise + ? R + : any; + +// https://github.com/krzkaczor/ts-essentials +export type MarkOptional = Omit & + Partial>; + +export type MarkRequired = Exclude & + Required>; + +export type MarkNonNullable = { + [P in K]-?: P extends K ? NonNullable : T[P]; +} & { [P in keyof T]: T[P] }; + +export type NonOptional = Exclude; + +// ----------------------------------------------------------------------------- +// type getter for interface's callable type +// src: https://stackoverflow.com/a/58658851/927631 +// ----------------------------------------------------------------------------- +export type SignatureType = T extends (...args: infer R) => any ? R : never; +export type CallableType any> = ( + ...args: SignatureType +) => ReturnType; +// --------------------------------------------------------------------------— + +// Type for React.forwardRef --- supply only the first generic argument T +export type ForwardRef = Parameters< + CallableType> +>[1]; + +export type ExtractSetType> = T extends Set + ? U + : never; diff --git a/src/utils.ts b/src/utils.ts index b2d85d4d3..4a01e587d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,6 +16,7 @@ import { AppState, DataURL, LastActiveTool, Zoom } from "./types"; import { unstable_batchedUpdates } from "react-dom"; import { SHAPES } from "./shapes"; import { isEraserActive, isHandToolActive } from "./appState"; +import { ResolutionType } from "./utility-types"; let mockDateTime: string | null = null; @@ -180,6 +181,79 @@ export const throttleRAF = ( return ret; }; +/** + * Exponential ease-out method + * + * @param {number} k - The value to be tweened. + * @returns {number} The tweened value. + */ +function easeOut(k: number): number { + return 1 - Math.pow(1 - k, 4); +} + +/** + * Compute new values based on the same ease function and trigger the + * callback through a requestAnimationFrame call + * + * use `opts` to define a duration and/or an easeFn + * + * for example: + * ```ts + * easeToValuesRAF([10, 20, 10], [0, 0, 0], (a, b, c) => setState(a,b, c)) + * ``` + * + * @param fromValues The initial values, must be numeric + * @param toValues The destination values, must also be numeric + * @param callback The callback receiving the values + * @param opts default to 250ms duration and the easeOut function + */ +export const easeToValuesRAF = ( + fromValues: number[], + toValues: number[], + callback: (...values: number[]) => void, + opts?: { duration?: number; easeFn?: (value: number) => number }, +) => { + let canceled = false; + let frameId = 0; + let startTime: number; + + const duration = opts?.duration || 250; // default animation to 0.25 seconds + const easeFn = opts?.easeFn || easeOut; // default the easeFn to easeOut + + function step(timestamp: number) { + if (canceled) { + return; + } + if (startTime === undefined) { + startTime = timestamp; + } + + const elapsed = timestamp - startTime; + + if (elapsed < duration) { + // console.log(elapsed, duration, elapsed / duration); + const factor = easeFn(elapsed / duration); + const newValues = fromValues.map( + (fromValue, index) => + (toValues[index] - fromValue) * factor + fromValue, + ); + + callback(...newValues); + frameId = window.requestAnimationFrame(step); + } else { + // ensure final values are reached at the end of the transition + callback(...toValues); + } + } + + frameId = window.requestAnimationFrame(step); + + return () => { + canceled = true; + window.cancelAnimationFrame(frameId); + }; +}; + // https://github.com/lodash/lodash/blob/es/chunk.js export const chunk = ( array: readonly T[],