diff --git a/frontend/package.json b/frontend/package.json index ba3c0ce70..8d5760f3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -100,7 +100,7 @@ "@penpot/hljs": "portal:./vendor/hljs", "@penpot/mousetrap": "portal:./vendor/mousetrap", "@penpot/svgo": "penpot/svgo#c6fba7a4dcfbc27b643e7fc0c94fc98cf680b77b", - "@penpot/text-editor": "penpot/penpot-text-editor#a100aad8d0efcbb070bed9144dbd2782547e78ba", + "@penpot/text-editor": "portal:./text-editor", "@tokens-studio/sd-transforms": "^0.16.1", "compression": "^1.7.4", "date-fns": "^4.1.0", diff --git a/frontend/src/app/main/data/workspace/text/shortcuts.cljs b/frontend/src/app/main/data/workspace/text/shortcuts.cljs index dc5272c29..4521bc4f4 100644 --- a/frontend/src/app/main/data/workspace/text/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/text/shortcuts.cljs @@ -12,6 +12,7 @@ [app.main.data.shortcuts :as ds] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.undo :as dwu] + [app.main.features :as features] [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] @@ -113,18 +114,24 @@ (defn calculate-text-values [shape] - (let [state-map (deref refs/workspace-editor-state) - editor-state (get state-map (:id shape))] + (let [state-map (if (features/active-feature? @st/state "text-editor/v2") + (deref refs/workspace-v2-editor-state) + (deref refs/workspace-editor-state)) + editor-state (get state-map (:id shape)) + editor-instance (when (features/active-feature? @st/state "text-editor/v2") + (deref refs/workspace-editor))] (d/merge (dwt/current-root-values {:shape shape :attrs txt/root-attrs}) (dwt/current-paragraph-values {:editor-state editor-state + :editor-instance editor-instance :shape shape :attrs txt/paragraph-attrs}) (dwt/current-text-values {:editor-state editor-state + :editor-instance editor-instance :shape shape :attrs txt/text-node-attrs})))) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 09c721bf2..a89eab0c6 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -171,6 +171,7 @@ (and (some? drawing-obj) (= :path (:type drawing-obj)))) node-editing? (and edition (= :path (get-in base-objects [edition :type]))) text-editing? (and edition (= :text (get-in base-objects [edition :type]))) + grid-editing? (and edition (ctl/grid-layout? base-objects edition)) mode-inspect? (= options-mode :inspect) diff --git a/frontend/src/app/util/text/ui.cljs b/frontend/src/app/util/text/ui.cljs index 71bb704ee..106e1b05a 100644 --- a/frontend/src/app/util/text/ui.cljs +++ b/frontend/src/app/util/text/ui.cljs @@ -16,7 +16,7 @@ (defn v2-closest-text-editor-content [target] - (.closest ^js target ".text-editor-content")) + (.closest ^js target "[data-itype=\"editor\"]")) (defn closest-text-editor-content [target] @@ -34,7 +34,7 @@ (defn v2-get-text-editor-content [] - (dom/get-element-by-class "text-editor-content")) + (dom/query "[data-itype=\"editor\"]")) (defn get-text-editor-content [] diff --git a/frontend/text-editor/.editorconfig b/frontend/text-editor/.editorconfig new file mode 100644 index 000000000..53926dcaf --- /dev/null +++ b/frontend/text-editor/.editorconfig @@ -0,0 +1,71 @@ +root = true + +[*] +charset = utf-8 + +indent_size = 2 +indent_style = space + +end_of_line = lf + +insert_final_newline = true + +trim_trailing_whitespace = true +cpp_indent_braces=false +cpp_indent_multi_line_relative_to=innermost_parenthesis +cpp_indent_within_parentheses=indent +cpp_indent_preserve_within_parentheses=false +cpp_indent_case_labels=false +cpp_indent_case_contents=true +cpp_indent_case_contents_when_block=false +cpp_indent_lambda_braces_when_parameter=true +cpp_indent_goto_labels=one_left +cpp_indent_preprocessor=leftmost_column +cpp_indent_access_specifiers=false +cpp_indent_namespace_contents=true +cpp_indent_preserve_comments=false +cpp_new_line_before_open_brace_namespace=ignore +cpp_new_line_before_open_brace_type=ignore +cpp_new_line_before_open_brace_function=ignore +cpp_new_line_before_open_brace_block=ignore +cpp_new_line_before_open_brace_lambda=ignore +cpp_new_line_scope_braces_on_separate_lines=false +cpp_new_line_close_brace_same_line_empty_type=false +cpp_new_line_close_brace_same_line_empty_function=false +cpp_new_line_before_catch=true +cpp_new_line_before_else=true +cpp_new_line_before_while_in_do_while=false +cpp_space_before_function_open_parenthesis=remove +cpp_space_within_parameter_list_parentheses=false +cpp_space_between_empty_parameter_list_parentheses=false +cpp_space_after_keywords_in_control_flow_statements=true +cpp_space_within_control_flow_statement_parentheses=false +cpp_space_before_lambda_open_parenthesis=false +cpp_space_within_cast_parentheses=false +cpp_space_after_cast_close_parenthesis=false +cpp_space_within_expression_parentheses=false +cpp_space_before_block_open_brace=true +cpp_space_between_empty_braces=false +cpp_space_before_initializer_list_open_brace=false +cpp_space_within_initializer_list_braces=true +cpp_space_preserve_in_initializer_list=true +cpp_space_before_open_square_bracket=false +cpp_space_within_square_brackets=false +cpp_space_before_empty_square_brackets=false +cpp_space_between_empty_square_brackets=false +cpp_space_group_square_brackets=true +cpp_space_within_lambda_brackets=false +cpp_space_between_empty_lambda_brackets=false +cpp_space_before_comma=false +cpp_space_after_comma=true +cpp_space_remove_around_member_operators=true +cpp_space_before_inheritance_colon=true +cpp_space_before_constructor_colon=true +cpp_space_remove_before_semicolon=true +cpp_space_after_semicolon=false +cpp_space_remove_around_unary_operator=true +cpp_space_around_binary_operator=insert +cpp_space_around_assignment_operator=insert +cpp_space_pointer_reference_alignment=left +cpp_space_around_ternary_operator=insert +cpp_wrap_preserve_blocks=one_liners diff --git a/frontend/text-editor/.gitignore b/frontend/text-editor/.gitignore new file mode 100644 index 000000000..db3424bb9 --- /dev/null +++ b/frontend/text-editor/.gitignore @@ -0,0 +1,342 @@ +# Created by https://www.toptal.com/developers/gitignore/api/linux,osx,windows,vim,emacs,sublimetext,visualstudiocode,node +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,osx,windows,vim,emacs,sublimetext,visualstudiocode,node + +### Emacs ### +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# network security +/network-security.data + + +### Linux ### + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### OSX ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### SublimeText ### +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +# *.sublime-project + +# SFTP configuration file +sftp-config.json +sftp-config-alt*.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/linux,osx,windows,vim,emacs,sublimetext,visualstudiocode,node +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + +vite.config.js.timestamp* diff --git a/frontend/text-editor/.prettierrc b/frontend/text-editor/.prettierrc new file mode 100644 index 000000000..ed2cb19ba --- /dev/null +++ b/frontend/text-editor/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false +} diff --git a/frontend/text-editor/README.md b/frontend/text-editor/README.md new file mode 100644 index 000000000..763f4424a --- /dev/null +++ b/frontend/text-editor/README.md @@ -0,0 +1,106 @@ +# Penpot Text Editor + +## How to run it + +### Development + +To start the development environment run: + +```sh +yarn run dev +``` + +### Testing + +For running unit tests and running coverage: + +```sh +yarn run test +yarn run coverage +``` + +> If you want, you can run the [vitest](https://vitest.dev/) UI by running: + +```sh +yarn run test:ui +``` + +## How to build it + +The editor can be built and updated inside Penpot using the following command: + +```sh +PENPOT_SOURCE_PATH=/path/to/penpot/repository yarn build:update +``` + +This command is going to search for the file located in `frontend/src/app/main/ui/workspace/shapes/text/new_editor/TextEditor.js` and update it. + +## How it works? + +The text editor divides the content in three elements: `root`, `paragraph` and `inline`. An `inline` in terms of content is a styled element that it is displayed in a line inside a block and an `inline` only can have one child (a Text node). A `paragraph` is a **block** element that can contain multiple `inline`s (**inline** elements). + +```html +
+
+ Hello, + World! +
+
+``` + +This way we only need to deal with a structure like this, where circular nodes are `HTMLElement`s and square nodes are `Text`. Also with an structure like this we have a predictable and ordered tree where we can find our position easily to do any operation (remove, insert, replace, etc). + +```mermaid +flowchart TB + root((root)) --> paragraph((paragraph)) + paragraph --> inline_1((inline)) + paragraph --> inline_2((inline)) + inline_1 --> text_1[Hello, ] + inline_2 --> text_2[World!] +``` + +This is compatible with the way Penpot stores text content. + +```mermaid +flowchart TB + root((root)) --> paragraph-set((paragraph-set)) + paragraph-set --> paragraph((paragraph)) + paragraph --> text((text)) +``` + +## How the code is organized? + +- `editor`: contains everything related to the TextEditor. Where `TextEditor.js` is the main file where all the basic code of the editor is handled. This has been designed so that in the future, when the Web Components API is more stable and has features such as handling selection events within shadow roots we will be able to update this class with little effort. +- `editor/clipboard`: Event handlers for clipboard events. +- `editor/commands`: Event handlers for input events (commands) that modifies the content of the TextEditor. +- `editor/content`: Code related to handling elements like text nodes, paragraphs, line breaks, etc. This are a series of utility functions that can perform some verifications and mutations on DOM nodes. +- `editor/controllers`: There are two controllers; `ChangeController` that handles when a change in the content should be notified and `SelectionController` that handles operations on selections and text, this is where all the mutations on DOM nodes are performed. + +## Implementation + +Everything is implemented in JavaScript using `beforeinput` and `InputEvent` for the user events. `blur` and `focus` are used to handle imposter selections. + +### Why imposter selections? + +Normally when you click on another UI element, the current selection is replaced by the selection of the new UI element. + +## References + +- [InputEvent](https://w3c.github.io/input-events/#interface-InputEvent): the main event used for handling user input. +- [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection): for handling selections. +- [Range](https://developer.mozilla.org/en-US/docs/Web/API/Range): for handling range selections. 1 +- [Node](https://developer.mozilla.org/en-US/docs/Web/API/Node): for operator functions like `compareDocumentPosition` or `nodeType`. +- [Text](https://developer.mozilla.org/en-US/docs/Web/API/Range): for operator functions like `splitText`. +- [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element): for operator functions like `after`, `before`, `append`, `remove`, `prepend`, etc. + +1: Firefox is the only browser right now (2024-07-08) that has support for multiple selection ranges so we have to deal with this special case removing old ranges. + +## For future reference + +In a near future maybe we could take a lot at the [EditContext API](https://developer.mozilla.org/en-US/docs/Web/API/EditContext_API). + +## FAQ + +### Sometimes I receive 'TypeError: Cannot read from private field' + +Sometimes, when you update the TextEditor source code, this exception could raise because shadow-cljs updated the code but keeps a reference to the old instance of the text editor, so the new code tries to read a private field from an old instance. diff --git a/frontend/text-editor/editor/Event.js b/frontend/text-editor/editor/Event.js new file mode 100644 index 000000000..9751bad73 --- /dev/null +++ b/frontend/text-editor/editor/Event.js @@ -0,0 +1,32 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Adds a series of listeners. + * + * @param {EventTarget} target + * @param {Object.} object + * @param {EventListenerOptions} [options] + */ +export function addEventListeners(target, object, options) { + Object.entries(object).forEach(([type, listener]) => + target.addEventListener(type, listener, options) + ); +} + +/** + * Removes a series of listeners. + * + * @param {EventTarget} target + * @param {Object.} object + */ +export function removeEventListeners(target, object) { + Object.entries(object).forEach(([type, listener]) => + target.removeEventListener(type, listener) + ); +} diff --git a/frontend/text-editor/editor/Event.test.js b/frontend/text-editor/editor/Event.test.js new file mode 100644 index 000000000..b1b56a1cd --- /dev/null +++ b/frontend/text-editor/editor/Event.test.js @@ -0,0 +1,29 @@ +import { describe, test, expect, vi } from 'vitest'; +import { addEventListeners, removeEventListeners } from './Event'; + +/* @vitest-environment jsdom */ +describe('Event', () => { + test('addEventListeners should add event listeners to an element using an object', () => { + const clickSpy = vi.fn(); + const events = { + click: clickSpy + } + const element = document.createElement('div'); + addEventListeners(element, events); + element.dispatchEvent(new Event('click')); + expect(clickSpy).toBeCalled(); + }); + + test('removeEventListeners should remove event listeners to an element using an object', () => { + const clickSpy = vi.fn(); + const events = { + click: clickSpy, + }; + const element = document.createElement("div"); + addEventListeners(element, events); + element.dispatchEvent(new Event("click")); + removeEventListeners(element, events); + element.dispatchEvent(new Event('click')) + expect(clickSpy).toBeCalledTimes(1); + }) +}); diff --git a/frontend/text-editor/editor/TextEditor.css b/frontend/text-editor/editor/TextEditor.css new file mode 100644 index 000000000..465bc4b63 --- /dev/null +++ b/frontend/text-editor/editor/TextEditor.css @@ -0,0 +1,65 @@ +::selection { + background-color: red; +} + +.selection-imposter-rect { + background-color: red; + position: absolute; +} + +.text-editor-selection-imposter { + pointer-events: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; +} + +.text-editor-container { + height: 100%; + display: flex; + flex-direction: column; +} + +.text-editor-content { + height: 100%; + font-family: sourcesanspro; + + outline: none; + user-select: text; + white-space: pre-wrap; + overflow-wrap: break-word; + + caret-color: black; + + /* color: transparent; */ + color: black; + + div { + line-height: inherit; + user-select: text; + + white-space: pre; + margin: 0px; + /* font-size: 0px; */ + } + + span { + line-break: auto; + line-height: inherit; + } +} + +.align-top[data-itype="root"] { + justify-content: flex-start; +} + +.align-center[data-itype="root"] { + justify-content: center; +} + +.align-bottom[data-itype="root"] { + justify-content: flex-end; +} diff --git a/frontend/text-editor/editor/TextEditor.js b/frontend/text-editor/editor/TextEditor.js new file mode 100644 index 000000000..8ea072a29 --- /dev/null +++ b/frontend/text-editor/editor/TextEditor.js @@ -0,0 +1,545 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import clipboard from "./clipboard/index.js"; +import commands from "./commands/index.js"; +import ChangeController from './controllers/ChangeController.js'; +import SelectionController from './controllers/SelectionController.js'; +import { createSelectionImposterFromClientRects } from './selection/Imposter.js'; +import { addEventListeners, removeEventListeners } from "./Event.js"; +import { createRoot, createEmptyRoot } from './content/dom/Root.js'; +import { createParagraph, fixParagraph, getParagraph } from './content/dom/Paragraph.js'; +import { createEmptyInline, createInline } from './content/dom/Inline.js'; +import { isLineBreak } from './content/dom/LineBreak.js'; +import LayoutType from './layout/LayoutType.js'; + +/** + * Text Editor. + */ +export class TextEditor extends EventTarget { + /** + * Element content editable to be used by the TextEditor + * + * @type {HTMLElement} + */ + #element = null; + + /** + * Map/Dictionary of events. + * + * @type {Object.} + */ + #events = null; + + /** + * Root element that will contain the content. + * + * @type {HTMLElement} + */ + #root = null; + + /** + * Change controller controls when we should notify changes. + * + * @type {ChangeController} + */ + #changeController = null; + + /** + * Selection controller controls the current/saved selection. + * + * @type {SelectionController} + */ + #selectionController = null; + + /** + * Selection imposter keeps selection elements. + * + * @type {HTMLElement} + */ + #selectionImposterElement = null; + + /** + * Style defaults. + * + * @type {Object.} + */ + #styleDefaults = null; + + /** + * FIXME: There is a weird case where the events + * `beforeinput` and `input` have different `data` when + * characters are deleted when the input type is + * `insertCompositionText`. + */ + #fixInsertCompositionText = false; + + /** + * Constructor. + * + * @param {HTMLElement} element + */ + constructor(element, options) { + super(); + if (!(element instanceof HTMLElement)) + throw new TypeError("Invalid text editor element"); + + this.#element = element; + this.#selectionImposterElement = options?.selectionImposterElement; + this.#events = { + blur: this.#onBlur, + focus: this.#onFocus, + + paste: this.#onPaste, + cut: this.#onCut, + copy: this.#onCopy, + + beforeinput: this.#onBeforeInput, + input: this.#onInput, + }; + this.#styleDefaults = options?.styleDefaults; + this.#setup(options); + } + + /** + * Setups editor properties. + */ + #setupElementProperties() { + if (!this.#element.isContentEditable) { + this.#element.contentEditable = "true"; + // In `jsdom` it isn't enough to set the attribute 'contentEditable' + // to `true` to work. + // FIXME: Remove this when `jsdom` implements this interface. + if (!this.#element.isContentEditable) { + this.#element.setAttribute("contenteditable", "true"); + } + } + if (this.#element.spellcheck) this.#element.spellcheck = false; + if (this.#element.autocapitalize) this.#element.autocapitalize = false; + if (!this.#element.autofocus) this.#element.autofocus = true; + if (!this.#element.role || this.#element.role !== "textbox") + this.#element.role = "textbox"; + if (this.#element.ariaAutoComplete) this.#element.ariaAutoComplete = false; + if (!this.#element.ariaMultiLine) this.#element.ariaMultiLine = true; + this.#element.dataset.itype = "editor"; + } + + /** + * Setups the root element. + */ + #setupRoot() { + this.#root = createEmptyRoot(this.#styleDefaults); + this.#element.appendChild(this.#root); + } + + /** + * Dispatchs a `change` event. + * + * @param {CustomEvent} e + * @returns {void} + */ + #onChange = (e) => this.dispatchEvent(new e.constructor(e.type, e)); + + /** + * Dispatchs a `stylechange` event. + * + * @param {CustomEvent} e + * @returns {void} + */ + #onStyleChange = (e) => { + if (this.#selectionImposterElement.children.length > 0) { + // We need to recreate the selection imposter when we've + // already have one. + this.#createSelectionImposter(); + } + this.dispatchEvent(new e.constructor(e.type, e)); + }; + + /** + * Setups the elements, the properties and the + * initial content. + */ + #setup(options) { + this.#setupElementProperties(); + this.#setupRoot(); + this.#changeController = new ChangeController(this); + this.#changeController.addEventListener("change", this.#onChange); + this.#selectionController = new SelectionController( + this, + document.getSelection(), + options + ); + this.#selectionController.addEventListener( + "stylechange", + this.#onStyleChange + ); + addEventListeners(this.#element, this.#events, { + capture: true, + }); + } + + /** + * Creates the selection imposter. + */ + #createSelectionImposter() { + // We only create a selection imposter if there's any selection + // and if there is a selection imposter element to attach the + // rects. + if ( + this.#selectionImposterElement && + !this.#selectionController.isCollapsed + ) { + const rects = this.#selectionController.range?.getClientRects(); + if (rects) { + const rect = this.#selectionImposterElement.getBoundingClientRect(); + this.#selectionImposterElement.replaceChildren( + createSelectionImposterFromClientRects(rect, rects) + ); + } + } + } + + /** + * On blur we create a new FakeSelection if there's any. + * + * @param {FocusEvent} e + */ + #onBlur = (e) => { + this.#changeController.notifyImmediately(); + this.#selectionController.saveSelection(); + this.#createSelectionImposter(); + this.dispatchEvent(new FocusEvent(e.type, e)); + }; + + /** + * On focus we should restore the FakeSelection from the current + * selection. + * + * @param {FocusEvent} e + */ + #onFocus = (e) => { + this.#selectionController.restoreSelection(); + if (this.#selectionImposterElement) { + this.#selectionImposterElement.replaceChildren(); + } + this.dispatchEvent(new FocusEvent(e.type, e)); + }; + + /** + * Event called when the user pastes some text into the + * editor. + * + * @param {ClipboardEvent} e + */ + #onPaste = (e) => { + clipboard.paste(e, this, this.#selectionController); + this.#notifyLayout(LayoutType.FULL, null); + }; + + /** + * Event called when the user cuts some text from the + * editor. + * + * @param {ClipboardEvent} e + */ + #onCut = (e) => clipboard.cut(e, this, this.#selectionController); + + /** + * Event called when the user copies some text from the + * editor. + * + * @param {ClipboardEvent} e + */ + #onCopy = (e) => clipboard.copy(e, this, this.#selectionController); + + /** + * Event called before the DOM is modified. + * + * @param {InputEvent} e + */ + #onBeforeInput = (e) => { + if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { + return; + } + + if (e.inputType === "insertCompositionText" && !e.data) { + e.preventDefault(); + this.#fixInsertCompositionText = true; + return; + } + + if (!(e.inputType in commands)) { + if (e.inputType !== "insertCompositionText") { + e.preventDefault(); + } + return; + } + + if (e.inputType in commands) { + const command = commands[e.inputType]; + if (!this.#selectionController.startMutation()) { + return; + } + command(e, this, this.#selectionController); + const mutations = this.#selectionController.endMutation(); + this.#notifyLayout(LayoutType.FULL, mutations); + } + }; + + /** + * Event called after the DOM is modified. + * + * @param {InputEvent} e + */ + #onInput = (e) => { + if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { + return; + } + + if (e.inputType === "insertCompositionText" && this.#fixInsertCompositionText) { + e.preventDefault(); + this.#fixInsertCompositionText = false; + if (e.data) { + this.#selectionController.fixInsertCompositionText(); + } + return; + } + + if (e.inputType === "insertCompositionText" && e.data) { + this.#notifyLayout(LayoutType.FULL, null); + } + }; + + /** + * Notifies that the edited texts needs layout. + * + * @param {'full'|'partial'} type + * @param {CommandMutations} mutations + */ + #notifyLayout(type = LayoutType.FULL, mutations) { + this.dispatchEvent( + new CustomEvent("needslayout", { + detail: { + type: type, + mutations: mutations, + }, + }) + ); + } + + /** + * Root element that contains all the paragraphs. + * + * @type {HTMLDivElement} + */ + get root() { + return this.#root; + } + + set root(newRoot) { + const previousRoot = this.#root; + this.#root = newRoot; + previousRoot.replaceWith(newRoot); + } + + /** + * Element that contains the root and that has the + * contenteditable attribute. + * + * @type {HTMLElement} + */ + get element() { + return this.#element; + } + + /** + * Returns true if the content is in an empty state. + * + * @type {boolean} + */ + get isEmpty() { + return ( + this.#root.children.length === 1 && + this.#root.firstElementChild.children.length === 1 && + isLineBreak(this.#root.firstElementChild.firstElementChild.firstChild) + ); + } + + /** + * Indicates the amount of paragraphs in the current content. + * + * @type {number} + */ + get numParagraphs() { + return this.#root.children.length; + } + + /** + * CSS Style declaration for the current inline. From here we + * can infer root, paragraph and inline declarations. + * + * @type {CSSStyleDeclaration} + */ + get currentStyle() { + return this.#selectionController.currentStyle; + } + + /** + * Focus the element + */ + focus() { + return this.#element.focus(); + } + + /** + * Blurs the element + */ + blur() { + return this.#element.blur(); + } + + /** + * Creates a new root. + * + * @param {...any} args + * @returns {HTMLDivElement} + */ + createRoot(...args) { + return createRoot(...args); + } + + /** + * Creates a new paragraph. + * + * @param {...any} args + * @returns {HTMLDivElement} + */ + createParagraph(...args) { + return createParagraph(...args); + } + + /** + * Creates a new inline from a string. + * + * @param {string} text + * @param {Object.|CSSStyleDeclaration} styles + * @returns {HTMLSpanElement} + */ + createInlineFromString(text, styles) { + if (text === "") { + return createEmptyInline(styles); + } + return createInline(new Text(text), styles); + } + + /** + * Creates a new inline. + * + * @param {...any} args + * @returns {HTMLSpanElement} + */ + createInline(...args) { + return createInline(...args); + } + + /** + * Applies the current styles to the selection or + * the current DOM node at the caret. + * + * @param {*} styles + */ + applyStylesToSelection(styles) { + this.#selectionController.startMutation(); + this.#selectionController.applyStyles(styles); + const mutations = this.#selectionController.endMutation(); + this.#notifyLayout(LayoutType.FULL, mutations); + this.#changeController.notifyImmediately(); + return this; + } + + /** + * Selects all content. + */ + selectAll() { + this.#selectionController.selectAll(); + return this; + } + + /** + * Moves cursor to end. + * + * @returns + */ + cursorToEnd() { + this.#selectionController.cursorToEnd(); + return this; + } + + /** + * Disposes everything. + */ + dispose() { + this.#changeController.removeEventListener("change", this.#onChange); + this.#changeController.dispose(); + this.#changeController = null; + this.#selectionController.removeEventListener( + "stylechange", + this.#onStyleChange + ); + this.#selectionController.dispose(); + this.#selectionController = null; + removeEventListeners(this.#element, this.#events); + this.#element = null; + this.#root = null; + } +} + +export function isEditor(instance) { + return (instance instanceof TextEditor); +} + +/* Convenience function based API for Text Editor */ +export function getRoot(instance) { + if (isEditor(instance)) { + return instance.root; + } else { + return null; + } +} + +export function setRoot(instance, root) { + if (isEditor(instance)) { + instance.root = root; + } + + return instance; +} + +export function create(element, options) { + return new TextEditor(element, {...options}); +} + +export function getCurrentStyle(instance) { + if (isEditor(instance)) { + return instance.currentStyle; + } +} + +export function applyStylesToSelection(instance, styles) { + if (isEditor(instance)) { + return instance.applyStylesToSelection(styles); + } +} + +export function dispose(instance) { + if (isEditor(instance)) { + instance.dispose(); + } +} + +export default TextEditor; diff --git a/frontend/text-editor/editor/TextEditor.test.js b/frontend/text-editor/editor/TextEditor.test.js new file mode 100644 index 000000000..891fbe79d --- /dev/null +++ b/frontend/text-editor/editor/TextEditor.test.js @@ -0,0 +1,100 @@ +import { describe, test, expect } from 'vitest' +import { TextEditor } from './TextEditor' + +/* @vitest-environment jsdom */ +describe('TextEditor', () => { + test('Creating TextEditor without element should throw', () => { + expect(() => new TextEditor()).toThrowError('Invalid text editor element'); + }); + + test('Creating TextEditor with element should success', () => { + expect(new TextEditor(document.createElement('div'))).toBeInstanceOf(TextEditor); + }); + + test('isEmpty should return true when editor is empty', () => { + const textEditor = new TextEditor(document.createElement("div")); + expect(textEditor).toBeInstanceOf(TextEditor); + expect(textEditor.isEmpty).toBe(true); + }); + + test('Num paragraphs should return 1 when empty', () => { + const textEditor = new TextEditor(document.createElement("div")); + expect(textEditor).toBeInstanceOf(TextEditor); + expect(textEditor.numParagraphs).toBe(1); + }); + + test('Num paragraphs should return the number of paragraphs', () => { + const textEditor = new TextEditor(document.createElement("div")); + textEditor.root = textEditor.createRoot([ + textEditor.createParagraph([ + textEditor.createInlineFromString('Hello, World!') + ]), + textEditor.createParagraph([ + textEditor.createInlineFromString('') + ]), + textEditor.createParagraph([ + textEditor.createInlineFromString('¡Hola, Mundo!') + ]), + textEditor.createParagraph([ + textEditor.createInlineFromString('Hallo, Welt!') + ]) + ]); + expect(textEditor).toBeInstanceOf(TextEditor); + expect(textEditor.numParagraphs).toBe(4); + }); + + test('Disposing a TextEditor nullifies everything', () => { + const textEditor = new TextEditor(document.createElement("div")); + expect(textEditor).toBeInstanceOf(TextEditor); + textEditor.dispose(); + expect(textEditor.root).toBe(null); + expect(textEditor.element).toBe(null); + }); + + test('TextEditor focus should focus the contenteditable element', () => { + const textEditorElement = document.createElement("div"); + document.body.appendChild(textEditorElement); + const textEditor = new TextEditor(textEditorElement); + expect(textEditor).toBeInstanceOf(TextEditor); + textEditor.focus(); + expect(document.activeElement).toBe(textEditor.element); + }); + + test("TextEditor blur should blur the contenteditable element", () => { + const textEditorElement = document.createElement("div"); + document.body.appendChild(textEditorElement); + const textEditor = new TextEditor(textEditorElement); + expect(textEditor).toBeInstanceOf(TextEditor); + textEditor.focus(); + textEditor.blur(); + expect(document.activeElement).not.toBe(textEditor.element); + }); + + test("TextEditor focus -> blur -> focus should restore old selection", () => { + const textEditorElement = document.createElement("div"); + document.body.appendChild(textEditorElement); + const textEditor = new TextEditor(textEditorElement); + textEditor.root = textEditor.createRoot([ + textEditor.createParagraph([ + textEditor.createInlineFromString("Hello, World!") + ]) + ]); + expect(textEditor).toBeInstanceOf(TextEditor); + textEditor.focus(); + textEditor.blur(); + textEditor.focus(); + expect(document.activeElement).toBe(textEditor.element); + }); + + test("TextEditor selectAll should select all the contenteditable", () => { + const selection = document.getSelection(); + const textEditorElement = document.createElement("div"); + document.body.appendChild(textEditorElement); + const textEditor = new TextEditor(textEditorElement); + expect(textEditor).toBeInstanceOf(TextEditor); + textEditor.focus(); + textEditor.selectAll(); + expect(document.activeElement).toBe(textEditor.element); + expect(selection.containsNode(textEditor.root)); + }); +}); diff --git a/frontend/text-editor/editor/clipboard/copy.js b/frontend/text-editor/editor/clipboard/copy.js new file mode 100644 index 000000000..49ed9f9ec --- /dev/null +++ b/frontend/text-editor/editor/clipboard/copy.js @@ -0,0 +1,19 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * This event is called when the user copies a text from the + * editor. + * + * TODO: We could transform `--fills` in here to CSS `color`, `background-image`, + * etc. to be more compatible with other applications. + * + * @param {ClipboardEvent} event + * @param {TextEditor} editor + */ +export function copy(event, editor) {} diff --git a/frontend/text-editor/editor/clipboard/cut.js b/frontend/text-editor/editor/clipboard/cut.js new file mode 100644 index 000000000..d376c572a --- /dev/null +++ b/frontend/text-editor/editor/clipboard/cut.js @@ -0,0 +1,19 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * This event is called when the user copies a text from the + * editor. + * + * TODO: We could transform `--fills` in here to CSS `color`, `background-image`, + * etc. to be more compatible with other applications. + * + * @param {ClipboardEvent} event + * @param {TextEditor} editor + */ +export function cut(event, editor) {} diff --git a/frontend/text-editor/editor/clipboard/index.js b/frontend/text-editor/editor/clipboard/index.js new file mode 100644 index 000000000..a76b7b049 --- /dev/null +++ b/frontend/text-editor/editor/clipboard/index.js @@ -0,0 +1,17 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { copy } from "./copy.js"; +import { cut } from "./cut.js"; +import { paste } from "./paste.js"; + +export default { + copy, + cut, + paste, +}; diff --git a/frontend/text-editor/editor/clipboard/paste.js b/frontend/text-editor/editor/clipboard/paste.js new file mode 100644 index 000000000..94a6cc161 --- /dev/null +++ b/frontend/text-editor/editor/clipboard/paste.js @@ -0,0 +1,45 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { mapContentFragmentFromHTML, mapContentFragmentFromString } from '../content/dom/Content.js'; + +/** + * When the user pastes some HTML, what we do is generate + * a new DOM based on what the user pasted and then we + * insert it in the appropiate part (see `insertFromPaste` command). + * + * @param {ClipboardEvent} event + * @param {TextEditor} editor + * @param {SelectionController} selectionController + * @returns {void} + */ +export function paste(event, editor, selectionController) { + // We need to prevent default behavior + // because we don't allow any HTML to + // be pasted. + event.preventDefault(); + + let fragment = null; + if (event.clipboardData.types.includes("text/html")) { + const html = event.clipboardData.getData("text/html"); + fragment = mapContentFragmentFromHTML(html, selectionController.currentStyle); + } else if (event.clipboardData.types.includes("text/plain")) { + const plain = event.clipboardData.getData("text/plain"); + fragment = mapContentFragmentFromString(plain, selectionController.currentStyle); + } + + if (!fragment) { + return; + } + + if (selectionController.isCollapsed) { + selectionController.insertPaste(fragment); + } else { + selectionController.replaceWithPaste(fragment); + } +} diff --git a/frontend/text-editor/editor/commands/CommandMutations.js b/frontend/text-editor/editor/commands/CommandMutations.js new file mode 100644 index 000000000..fca36be14 --- /dev/null +++ b/frontend/text-editor/editor/commands/CommandMutations.js @@ -0,0 +1,66 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Command mutations + */ +export class CommandMutations { + #added = new Set(); + #removed = new Set(); + #updated = new Set(); + + constructor(added, updated, removed) { + if (added && Array.isArray(added)) this.#added = new Set(added); + if (updated && Array.isArray(updated)) this.#updated = new Set(updated); + if (removed && Array.isArray(removed)) this.#removed = new Set(removed); + } + + get added() { + return this.#added; + } + + get removed() { + return this.#removed; + } + + get updated() { + return this.#updated; + } + + clear() { + this.#added.clear(); + this.#removed.clear(); + this.#updated.clear(); + } + + dispose() { + this.#added.clear(); + this.#added = null; + this.#removed.clear(); + this.#removed = null; + this.#updated.clear(); + this.#updated = null; + } + + add(node) { + this.#added.add(node); + return this; + } + + remove(node) { + this.#removed.add(node); + return this; + } + + update(node) { + this.#updated.add(node); + return this; + } +} + +export default CommandMutations; diff --git a/frontend/text-editor/editor/commands/CommandMutations.test.js b/frontend/text-editor/editor/commands/CommandMutations.test.js new file mode 100644 index 000000000..94e540ed5 --- /dev/null +++ b/frontend/text-editor/editor/commands/CommandMutations.test.js @@ -0,0 +1,71 @@ +import { describe, test, expect } from 'vitest'; +import CommandMutations from './CommandMutations'; + +describe("CommandMutations", () => { + test("should create a new CommandMutations", () => { + const mutations = new CommandMutations(); + expect(mutations).toHaveProperty("added"); + expect(mutations).toHaveProperty("updated"); + expect(mutations).toHaveProperty("removed"); + }); + + test("should create an initialized new CommandMutations", () => { + const mutations = new CommandMutations([1], [2], [3]); + expect(mutations.added.size).toBe(1); + expect(mutations.updated.size).toBe(1); + expect(mutations.removed.size).toBe(1); + expect(mutations.added.has(1)).toBe(true); + expect(mutations.updated.has(2)).toBe(true); + expect(mutations.removed.has(3)).toBe(true); + }); + + test("should add an added node to a CommandMutations", () => { + const mutations = new CommandMutations(); + mutations.add(1); + expect(mutations.added.has(1)).toBe(true); + }); + + test("should add an updated node to a CommandMutations", () => { + const mutations = new CommandMutations(); + mutations.update(1); + expect(mutations.updated.has(1)).toBe(true); + }); + + test("should add an removed node to a CommandMutations", () => { + const mutations = new CommandMutations(); + mutations.remove(1); + expect(mutations.removed.has(1)).toBe(true); + }); + + test("should clear a CommandMutations", () => { + const mutations = new CommandMutations(); + mutations.add(1); + mutations.update(2); + mutations.remove(3); + expect(mutations.added.has(1)).toBe(true); + expect(mutations.added.size).toBe(1); + expect(mutations.updated.has(2)).toBe(true); + expect(mutations.updated.size).toBe(1); + expect(mutations.removed.has(3)).toBe(true); + expect(mutations.removed.size).toBe(1); + + mutations.clear(); + expect(mutations.added.size).toBe(0); + expect(mutations.added.has(1)).toBe(false); + expect(mutations.updated.size).toBe(0); + expect(mutations.updated.has(1)).toBe(false); + expect(mutations.removed.size).toBe(0); + expect(mutations.removed.has(1)).toBe(false); + }); + + test("should dispose a CommandMutations", () => { + const mutations = new CommandMutations(); + mutations.add(1); + mutations.update(2); + mutations.remove(3); + mutations.dispose(); + expect(mutations.added).toBe(null); + expect(mutations.updated).toBe(null); + expect(mutations.removed).toBe(null); + }) +}); diff --git a/frontend/text-editor/editor/commands/deleteByCut.js b/frontend/text-editor/editor/commands/deleteByCut.js new file mode 100644 index 000000000..f35b6d2bc --- /dev/null +++ b/frontend/text-editor/editor/commands/deleteByCut.js @@ -0,0 +1,22 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Remove the current selection as part of a cut. + * + * @param {InputEvent} event + * @param {TextEditor} editor + * @param {SelectionController} selectionController + */ +export function deleteByCut(event, editor, selectionController) { + event.preventDefault(); + if (selectionController.isCollapsed) { + throw new Error("This should be impossible"); + } + return selectionController.removeSelected(); +} diff --git a/frontend/text-editor/editor/commands/deleteContentBackward.js b/frontend/text-editor/editor/commands/deleteContentBackward.js new file mode 100644 index 000000000..142e2236b --- /dev/null +++ b/frontend/text-editor/editor/commands/deleteContentBackward.js @@ -0,0 +1,53 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * delete the content directly before the caret position and this intention is + * not covered by another `inputType` or delete the selection with the + * selection collapsing to its start after the deletion. + * + * @param {InputEvent} event + * @param {TextEditor} editor + * @param {SelectionController} selectionController + */ +export function deleteContentBackward(event, editor, selectionController) { + event.preventDefault(); + // If the editor is empty this is a no op. + if (editor.isEmpty) return; + + // If not is collapsed AKA is a selection, then + // we removeSelected. + if (!selectionController.isCollapsed) { + return selectionController.removeSelected({ direction: 'backward' }); + } + + // If we're in a text node and the offset is + // greater than 0 (not at the start of the inline) + // we simple remove a character from the text. + if (selectionController.isTextFocus && selectionController.focusOffset > 0) { + return selectionController.removeBackwardText(); + + // If we're in a text node but we're at the end of the + // paragraph, we should merge the current paragraph + // with the following paragraph. + } else if ( + selectionController.isTextFocus && + selectionController.focusAtStart + ) { + return selectionController.mergeBackwardParagraph(); + + // If we're at an inline or a line break paragraph + // and there's more than one paragraph, then we should + // remove the next paragraph. + } else if ( + selectionController.isInlineFocus || + selectionController.isLineBreakFocus + ) { + return selectionController.removeBackwardParagraph(); + } +} diff --git a/frontend/text-editor/editor/commands/deleteContentForward.js b/frontend/text-editor/editor/commands/deleteContentForward.js new file mode 100644 index 000000000..c8b5c4d70 --- /dev/null +++ b/frontend/text-editor/editor/commands/deleteContentForward.js @@ -0,0 +1,54 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * delete the content directly after the caret position and this intention is not covered by + * another inputType or delete the selection with the selection collapsing to its end after the deletion + * + * @param {InputEvent} event + * @param {TextEditor} editor + * @param {SelectionController} selectionController + */ +export function deleteContentForward(event, editor, selectionController) { + event.preventDefault(); + // If the editor is empty this is a no op. + if (editor.isEmpty) return; + + // If not is collapsed AKA is a selection, then + // we removeSelected. + if (!selectionController.isCollapsed) { + return selectionController.removeSelected({ direction: "forward" }); + } + + // If we're in a text node and the offset is + // greater than 0 (not at the start of the inline) + // we simple remove a character from the text. + if (selectionController.isTextFocus + && selectionController.focusAtEnd) { + return selectionController.mergeForwardParagraph(); + + // If we're in a text node but we're at the end of the + // paragraph, we should merge the current paragraph + // with the following paragraph. + } else if ( + selectionController.isTextFocus && + selectionController.focusOffset >= 0 + ) { + return selectionController.removeForwardText(); + + // If we're at an inline or a line break paragraph + // and there's more than one paragraph, then we should + // remove the next paragraph. + } else if ( + (selectionController.isInlineFocus || + selectionController.isLineBreakFocus) && + editor.numParagraphs > 1 + ) { + return selectionController.removeForwardParagraph(); + } +} diff --git a/frontend/text-editor/editor/commands/index.js b/frontend/text-editor/editor/commands/index.js new file mode 100644 index 000000000..eb639da0b --- /dev/null +++ b/frontend/text-editor/editor/commands/index.js @@ -0,0 +1,21 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { insertText } from "./insertText.js"; +import { insertParagraph } from "./insertParagraph.js"; +import { deleteByCut } from "./deleteByCut.js"; +import { deleteContentBackward } from "./deleteContentBackward.js"; +import { deleteContentForward } from "./deleteContentForward.js"; + +export default { + insertText, + insertParagraph, + deleteByCut, + deleteContentBackward, + deleteContentForward, +}; diff --git a/frontend/text-editor/editor/commands/insertParagraph.js b/frontend/text-editor/editor/commands/insertParagraph.js new file mode 100644 index 000000000..2efdd9327 --- /dev/null +++ b/frontend/text-editor/editor/commands/insertParagraph.js @@ -0,0 +1,23 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Insert a paragraph + * + * @see https://w3c.github.io/input-events/#interface-InputEvent + * @param {InputEvent} event + * @param {TextEditor} editor + * @param {SelectionController} selectionController + */ +export function insertParagraph(event, editor, selectionController) { + event.preventDefault(); + if (selectionController.isCollapsed) { + return selectionController.insertParagraph(); + } + return selectionController.replaceWithParagraph(); +} diff --git a/frontend/text-editor/editor/commands/insertText.js b/frontend/text-editor/editor/commands/insertText.js new file mode 100644 index 000000000..c8df8d8fd --- /dev/null +++ b/frontend/text-editor/editor/commands/insertText.js @@ -0,0 +1,34 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Insert typed plain text + * + * @see https://w3c.github.io/input-events/#interface-InputEvent + * @param {InputEvent} event + * @param {TextEditor} editor + * @param {SelectionController} selectionController + */ +export function insertText(event, editor, selectionController) { + event.preventDefault(); + if (selectionController.isCollapsed) { + if (selectionController.isTextFocus) { + return selectionController.insertText(event.data); + } else if (selectionController.isLineBreakFocus) { + return selectionController.replaceLineBreak(event.data); + } + } else { + if (selectionController.isMultiParagraph) { + return selectionController.replaceParagraphs(event.data); + } else if (selectionController.isMultiInline) { + return selectionController.replaceInlines(event.data); + } else if (selectionController.isTextSame) { + return selectionController.replaceText(event.data); + } + } +} diff --git a/frontend/text-editor/editor/content/Text.js b/frontend/text-editor/editor/content/Text.js new file mode 100644 index 000000000..9f06c2dcc --- /dev/null +++ b/frontend/text-editor/editor/content/Text.js @@ -0,0 +1,104 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Throws if the passed value is not a valid offset value. + * + * @param {*} offset + * @throws {TypeError} + */ +function tryOffset(offset) { + if (!Number.isInteger(offset) || offset < 0) + throw new TypeError("Invalid offset"); +} + +/** + * Throws if the passed value is not a valid string. + * + * @param {*} str + * @throws {TypeError} + */ +function tryString(str) { + if (typeof str !== "string") throw new TypeError("Invalid string"); +} + +/** + * Inserts string into a string. + * + * @param {string} str + * @param {number} offset + * @param {string} text + * @returns {string} + */ +export function insertInto(str, offset, text) { + tryString(str); + tryOffset(offset); + tryString(text); + return str.slice(0, offset) + text + str.slice(offset); +} + +/** + * Replaces a part of a string with a string. + * + * @param {string} str + * @param {number} startOffset + * @param {number} endOffset + * @param {string} text + * @returns {string} + */ +export function replaceWith(str, startOffset, endOffset, text) { + tryString(str); + tryOffset(startOffset); + tryOffset(endOffset); + tryString(text); + return str.slice(0, startOffset) + text + str.slice(endOffset); +} + +/** + * Removes text backward from specified offset. + * + * @param {string} str + * @param {number} offset + * @returns {string} + */ +export function removeBackward(str, offset) { + tryString(str); + tryOffset(offset); + if (offset === 0) { + return str; + } + return str.slice(0, offset - 1) + str.slice(offset); +} + +/** + * Removes text forward from specified offset. + * + * @param {string} str + * @param {number} offset + * @returns {string} + */ +export function removeForward(str, offset) { + tryString(str); + tryOffset(offset); + return str.slice(0, offset) + str.slice(offset + 1); +} + +/** + * Removes a slice of text. + * + * @param {string} str + * @param {number} start + * @param {number} end + * @returns {string} + */ +export function removeSlice(str, start, end) { + tryString(str); + tryOffset(start); + tryOffset(end); + return str.slice(0, start) + str.slice(end); +} diff --git a/frontend/text-editor/editor/content/Text.test.js b/frontend/text-editor/editor/content/Text.test.js new file mode 100644 index 000000000..e8c43a130 --- /dev/null +++ b/frontend/text-editor/editor/content/Text.test.js @@ -0,0 +1,46 @@ +import { describe, test, expect } from 'vitest' +import { insertInto, removeBackward, removeForward, replaceWith } from './Text'; + +describe("Text", () => { + test("* should throw when passed wrong parameters", () => { + expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError('Invalid string'); + expect(() => insertInto('Hello', Infinity, Infinity)).toThrowError('Invalid offset'); + expect(() => insertInto('Hello', 0, Infinity)).toThrowError('Invalid string'); + }); + + test("`insertInto` should insert a string into an offset", () => { + expect(insertInto("Hell, World!", 4, "o")).toBe("Hello, World!"); + }); + + test("`replaceWith` should replace a string into a string", () => { + expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe("Hello, World!"); + }); + + test("`removeBackward` should remove string backward from start (offset 0)", () => { + expect(removeBackward("Hello, World!", 0)).toBe("Hello, World!"); + }); + + test("`removeForward` should remove string forward from start (offset 0)", () => { + expect(removeForward("Hello, World!", 0)).toBe("ello, World!"); + }); + + test("`removeBackward` should remove string backward from end", () => { + expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe( + "Hello, World" + ); + }); + + test("`removeForward` should remove string forward from end", () => { + expect(removeForward("Hello, World!", "Hello, World!".length)).toBe( + "Hello, World!" + ); + }); + + test("`removeBackward` should remove string backward from offset 6", () => { + expect(removeBackward("Hello, World!", 6)).toBe("Hello World!"); + }); + + test("`removeForward` should remove string forward from offset 6", () => { + expect(removeForward("Hello, World!", 6)).toBe("Hello,World!"); + }); +}); diff --git a/frontend/text-editor/editor/content/dom/Color.js b/frontend/text-editor/editor/content/dom/Color.js new file mode 100644 index 000000000..01a9e23bb --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Color.js @@ -0,0 +1,78 @@ +/** + * Canvas used to retrieve colors as CSS hexadecimals. + * + * @type {OffscreenCanvas|HTMLCanvasElement} + */ +let canvas = null; // createCanvas(1, 1); + +/** + * Context used to retrieve colors as CSS hexadecimals. + * + * @type {CanvasRenderingContext2D} + */ +let context = null; // canvas.getContext("2d"); + +/** + * Returns the canvas context. + * + * @returns {CanvasRenderingContext2D} + */ +function getContext() { + if (!canvas) { + canvas = createCanvas(1, 1); + } + if (!context) { + context = canvas.getContext("2d"); + } + return context +} + +/** + * Creates a new canvas element. + * + * @param {number} width + * @param {number} height + * @returns {OffscreenCanvas|HTMLCanvasElement} + */ +function createCanvas(width, height) { + if ("OffscreenCanvas" in globalThis) { + return new OffscreenCanvas(width, height); + } + return document.createElement("canvas"); +} + +/** + * Returns a byte representation as an hex. + * + * @param {number} byte + * @returns {string} + */ +export function getByteAsHex(byte) { + return byte.toString(16).padStart(2, "0"); +} + +/** + * Returns a color definition from a fillStyle color. + * + * @param {string} fillStyle + * @returns {[string, number]} + */ +export function getColor(fillStyle) { + const context = getContext(); + context.fillStyle = fillStyle; + context.fillRect(0, 0, 1, 1); + const imageData = context.getImageData(0, 0, 1, 1); + const [r, g, b, a] = imageData.data; + return [`#${getByteAsHex(r)}${getByteAsHex(g)}${getByteAsHex(b)}`, a / 255.0]; +} + +/** + * Returns a fill from a fillStyle color. + * + * @param {string} fillStyle + * @returns {string} + */ +export function getFills(fillStyle) { + const [color, opacity] = getColor(fillStyle); + return `[["^ ","~:fill-color","${color}","~:fill-opacity",${opacity}]]`; +} diff --git a/frontend/text-editor/editor/content/dom/Content.js b/frontend/text-editor/editor/content/dom/Content.js new file mode 100644 index 000000000..0a2ba7ff0 --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Content.js @@ -0,0 +1,102 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { createInline } from "./Inline.js"; +import { + createEmptyParagraph, + createParagraph, + isLikeParagraph, +} from "./Paragraph.js"; +import { isDisplayBlock, normalizeStyles } from "./Style.js"; + +/** + * Maps any HTML into a valid content DOM element. + * + * @param {Document} document + * @param {HTMLElement} root + * @param {CSSStyleDeclaration} [styleDefaults] + * @returns {DocumentFragment} + */ +export function mapContentFragmentFromDocument(document, root, styleDefaults) { + const nodeIterator = document.createNodeIterator( + root, + NodeFilter.SHOW_TEXT + ); + const fragment = document.createDocumentFragment(); + + let currentParagraph = null; + let currentNode = nodeIterator.nextNode(); + while (currentNode) { + // We cannot call document.defaultView because it is `null`. + const currentStyle = normalizeStyles(currentNode, styleDefaults); + if ( + isDisplayBlock(currentNode.parentElement.style) || + isDisplayBlock(currentStyle) || + isLikeParagraph(currentNode.parentElement) + ) { + if (currentParagraph) { + fragment.appendChild(currentParagraph); + } + currentParagraph = createParagraph(undefined, currentStyle); + } else { + if (currentParagraph === null) { + currentParagraph = createParagraph(undefined, currentStyle); + } + } + + currentParagraph.appendChild( + createInline(new Text(currentNode.nodeValue), currentStyle) + ); + + currentNode = nodeIterator.nextNode(); + } + + fragment.appendChild(currentParagraph); + return fragment; +} + +/** + * Maps any HTML into a valid content DOM element. + * + * @param {string} html + * @param {CSSStyleDeclaration} [styleDefaults] + * @returns {DocumentFragment} + */ +export function mapContentFragmentFromHTML(html, styleDefaults) { + const parser = new DOMParser(); + const htmlDocument = parser.parseFromString(html, "text/html"); + return mapContentFragmentFromDocument( + htmlDocument, + htmlDocument.documentElement, + styleDefaults + ); +} + +/** + * Maps a plain text into a valid content DOM element. + * + * @param {string} string + * @param {CSSStyleDeclaration} [styleDefaults] + * @returns {DocumentFragment} + */ +export function mapContentFragmentFromString(string, styleDefaults) { + const lines = string.replace(/\r/g, "").split("\n"); + const fragment = document.createDocumentFragment(); + for (const line of lines) { + if (line === "") { + fragment.appendChild(createEmptyParagraph(styleDefaults)); + } else { + fragment.appendChild( + createParagraph([ + createInline(new Text(line), styleDefaults) + ], styleDefaults) + ); + } + } + return fragment; +} diff --git a/frontend/text-editor/editor/content/dom/Content.test.js b/frontend/text-editor/editor/content/dom/Content.test.js new file mode 100644 index 000000000..e27d0968e --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Content.test.js @@ -0,0 +1,91 @@ +import { describe, test, expect } from 'vitest'; +import { mapContentFragmentFromHTML, mapContentFragmentFromString } from './Content.js'; + +/* @vitest-environment jsdom */ +describe('Content', () => { + test("mapContentFragmentFromHTML should return a valid content for the editor", () => { + const inertElement = document.createElement("div"); + const contentFragment = mapContentFragmentFromHTML( + "
Hello, World!
", + inertElement.style + ); + expect(contentFragment).toBeInstanceOf(DocumentFragment); + expect(contentFragment.children).toHaveLength(1); + expect(contentFragment.firstElementChild).toBeInstanceOf(HTMLDivElement); + expect(contentFragment.firstElementChild.firstElementChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(contentFragment.firstElementChild.firstElementChild.firstChild).toBeInstanceOf( + Text + ); + expect(contentFragment.textContent).toBe("Hello, World!"); + }); + + test("mapContentFragmentFromHTML should return a valid content for the editor (multiple inlines)", () => { + const inertElement = document.createElement("div"); + const contentFragment = mapContentFragmentFromHTML( + "
Hello,
World!
", + inertElement.style + ); + expect(contentFragment).toBeInstanceOf(DocumentFragment); + expect(contentFragment.children).toHaveLength(1); + expect(contentFragment.firstElementChild).toBeInstanceOf(HTMLDivElement); + expect(contentFragment.firstElementChild.children).toHaveLength(2); + expect(contentFragment.firstElementChild.firstElementChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(contentFragment.firstElementChild.firstElementChild.firstChild).toBeInstanceOf( + Text + ); + expect(contentFragment.textContent).toBe("Hello, World!"); + }); + + test("mapContentFragmentFromHTML should return a valid content for the editor (multiple paragraphs)", () => { + const paragraphs = [ + "Lorem ipsum", + "Dolor sit amet", + "Sed iaculis blandit odio ornare sagittis.", + ]; + const inertElement = document.createElement("div"); + const contentFragment = mapContentFragmentFromHTML( + "
Lorem ipsum
Dolor sit amet

Sed iaculis blandit odio ornare sagittis.
", + inertElement.style + ); + expect(contentFragment).toBeInstanceOf(DocumentFragment); + expect(contentFragment.children).toHaveLength(3); + for (let index = 0; index < contentFragment.children.length; index++) { + expect(contentFragment.children.item(index)).toBeInstanceOf(HTMLDivElement); + expect(contentFragment.children.item(index).firstElementChild).toBeInstanceOf( + HTMLSpanElement + ); + expect( + contentFragment.children.item(index).firstElementChild.firstChild + ).toBeInstanceOf(Text); + expect(contentFragment.children.item(index).textContent).toBe(paragraphs[index]); + } + expect(contentFragment.textContent).toBe("Lorem ipsumDolor sit ametSed iaculis blandit odio ornare sagittis."); + }); + + test("mapContentFragmentFromString should return a valid content for the editor", () => { + const contentFragment = mapContentFragmentFromString( + "Hello, \nWorld!" + ); + expect(contentFragment).toBeInstanceOf(DocumentFragment); + expect(contentFragment.children).toHaveLength(2); + expect(contentFragment.children.item(0)).toBeInstanceOf(HTMLDivElement); + expect(contentFragment.children.item(1)).toBeInstanceOf(HTMLDivElement); + expect(contentFragment.children.item(0).firstElementChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(contentFragment.children.item(0).firstElementChild.firstChild).toBeInstanceOf( + Text + ); + expect(contentFragment.children.item(1).firstElementChild).toBeInstanceOf( + HTMLSpanElement + ); + expect( + contentFragment.children.item(1).firstElementChild.firstChild + ).toBeInstanceOf(Text); + expect(contentFragment.textContent).toBe("Hello, World!"); + }); +}); diff --git a/frontend/text-editor/editor/content/dom/Element.js b/frontend/text-editor/editor/content/dom/Element.js new file mode 100644 index 000000000..d11880146 --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Element.js @@ -0,0 +1,98 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { setStyles } from "./Style.js"; + +/** + * @typedef {Object} CreateElementOptions + * @property {Object.} [attributes] + * @property {Object.} [data] + * @property {Object.|CSSStyleDeclaration} [styles] + * @property {Array<[string,?string]>} [allowedStyles] + * @property {Array|Node} [children] + */ + +/** + * Creates a new random id to identify content nodes. + * + * @returns {string} + */ +export function createRandomId() { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36); +} + +/** + * Creates a new HTML element. + * + * @param {string} tag + * @param {*} options + * @returns {HTMLElement} + */ +export function createElement(tag, options) { + const element = document.createElement(tag); + if (options?.attributes) { + Object.entries(options.attributes).forEach(([name, value]) => + element.setAttribute(name, value) + ); + } + if (options?.data) { + Object.entries(options.data).forEach( + ([name, value]) => (element.dataset[name] = value) + ); + } + if (options?.styles && options?.allowedStyles) { + setStyles(element, options.allowedStyles, options.styles); + } + if (options?.children) { + if (Array.isArray(options.children)) { + element.append(...options.children); + } else { + element.appendChild(options.children); + } + } + return element; +} + +/** + * Returns true if passed node is an element. + * + * @param {Node} element + * @param {string} nodeName + * @returns {boolean} + */ +export function isElement(element, nodeName) { + return ( + element.nodeType === Node.ELEMENT_NODE && + element.nodeName === nodeName.toUpperCase() + ); +} + +/** + * Returns true if the specified offset is at the start of the element. + * + * @param {Node} node + * @param {number} offset + * @returns {boolean} + */ +export function isOffsetAtStart(node, offset) { + return offset === 0; +} + +/** + * Returns true if the specified offset is at the end of the element. + * + * @param {Node} node + * @param {number} offset + * @returns {boolean} + */ +export function isOffsetAtEnd(node, offset) { + if (node.nodeType === Node.TEXT_NODE) { + return node.nodeValue.length === offset; + } + return true; +} diff --git a/frontend/text-editor/editor/content/dom/Element.test.js b/frontend/text-editor/editor/content/dom/Element.test.js new file mode 100644 index 000000000..4e91dc428 --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Element.test.js @@ -0,0 +1,108 @@ +import { describe, test, expect } from "vitest"; +import { createElement, isElement, createRandomId, isOffsetAtStart, isOffsetAtEnd } from "./Element.js"; + +/* @vitest-environment jsdom */ +describe("Element", () => { + test("createRandomId should create a new random id", () => { + const randomId = createRandomId(); + expect(typeof randomId).toBe('string'); + expect(randomId.length).toBeGreaterThan(0); + expect(randomId.length).toBeLessThan(12); + }); + + test("createElement should create a new element", () => { + const element = createElement("div"); + expect(element.nodeType).toBe(Node.ELEMENT_NODE); + expect(element.nodeName).toBe("DIV"); + }); + + test("createElement should create a new element with attributes", () => { + const element = createElement("div", { + attributes: { + "aria-multiline": true, + "role": "textbox" + } + }); + expect(element.ariaMultiLine).toBe("true"); + expect(element.role).toBe("textbox"); + }); + + test("createElement should create a new element with data- properties", () => { + const element = createElement("div", { + data: { + itype: "root" + } + }); + expect(element.dataset.itype).toBe("root"); + }); + + test("createElement should create a new element with styles from an object", () => { + const element = createElement("div", { + styles: { + "text-decoration": "underline", + }, + allowedStyles: [["text-decoration"]] + }); + expect(element.style.textDecoration).toBe("underline"); + }); + + test("createElement should create a new element with a child", () => { + const element = createElement("div", { + children: new Text("Hello, World!") + }); + expect(element.textContent).toBe("Hello, World!"); + }); + + test("createElement should create a new element with children", () => { + const element = createElement("div", { + children: [ + createElement("div", { + children: [ + createElement("div", { + children: new Text("Hello, World!") + }) + ] + }) + ], + }); + expect(element.textContent).toBe("Hello, World!"); + expect(element.firstChild.nodeType).toBe(Node.ELEMENT_NODE); + expect(element.firstChild.firstChild.nodeType).toBe(Node.ELEMENT_NODE); + expect(element.firstChild.firstChild.firstChild.nodeType).toBe(Node.TEXT_NODE); + }); + + test("isElement returns true if the passed element is the expected element", () => { + const br = createElement("br"); + expect(isElement(br, "br")).toBe(true); + const div = createElement("div"); + expect(isElement(div, "div")).toBe(true); + const text = new Text("Hello, World!"); + expect(isElement(text, "text")).toBe(false); + }); + + test("isOffsetAtStart should return true when offset is 0", () => { + const element = createElement('span', { + children: new Text("Hello") + }) + expect(isOffsetAtStart(element, 0)).toBe(true); + }); + + test("isOffsetAtEnd should return true when offset is the length of the text content", () => { + const element = createElement("span", { + children: new Text("Hello"), + }); + expect(isOffsetAtEnd(element, 5)).toBe(true); + }); + + test("isOffsetAtEnd should return true when the node is a Text and offset is the length of the node", () => { + const element = new Text("Hello"); + expect(isOffsetAtEnd(element, 5)).toBe(true); + }); + + test("isOffsetAtEnd should return true when node is an element", () => { + const element = createElement("span", { + children: createElement("br"), + }); + expect(isOffsetAtEnd(element, 5)).toBe(true); + }); +}); diff --git a/frontend/text-editor/editor/content/dom/Inline.js b/frontend/text-editor/editor/content/dom/Inline.js new file mode 100644 index 000000000..fbef9d2d0 --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Inline.js @@ -0,0 +1,272 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { + createElement, + isElement, + isOffsetAtStart, + isOffsetAtEnd, +} from "./Element.js"; +import { createLineBreak, isLineBreak } from "./LineBreak.js"; +import { setStyles, mergeStyles } from "./Style.js"; +import { createRandomId } from "./Element.js"; + +export const TAG = "SPAN"; +export const TYPE = "inline"; +export const QUERY = `[data-itype="${TYPE}"]`; +export const STYLES = [ + ["--typography-ref-id"], + ["--typography-ref-file"], + ["--font-id"], + ["--font-variant-id"], + ["--fills"], + ["font-variant"], + ["font-family"], + ["font-size", "px"], + ["font-weight"], + ["font-style"], + ["line-height"], + ["letter-spacing", "px"], + ["text-decoration"], + ["text-transform"], +]; + +/** + * Returns true if passed node is an inline. + * + * @param {Node} node + * @returns {boolean} + */ +export function isInline(node) { + if (!node) return false; + if (!isElement(node, TAG)) return false; + if (node.dataset.itype !== TYPE) return false; + return true; +} + +/** + * Returns true if the passed node "behaves" like an + * inline. + * + * @param {Node} element + * @returns {boolean} + */ +export function isLikeInline(element) { + return element + ? [ + "A", + "ABBR", + "ACRONYM", + "B", + "BDO", + "BIG", + "BR", + "BUTTON", + "CITE", + "CODE", + "DFN", + "EM", + "I", + "IMG", + "INPUT", + "KBD", + "LABEL", + "MAP", + "OBJECT", + "OUTPUT", + "Q", + "SAMP", + "SCRIPT", + "SELECT", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "TEXTAREA", + "TIME", + "TT", + "VAR", + ].includes(element.nodeName) + : false; +} + +/** + * Creates a new Inline + * + * @param {Text|HTMLBRElement} text + * @param {Object.|CSSStyleDeclaration} styles + * @param {Object.} [attrs] + * @returns {HTMLSpanElement} + */ +export function createInline(textOrLineBreak, styles, attrs) { + if ( + !(textOrLineBreak instanceof HTMLBRElement) && + !(textOrLineBreak instanceof Text) + ) { + throw new TypeError("Invalid inline child"); + } + if (textOrLineBreak instanceof Text + && textOrLineBreak.nodeValue.length === 0) { + console.trace("nodeValue", textOrLineBreak.nodeValue) + throw new TypeError("Invalid inline child, cannot be an empty text"); + } + return createElement(TAG, { + attributes: { id: createRandomId(), ...attrs }, + data: { itype: TYPE }, + styles: styles, + allowedStyles: STYLES, + children: textOrLineBreak, + }); +} + +/** + * Creates a new inline from an older inline. This only + * merges styles from the older inline to the new inline. + * + * @param {HTMLSpanElement} inline + * @param {Object.} textOrLineBreak + * @param {Object.|CSSStyleDeclaration} styles + * @param {Object.} [attrs] + * @returns {HTMLSpanElement} + */ +export function createInlineFrom(inline, textOrLineBreak, styles, attrs) { + return createInline( + textOrLineBreak, + mergeStyles(STYLES, inline.style, styles), + attrs + ); +} + +/** + * Creates a new empty inline. + * + * @param {Object.|CSSStyleDeclaration} styles + * @returns {HTMLSpanElement} + */ +export function createEmptyInline(styles) { + return createInline(createLineBreak(), styles); +} + +/** + * Sets the inline styles. + * + * @param {HTMLSpanElement} element + * @param {Object.|CSSStyleDeclaration} styles + * @returns {HTMLSpanElement} + */ +export function setInlineStyles(element, styles) { + return setStyles(element, STYLES, styles); +} + +/** + * Gets the closest inline from a node. + * + * @param {Node} node + * @returns {HTMLElement|null} + */ +export function getInline(node) { + if (!node) return null; // FIXME: Should throw? + if (isInline(node)) return node; + if (node.nodeType === Node.TEXT_NODE) { + const inline = node?.parentElement; + if (!inline) return null; + if (!isInline(inline)) return null; + return inline; + } + return node.closest(QUERY); +} + +/** + * Returns true if we are at the start offset + * of an inline. + * + * NOTE: Only the first inline returns this as true + * + * @param {TextNode|HTMLBRElement} node + * @param {number} offset + * @returns {boolean} + */ +export function isInlineStart(node, offset) { + const inline = getInline(node); + if (!inline) return false; + return isOffsetAtStart(inline, offset); +} + +/** + * Returns true if we are at the end offset + * of an inline. + * + * @param {TextNode|HTMLBRElement} node + * @param {number} offset + * @returns {boolean} + */ +export function isInlineEnd(node, offset) { + const inline = getInline(node); + if (!inline) return false; + return isOffsetAtEnd(inline.firstChild, offset); +} + +/** + * Splits an inline. + * + * @param {HTMLSpanElement} inline + * @param {number} offset + */ +export function splitInline(inline, offset) { + const textNode = inline.firstChild; + const style = inline.style; + const newTextNode = textNode.splitText(offset); + return createInline(newTextNode, style); +} + +/** + * Returns all the inlines of a paragraph starting at + * the specified inline. + * + * @param {HTMLSpanElement} startInline + * @returns {Array} + */ +export function getInlinesFrom(startInline) { + const inlines = []; + let currentInline = startInline; + let index = 0; + while (currentInline) { + if (index > 0) inlines.push(currentInline); + currentInline = currentInline.nextElementSibling; + index++; + } + return inlines; +} + +/** + * Returns the length of an inline. + * + * @param {HTMLElement} inline + * @returns {number} + */ +export function getInlineLength(inline) { + if (!isInline(inline)) throw new Error("Invalid inline"); + if (isLineBreak(inline.firstChild)) return 0; + return inline.firstChild.nodeValue.length; +} + +/** + * Merges two inlines. + * + * @param {HTMLSpanElement} a + * @param {HTMLSpanElement} b + * @returns {HTMLSpanElement} + */ +export function mergeInlines(a, b) { + a.append(...b.childNodes); + b.remove(); + // We need to normalize Text nodes. + a.normalize(); + return a; +} diff --git a/frontend/text-editor/editor/content/dom/Inline.test.js b/frontend/text-editor/editor/content/dom/Inline.test.js new file mode 100644 index 000000000..98272345a --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Inline.test.js @@ -0,0 +1,111 @@ +import { describe, test, expect } from "vitest"; +import { createEmptyInline, createInline, getInline, getInlineLength, isInline, isInlineEnd, isInlineStart, isLikeInline, splitInline, TAG, TYPE } from "./Inline.js"; +import { createLineBreak } from "./LineBreak.js"; + +/* @vitest-environment jsdom */ +describe("Inline", () => { + test("createInline should throw when passed an invalid child", () => { + expect(() => createInline("Hello, World!")).toThrowError( + "Invalid inline child" + ); + }); + + test("createInline creates a new inline element with a
inside", () => { + const inline = createInline(createLineBreak()); + expect(inline).toBeInstanceOf(HTMLSpanElement); + expect(inline.dataset.itype).toBe(TYPE); + expect(inline.nodeName).toBe(TAG); + expect(inline.textContent).toBe(""); + expect(inline.firstChild).toBeInstanceOf(HTMLBRElement); + }); + + test("createInline creates a new inline element with a text inside", () => { + const inline = createInline(new Text("Hello, World!")); + expect(inline).toBeInstanceOf(HTMLSpanElement); + expect(inline.dataset.itype).toBe(TYPE); + expect(inline.nodeName).toBe(TAG); + expect(inline.textContent).toBe("Hello, World!"); + expect(inline.firstChild).toBeInstanceOf(Text); + }); + + test("createEmptyInline creates a new empty inline element with a
inside", () => { + const emptyInline = createEmptyInline(); + expect(emptyInline).toBeInstanceOf(HTMLSpanElement); + expect(emptyInline.dataset.itype).toBe(TYPE); + expect(emptyInline.nodeName).toBe(TAG); + expect(emptyInline.textContent).toBe(""); + expect(emptyInline.firstChild).toBeInstanceOf(HTMLBRElement); + }); + + test("isInline should return true on elements that are inlines", () => { + const inline = createInline(new Text("Hello, World!")); + expect(isInline(inline)).toBe(true); + const a = document.createElement("a"); + expect(isInline(a)).toBe(false); + const b = null; + expect(isInline(b)).toBe(false); + const c = document.createElement('span'); + expect(isInline(c)).toBe(false); + }); + + test("isLikeInline should return true on elements that have inline behavior by default", () => { + expect(isLikeInline(Infinity)).toBe(false); + expect(isLikeInline(null)).toBe(false); + expect(isLikeInline(document.createElement("A"))).toBe(true); + }); + + // FIXME: Should throw? + test("isInlineStart returns false when passed node is not an inline", () => { + const inline = document.createElement("div"); + expect(isInlineStart(inline, 0)).toBe(false); + expect(isInlineStart(inline, "Hello, World!".length)).toBe(false); + }); + + test("isInlineStart returns if we're at the start of an inline", () => { + const inline = createInline(new Text("Hello, World!")); + expect(isInlineStart(inline, 0)).toBe(true); + expect(isInlineStart(inline, "Hello, World!".length)).toBe(false); + }); + + // FIXME: Should throw? + test("isInlineEnd returns false when passed node is not an inline", () => { + const inline = document.createElement("div"); + expect(isInlineEnd(inline, 0)).toBe(false); + expect(isInlineEnd(inline, "Hello, World!".length)).toBe(false); + }); + + test("isInlineEnd returns if we're in the end of an inline", () => { + const inline = createInline(new Text("Hello, World!")); + expect(isInlineEnd(inline, 0)).toBe(false); + expect(isInlineEnd(inline, "Hello, World!".length)).toBe(true); + }); + + test("getInline ", () => { + expect(getInline(null)).toBe(null); + }) + + test("getInlineLength throws when the passed node is not an inline", () => { + const inline = document.createElement('div'); + expect(() => getInlineLength(inline)).toThrowError('Invalid inline'); + }); + + test("getInlineLength returns the length of the inline content", () => { + const inline = createInline(new Text("Hello, World!")); + expect(getInlineLength(inline)).toBe(13); + }); + + test("getInlineLength should return 0 when the inline content is a
", () => { + const emptyInline = createEmptyInline(); + expect(getInlineLength(emptyInline)).toBe(0); + }); + + test("splitInline returns a new inline from the splitted inline", () => { + const inline = createInline(new Text("Hello, World!")); + const newInline = splitInline(inline, 5); + expect(newInline).toBeInstanceOf(HTMLSpanElement); + expect(newInline.firstChild).toBeInstanceOf(Text); + expect(newInline.textContent).toBe(", World!"); + expect(newInline.dataset.itype).toBe(TYPE); + expect(newInline.nodeName).toBe(TAG); + }); +}); diff --git a/frontend/text-editor/editor/content/dom/LineBreak.js b/frontend/text-editor/editor/content/dom/LineBreak.js new file mode 100644 index 000000000..8ae2a09ae --- /dev/null +++ b/frontend/text-editor/editor/content/dom/LineBreak.js @@ -0,0 +1,28 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +export const TAG = "BR"; + +/** + * Creates a new line break. + * + * @returns {HTMLBRElement} + */ +export function createLineBreak() { + return document.createElement(TAG); +} + +/** + * Returns true if the passed node is a line break. + * + * @param {Node} node + * @returns {boolean} + */ +export function isLineBreak(node) { + return node && node.nodeType === Node.ELEMENT_NODE && node.nodeName === TAG; +} diff --git a/frontend/text-editor/editor/content/dom/LineBreak.test.js b/frontend/text-editor/editor/content/dom/LineBreak.test.js new file mode 100644 index 000000000..f9faeeeb5 --- /dev/null +++ b/frontend/text-editor/editor/content/dom/LineBreak.test.js @@ -0,0 +1,11 @@ +import { describe, expect, test } from 'vitest'; +import { createLineBreak } from './LineBreak.js'; + +/* @vitest-environment jsdom */ +describe('LineBreak', () => { + test("createLineBreak should return a
element", () => { + const br = createLineBreak(); + expect(br.nodeType).toBe(Node.ELEMENT_NODE); + expect(br.nodeName).toBe('BR'); + }) +}); diff --git a/frontend/text-editor/editor/content/dom/Paragraph.js b/frontend/text-editor/editor/content/dom/Paragraph.js new file mode 100644 index 000000000..09ce69ead --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Paragraph.js @@ -0,0 +1,259 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { + createElement, + isElement, + isOffsetAtStart, + isOffsetAtEnd, +} from "./Element.js"; +import { + isInline, + isLikeInline, + getInline, + getInlinesFrom, + createInline, + createEmptyInline, + isInlineEnd, + splitInline, +} from "./Inline.js"; +import { createLineBreak, isLineBreak } from "./LineBreak.js"; +import { setStyles } from "./Style.js"; +import { createRandomId } from "./Element.js"; +import { isEmptyTextNode, isTextNode } from './TextNode.js'; + +export const TAG = "DIV"; +export const TYPE = "paragraph"; +export const QUERY = `[data-itype="${TYPE}"]`; +export const STYLES = [ + ["--typography-ref-id"], + ["--typography-ref-file"], + ["--font-id"], + ["--font-variant-id"], + ["--fills"], + ["font-variant"], + ["font-family"], + ["font-size", "px"], + ["font-weight"], + ["font-style"], + ["line-height"], + ["letter-spacing", "px"], + ["text-decoration"], + ["text-transform"], + ["text-align"], + ["direction"] +]; + +/** + * FIXME: This is a fix for Chrome that removes the + * current inline when the last character is deleted + * in `insertCompositionText`. + * + * @param {*} node + */ +export function fixParagraph(node) { + if (!isParagraph(node) || !isLineBreak(node.firstChild)) { + return; + } + const br = createLineBreak(); + node.replaceChildren( + createInline(br) + ); + return br; +} + +/** + * Returns true if the passed node behaves like a paragraph. + * + * NOTE: This is mainly used in paste operations. Every element node + * it's going to be treated as paragraph it + * + * @param {Node} element + * @returns {boolean} + */ +export function isLikeParagraph(element) { + return !isLikeInline(element); +} + +/** + * Returns true if we have an empty paragraph. + * + * @param {Node} element + * @returns {boolean} + */ +export function isEmptyParagraph(element) { + if (!isParagraph(element)) throw new TypeError("Invalid paragraph"); + const inline = element.firstChild; + if (!isInline(inline)) throw new TypeError("Invalid inline"); + return isLineBreak(inline.firstChild); +} + +/** + * Returns true if passed node is a paragraph. + * + * @param {Node} node + * @returns {boolean} + */ +export function isParagraph(node) { + if (!node) return false; + if (!isElement(node, TAG)) return false; + if (node.dataset.itype !== TYPE) return false; + return true; +} + +/** + * Creates a new paragraph. + * + * @param {Array} inlines + * @param {Object.|CSSStyleDeclaration} styles + * @param {Object.} [attrs] + * @returns {HTMLDivElement} + */ +export function createParagraph(inlines, styles, attrs) { + if (inlines && (!Array.isArray(inlines) || !inlines.every(isInline))) + throw new TypeError("Invalid paragraph children"); + return createElement(TAG, { + attributes: { id: createRandomId(), ...attrs }, + data: { itype: TYPE }, + styles: styles, + allowedStyles: STYLES, + children: inlines, + }); +} + +/** + * Returns a new empty paragraph + * + * @param {Object.} styles + * @returns {HTMLDivElement} + */ +export function createEmptyParagraph(styles) { + return createParagraph([ + createEmptyInline(styles) + ], styles); +} + +/** + * Sets the paragraph styles. + * + * @param {HTMLDivElement} element + * @param {Object.|CSSStyleDeclaration} styles + * @returns {HTMLDivElement} + */ +export function setParagraphStyles(element, styles) { + return setStyles(element, STYLES, styles); +} + +/** + * Gets the closest paragraph from a node. + * + * @param {Text|HTMLBRElement} node + * @returns {HTMLElement|null} + */ +export function getParagraph(node) { + if (!node) return null; + if (isParagraph(node)) return node; + if (node.nodeType === Node.TEXT_NODE + || isLineBreak(node)) { + const paragraph = node?.parentElement?.parentElement; + if (!paragraph) { + return null; + } + if (!isParagraph(paragraph)) { + return null; + } + return paragraph; + } + return node.closest(QUERY); +} + +/** + * Returns if the specified node and offset represents + * the start of the paragraph. + * + * @param {Text|HTMLBRElement} node + * @param {number} offset + * @returns {boolean} + */ +export function isParagraphStart(node, offset) { + const paragraph = getParagraph(node); + if (!paragraph) throw new Error("Can't find the paragraph"); + const inline = getInline(node); + if (!inline) throw new Error("Can't find the inline"); + return ( + paragraph.firstElementChild === inline && + isOffsetAtStart(inline.firstChild, offset) + ); +} + +/** + * Returns if the specified node and offset represents + * the end of the paragraph. + * + * @param {Text|HTMLBRElement} node + * @param {number} offset + * @returns {boolean} + */ +export function isParagraphEnd(node, offset) { + const paragraph = getParagraph(node); + if (!paragraph) throw new Error("Cannot find the paragraph"); + const inline = getInline(node); + if (!inline) throw new Error("Cannot find the inline"); + return ( + paragraph.lastElementChild === inline && + isOffsetAtEnd(inline.firstChild, offset) + ); +} + +/** + * Splits a paragraph. + * + * @param {HTMLDivElement} paragraph + * @param {HTMLSpanElement} inline + * @param {number} offset + */ +export function splitParagraph(paragraph, inline, offset) { + const style = paragraph.style; + if (isInlineEnd(inline, offset)) { + const newParagraph = createParagraph(getInlinesFrom(inline), style); + return newParagraph; + } + const newInline = splitInline(inline, offset); + const newParagraph = createParagraph([newInline], style); + return newParagraph; +} + +/** + * Splits a paragraph at a specified child node index + * + * @param {HTMLDivElement} paragraph + * @param {number} startIndex + */ +export function splitParagraphAtNode(paragraph, startIndex) { + const style = paragraph.style; + const newParagraph = createParagraph(null, style); + const newInlines = []; + for (let index = startIndex; index < paragraph.children.length; index++) { + newInlines.push(paragraph.children.item(index)); + } + newParagraph.append(...newInlines); + return newParagraph; +} + +/** + * Merges two paragraphs. + * + * @param {HTMLDivElement} a + * @param {HTMLDivElement} b + * @returns {HTMLDivElement} + */ +export function mergeParagraphs(a, b) { + a.append(...b.children); + b.remove(); + return a; +} diff --git a/frontend/text-editor/editor/content/dom/Paragraph.test.js b/frontend/text-editor/editor/content/dom/Paragraph.test.js new file mode 100644 index 000000000..5caadc0b0 --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Paragraph.test.js @@ -0,0 +1,172 @@ +import { describe, test, expect } from "vitest"; +import { + createEmptyParagraph, + createParagraph, + getParagraph, + isLikeParagraph, + isParagraph, + isParagraphStart, + isParagraphEnd, + TAG, + TYPE, + splitParagraph, + splitParagraphAtNode, + isEmptyParagraph, +} from "./Paragraph.js"; +import { createInline, isInline } from "./Inline.js"; + +/* @vitest-environment jsdom */ +describe("Paragraph", () => { + test("createParagraph should throw when passed invalid children", () => { + expect(() => createParagraph([ + "Whatever" + ])).toThrowError("Invalid paragraph children"); + }); + + test("createEmptyParagraph should create a new empty paragraph", () => { + const emptyParagraph = createEmptyParagraph(); + expect(emptyParagraph).toBeInstanceOf(HTMLDivElement); + expect(emptyParagraph.nodeName).toBe(TAG); + expect(emptyParagraph.dataset.itype).toBe(TYPE); + expect(isInline(emptyParagraph.firstChild)).toBe(true); + }); + + test("isParagraph should return true when the passed node is a paragraph", () => { + expect(isParagraph(null)).toBe(false); + expect(isParagraph(document.createElement('div'))).toBe(false); + expect(isParagraph(document.createElement('h1'))).toBe(false); + expect(isParagraph(createEmptyParagraph())).toBe(true); + expect(isParagraph(createParagraph([ + createInline(new Text('Hello, World!')) + ]))).toBe(true); + }); + + test("isLikeParagraph should return true when node looks like a paragraph", () => { + const p = document.createElement('p'); + expect(isLikeParagraph(p)).toBe(true); + const div = document.createElement('div'); + expect(isLikeParagraph(div)).toBe(true); + const h1 = document.createElement('h1'); + expect(isLikeParagraph(h1)).toBe(true); + const h2 = document.createElement('h2'); + expect(isLikeParagraph(h2)).toBe(true); + const h3 = document.createElement('h3'); + expect(isLikeParagraph(h3)).toBe(true); + const h4 = document.createElement('h4'); + expect(isLikeParagraph(h4)).toBe(true); + const h5 = document.createElement('h5'); + expect(isLikeParagraph(h5)).toBe(true); + const h6 = document.createElement('h6'); + expect(isLikeParagraph(h6)).toBe(true); + }); + + test("getParagraph should return the closest paragraph of the passed node", () => { + const text = new Text("Hello, World!"); + const inline = createInline(text); + const paragraph = createParagraph([inline]); + expect(getParagraph(text)).toBe(paragraph); + }); + + test("getParagraph should return null if there aren't closer paragraph nodes", () => { + const text = new Text("Hello, World!"); + const whatever = document.createElement('div'); + whatever.appendChild(text); + expect(getParagraph(text)).toBe(null); + }); + + test("isParagraphStart should return true on an empty paragraph", () => { + const paragraph = createEmptyParagraph(); + expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true); + }); + + test("isParagraphStart should return true on a paragraph", () => { + const paragraph = createParagraph([ + createInline(new Text("Hello, World!")) + ]); + expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true); + }); + + test("isParagraphEnd should return true on an empty paragraph", () => { + const paragraph = createEmptyParagraph(); + expect(isParagraphEnd(paragraph.firstChild.firstChild, 0)).toBe(true); + }); + + test("isParagraphEnd should return true on a paragraph", () => { + const paragraph = createParagraph([ + createInline(new Text("Hello, World!")), + ]); + expect(isParagraphEnd(paragraph.firstChild.firstChild, 13)).toBe(true); + }); + + test("splitParagraph should split a paragraph", () => { + const inline = createInline(new Text("Hello, World!")); + const paragraph = createParagraph([inline]); + const newParagraph = splitParagraph(paragraph, inline, 6); + expect(newParagraph).toBeInstanceOf(HTMLDivElement); + expect(newParagraph.nodeName).toBe(TAG); + expect(newParagraph.dataset.itype).toBe(TYPE); + expect(newParagraph.firstElementChild.textContent).toBe(" World!"); + }); + + test("splitParagraphAtNode should split a paragraph at a specified node", () => { + const helloInline = createInline(new Text("Hello, ")); + const worldInline = createInline(new Text("World")); + const exclInline = createInline(new Text("!")); + const paragraph = createParagraph([helloInline, worldInline, exclInline]); + const newParagraph = splitParagraphAtNode(paragraph, 1); + expect(newParagraph).toBeInstanceOf(HTMLDivElement); + expect(newParagraph.nodeName).toBe(TAG); + expect(newParagraph.dataset.itype).toBe(TYPE); + expect(newParagraph.children.length).toBe(2); + expect(newParagraph.textContent).toBe("World!"); + }); + + test("isLikeParagraph should return true if the element it's not an inline element", () => { + const span = document.createElement("span"); + const a = document.createElement("a"); + const br = document.createElement("br"); + const i = document.createElement("span"); + const u = document.createElement("span"); + const div = document.createElement("div"); + const blockquote = document.createElement("blockquote"); + const table = document.createElement("table"); + expect(isLikeParagraph(span)).toBe(false); + expect(isLikeParagraph(a)).toBe(false); + expect(isLikeParagraph(br)).toBe(false); + expect(isLikeParagraph(i)).toBe(false); + expect(isLikeParagraph(u)).toBe(false); + expect(isLikeParagraph(div)).toBe(true); + expect(isLikeParagraph(blockquote)).toBe(true); + expect(isLikeParagraph(table)).toBe(true); + }); + + test("isEmptyParagraph should return true if the paragraph is empty", () => { + expect(() => { + isEmptyParagraph(document.createElement("svg")); + }).toThrowError("Invalid paragraph"); + expect(() => { + const paragraph = document.createElement("div"); + paragraph.dataset.itype = "paragraph"; + paragraph.appendChild(document.createElement("svg")); + isEmptyParagraph(paragraph); + }).toThrowError("Invalid inline"); + + const lineBreak = document.createElement("br"); + const emptyInline = document.createElement("span"); + emptyInline.dataset.itype = "inline"; + emptyInline.appendChild(lineBreak); + const emptyParagraph = document.createElement("div"); + emptyParagraph.dataset.itype = "paragraph"; + emptyParagraph.appendChild(emptyInline); + expect(isEmptyParagraph(emptyParagraph)).toBe(true); + + const nonEmptyInline = document.createElement("span"); + nonEmptyInline.dataset.itype = "inline"; + nonEmptyInline.appendChild(new Text('Not empty!')); + const nonEmptyParagraph = document.createElement("div"); + nonEmptyParagraph.dataset.itype = "paragraph"; + nonEmptyParagraph.appendChild(nonEmptyInline); + expect(isEmptyParagraph(nonEmptyParagraph)).toBe(false); + + }); +}); diff --git a/frontend/text-editor/editor/content/dom/Root.js b/frontend/text-editor/editor/content/dom/Root.js new file mode 100644 index 000000000..4eeec2070 --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Root.js @@ -0,0 +1,71 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { createElement, isElement } from "./Element.js"; +import { createEmptyParagraph, isParagraph } from "./Paragraph.js"; +import { setStyles } from "./Style.js"; +import { createRandomId } from "./Element.js"; + +export const TAG = "DIV"; +export const TYPE = "root"; +export const QUERY = `[data-itype="${TYPE}"]`; +export const STYLES = [["--vertical-align"]]; + +/** + * Returns true if passed node is a root. + * + * @param {Node} node + * @returns {boolean} + */ +export function isRoot(node) { + if (!node) return false; + if (!isElement(node, TAG)) return false; + if (node.dataset.itype !== TYPE) return false; + return true; +} + +/** + * Create a new root element + * + * @param {Array} paragraphs + * @param {Object.|CSSStyleDeclaration} styles, + * @param {Object.} [attrs] + * @returns {HTMLDivElement} + */ +export function createRoot(paragraphs, styles, attrs) { + if (!Array.isArray(paragraphs) || !paragraphs.every(isParagraph)) + throw new TypeError("Invalid root children"); + + return createElement(TAG, { + attributes: { id: createRandomId(), ...attrs }, + data: { itype: TYPE }, + styles: styles, + allowedStyles: STYLES, + children: paragraphs, + }); +} + +/** + * Creates a new empty root element + * + * @param {Object.|CSSStyleDeclaration} styles + */ +export function createEmptyRoot(styles) { + return createRoot([createEmptyParagraph(styles)], styles); +} + +/** + * Sets the root styles. + * + * @param {HTMLDivElement} element + * @param {Object.|CSSStyleDeclaration} styles + * @returns {HTMLDivElement} + */ +export function setRootStyles(element, styles) { + return setStyles(element, STYLES, styles); +} diff --git a/frontend/text-editor/editor/content/dom/Root.test.js b/frontend/text-editor/editor/content/dom/Root.test.js new file mode 100644 index 000000000..a2d048bef --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Root.test.js @@ -0,0 +1,33 @@ +import { describe, test, expect } from "vitest"; +import { createEmptyRoot, createRoot, setRootStyles, TAG, TYPE } from './Root.js' + +/* @vitest-environment jsdom */ +describe("Root", () => { + test("createRoot should throw when passed invalid children", () => { + expect(() => createRoot(["Whatever"])).toThrowError( + "Invalid root children" + ); + }); + + test("createEmptyRoot should create a new root with an empty paragraph", () => { + const emptyRoot = createEmptyRoot(); + expect(emptyRoot).toBeInstanceOf(HTMLDivElement); + expect(emptyRoot.nodeName).toBe(TAG); + expect(emptyRoot.dataset.itype).toBe(TYPE); + expect(emptyRoot.firstChild).toBeInstanceOf(HTMLDivElement); + expect(emptyRoot.firstChild.firstChild).toBeInstanceOf(HTMLSpanElement); + expect(emptyRoot.firstChild.firstChild.firstChild).toBeInstanceOf(HTMLBRElement); + }); + + test("setRootStyles should apply only the styles of root to the root", () => { + const emptyRoot = createEmptyRoot(); + setRootStyles(emptyRoot, { + ["--vertical-align"]: "top", + ["font-size"]: "25px" + }); + expect(emptyRoot.style.getPropertyValue("--vertical-align")).toBe("top"); + // We expect this style to be empty because we don't apply it + // to the root. + expect(emptyRoot.style.getPropertyValue("font-size")).toBe(""); + }) +}); diff --git a/frontend/text-editor/editor/content/dom/Style.js b/frontend/text-editor/editor/content/dom/Style.js new file mode 100644 index 000000000..6b563584a --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Style.js @@ -0,0 +1,329 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { getFills } from "./Color.js"; + +const DEFAULT_FONT_SIZE = "16px"; +const DEFAULT_LINE_HEIGHT = "1.2"; + +/** + * Merges two style declarations. `source` -> `target`. + * + * @param {CSSStyleDeclaration} target + * @param {CSSStyleDeclaration} source + * @returns {CSSStyleDeclaration} + */ +export function mergeStyleDeclarations(target, source) { + // This is better but it doesn't work in JSDOM + // for (const styleName of source) { + for (let index = 0; index < source.length; index++) { + const styleName = source.item(index); + target.setProperty(styleName, source.getPropertyValue(styleName)); + } + return target +} + +/** + * Resets the properties of a style declaration. + * + * @param {CSSStyleDeclaration} styleDeclaration + * @returns {CSSStyleDeclaration} + */ +function resetStyleDeclaration(styleDeclaration) { + for (let index = 0; index < styleDeclaration.length; index++) { + const styleName = styleDeclaration.item(index); + styleDeclaration.removeProperty(styleName); + } + return styleDeclaration +} + +/** + * An inert element that only keeps the style + * declaration used for merging other styleDeclarations. + * + * @type {HTMLDivElement|null} + */ +let inertElement = null + +/** + * Resets the style declaration of the inert + * element. + */ +function resetInertElement() { + if (!inertElement) throw new Error('Invalid inert element'); + resetStyleDeclaration(inertElement.style); + return inertElement; +} + +/** + * Returns an instance of a
element used + * to keep style declarations. + * + * @returns {HTMLDivElement} + */ +function getInertElement() { + if (!inertElement) { + inertElement = document.createElement("div"); + return inertElement; + } + resetInertElement(); + return inertElement; +} + +/** + * Computes the styles of an element the same way `window.getComputedStyle` does. + * + * @param {Element} element + * @returns {CSSStyleDeclaration} + */ +export function getComputedStyle(element) { + const inertElement = getInertElement(); + let currentElement = element; + while (currentElement) { + // This is better but it doesn't work in JSDOM. + // for (const styleName of currentElement.style) { + for (let index = 0; index < currentElement.style.length; index++) { + const styleName = currentElement.style.item(index); + const currentValue = inertElement.style.getPropertyValue(styleName); + if (currentValue) { + const priority = currentElement.style.getPropertyPriority(styleName); + if (priority === "important") { + const newValue = currentElement.style.getPropertyValue(styleName); + inertElement.style.setProperty(styleName, newValue); + } + } else { + inertElement.style.setProperty( + styleName, + currentElement.style.getPropertyValue(styleName) + ); + } + } + currentElement = currentElement.parentElement; + } + return inertElement.style; +} + +/** + * Normalizes style declaration. + * + * TODO: I think that this also needs to remove some "conflicting" + * CSS properties like `font-family` or some CSS variables. + * + * @param {Node} node + * @param {CSSStyleDeclaration} styleDefaults + * @returns {CSSStyleDeclaration} + */ +export function normalizeStyles(node, styleDefaults) { + const styleDeclaration = mergeStyleDeclarations( + styleDefaults, + getComputedStyle(node.parentElement) + ); + // If there's a color property, we should convert it to + // a --fills CSS variable property. + const fills = styleDeclaration.getPropertyValue("--fills"); + const color = styleDeclaration.getPropertyValue("color"); + if (color && !fills) { + styleDeclaration.removeProperty("color"); + styleDeclaration.setProperty("--fills", getFills(color)); + } + // If there's a font-family property and not a --font-id, then + // we remove the font-family because it will not work. + const fontFamily = styleDeclaration.getPropertyValue("font-family"); + const fontId = styleDeclaration.getPropertyPriority("--font-id"); + if (fontFamily && !fontId) { + styleDeclaration.removeProperty("font-family"); + } + + const fontSize = styleDeclaration.getPropertyValue("font-size"); + if (!fontSize || fontSize === "0px") { + styleDeclaration.setProperty("font-size", DEFAULT_FONT_SIZE); + } + + const lineHeight = styleDeclaration.getPropertyValue("line-height"); + if (!lineHeight || lineHeight === "") { + styleDeclaration.setProperty("line-height", DEFAULT_LINE_HEIGHT); + } + return styleDeclaration +} +/** + * Sets a single style property value of an element. + * + * @param {HTMLElement} element + * @param {string} styleName + * @param {*} styleValue + * @param {string} [styleUnit] + * @returns {HTMLElement} + */ +export function setStyle(element, styleName, styleValue, styleUnit) { + if ( + styleName.startsWith("--") && + typeof styleValue !== "string" && + typeof styleValue !== "number" + ) { + if (styleName === "--fills" && styleValue === null) debugger; + element.style.setProperty(styleName, JSON.stringify(styleValue)); + } else { + element.style.setProperty(styleName, styleValue + (styleUnit ?? "")); + } + return element; +} + +/** + * Returns the value of a style from a declaration. + * + * @param {CSSStyleDeclaration} style + * @param {string} styleName + * @param {string|undefined} [styleUnit] + * @returns {*} + */ +export function getStyleFromDeclaration(style, styleName, styleUnit) { + if (styleName.startsWith("--")) { + return style.getPropertyValue(styleName); + } + const styleValue = style.getPropertyValue(styleName); + if (styleValue.endsWith(styleUnit)) { + return styleValue.slice(0, -styleUnit.length); + } + return styleValue; +} + +/** + * Returns the value of a style. + * + * @param {HTMLElement} element + * @param {string} styleName + * @param {string|undefined} [styleUnit] + * @returns {*} + */ +export function getStyle(element, styleName, styleUnit) { + return getStyleFromDeclaration(element.style, styleName, styleUnit); +} + +/** + * Sets the styles of an element using an object and a list of + * allowed styles. + * + * @param {HTMLElement} element + * @param {Array<[string,?string]>} allowedStyles + * @param {Object.} styleObject + * @returns {HTMLElement} + */ +export function setStylesFromObject(element, allowedStyles, styleObject) { + for (const [styleName, styleUnit] of allowedStyles) { + if (!(styleName in styleObject)) { + continue; + } + const styleValue = styleObject[styleName]; + if (styleValue) { + setStyle(element, styleName, styleValue, styleUnit); + } + } + return element; +} + +/** + * Sets the styles of an element using a CSS Style Declaration and a list + * of allowed styles. + * + * @param {HTMLElement} element + * @param {Array<[string,?string]>} allowedStyles + * @param {CSSStyleDeclaration} styleDeclaration + * @returns {HTMLElement} + */ +export function setStylesFromDeclaration( + element, + allowedStyles, + styleDeclaration +) { + for (const [styleName, styleUnit] of allowedStyles) { + const styleValue = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit); + if (styleValue) { + setStyle(element, styleName, styleValue, styleUnit); + } + } + return element; +} + +/** + * Sets the styles of an element using an Object or a CSS Style Declaration and + * a list of allowed styles. + * + * @param {HTMLElement} element + * @param {Array<[string,?string]} allowedStyles + * @param {Object.|CSSStyleDeclaration} styleObjectOrDeclaration + * @returns {HTMLElement} + */ +export function setStyles(element, allowedStyles, styleObjectOrDeclaration) { + if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) { + return setStylesFromDeclaration( + element, + allowedStyles, + styleObjectOrDeclaration + ); + } + return setStylesFromObject(element, allowedStyles, styleObjectOrDeclaration); +} + +/** + * Gets the styles of an element using a list of allowed styles. + * + * @param {HTMLElement} element + * @param {Array<[string,?string]} allowedStyles + * @returns {Object.} + */ +export function getStyles(element, allowedStyles) { + const styleObject = {}; + for (const [styleName, styleUnit] of allowedStyles) { + const styleValue = getStyle(element, styleName, styleUnit); + if (styleValue) { + styleObject[styleName] = styleValue; + } + } + return styleObject; +} + +/** + * Returns a series of merged styles. + * + * @param {Array<[string,?string]} allowedStyles + * @param {CSSStyleDeclaration} styleDeclaration + * @param {Object.} newStyles + * @returns {Object.} + */ +export function mergeStyles(allowedStyles, styleDeclaration, newStyles) { + const mergedStyles = {}; + for (const [styleName, styleUnit] of allowedStyles) { + if (styleName in newStyles) { + mergedStyles[styleName] = newStyles[styleName]; + } else { + mergedStyles[styleName] = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit); + } + } + return mergedStyles; +} + +/** + * Returns true if the specified style declaration has a display block. + * + * @param {CSSStyleDeclaration} style + * @returns {boolean} + */ +export function isDisplayBlock(style) { + return style.display === "block"; +} + +/** + * Returns true if the specified style declaration has a display inline + * or inline-block. + * + * @param {CSSStyleDeclaration} style + * @returns {boolean} + */ +export function isDisplayInline(style) { + return style.display === "inline" || style.display === "inline-block"; +} diff --git a/frontend/text-editor/editor/content/dom/Style.test.js b/frontend/text-editor/editor/content/dom/Style.test.js new file mode 100644 index 000000000..0c1e14735 --- /dev/null +++ b/frontend/text-editor/editor/content/dom/Style.test.js @@ -0,0 +1,76 @@ +import { describe, test, expect, vi } from "vitest"; +import { getStyles, isDisplayBlock, isDisplayInline, setStyle, setStyles } from "./Style.js"; + +/* @vitest-environment jsdom */ +describe("Style", () => { + test("setStyle should apply a style to an element", () => { + const element = document.createElement("div"); + setStyle(element, "display", "none"); + expect(element.style.display).toBe("none"); + }); + + test("setStyles should apply multiple styles to an element using an Object", () => { + const element = document.createElement("div"); + setStyles(element, [["display"]], { + "text-decoration": "none", + "font-size": "32px", + display: "none", + }); + expect(element.style.display).toBe("none"); + expect(element.style.fontSize).toBe(""); + expect(element.style.textDecoration).toBe(""); + }); + + test("setStyles should apply multiple styles to an element using a CSSStyleDeclaration", () => { + const a = document.createElement("div"); + setStyles(a, [["display"]], { + display: "none", + }); + expect(a.style.display).toBe("none"); + expect(a.style.fontSize).toBe(""); + expect(a.style.textDecoration).toBe(""); + + const b = document.createElement("div"); + setStyles(b, [["display"]], a.style); + expect(b.style.display).toBe("none"); + expect(b.style.fontSize).toBe(""); + expect(b.style.textDecoration).toBe(""); + }); + + test("getStyles should retrieve a list of allowed styles", () => { + const element = document.createElement("div"); + element.style.display = 'block'; + element.style.textDecoration = 'underline'; + element.style.fontSize = '32px'; + const textDecorationStyles = getStyles(element, [["text-decoration"]]); + expect(textDecorationStyles).toStrictEqual({ + "text-decoration": "underline" + }); + const displayStyles = getStyles(element, [["display"]]); + expect(displayStyles).toStrictEqual({ + "display": "block", + }); + const fontSizeStyles = getStyles(element, [["font-size", "px"]]); + expect(fontSizeStyles).toStrictEqual({ + "font-size": "32", + }); + }); + + test("isDisplayBlock should return true if display is 'block'", () => { + const div = document.createElement("div"); + div.style.display = "block"; + expect(isDisplayBlock(div.style)).toBe(true); + const span = document.createElement("span"); + span.style.display = "inline"; + expect(isDisplayBlock(span)).toBe(false); + }); + + test("isDisplayInline should return true if display is 'inline'", () => { + const span = document.createElement("span"); + span.style.display = "inline"; + expect(isDisplayInline(span.style)).toBe(true); + const div = document.createElement("div"); + div.style.display = "block"; + expect(isDisplayInline(div)).toBe(false); + }); +}); diff --git a/frontend/text-editor/editor/content/dom/TextNode.js b/frontend/text-editor/editor/content/dom/TextNode.js new file mode 100644 index 000000000..fff86dbdf --- /dev/null +++ b/frontend/text-editor/editor/content/dom/TextNode.js @@ -0,0 +1,64 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { isInline } from "./Inline.js"; +import { isLineBreak } from "./LineBreak.js"; +import { isParagraph } from "./Paragraph.js"; +import { isRoot } from "./Root.js"; + +/** + * Returns true if the node is "like" + * text, this means that it is a Text + * node or a
element. + * + * @param {Node} node + * @returns {boolean} + */ +export function isTextNode(node) { + if (!node) throw new TypeError("Invalid text node"); + return node.nodeType === Node.TEXT_NODE + || isLineBreak(node); +} + +/** + * Returns true if the text node is empty. + * + * @param {Node} node + * @returns {boolean} + */ +export function isEmptyTextNode(node) { + return node.nodeType === Node.TEXT_NODE + && node.nodeValue === ""; +} + +/** + * Returns the content length of the + * node. + * + * @param {Node} node + * @returns {number} + */ +export function getTextNodeLength(node) { + if (!node) throw new TypeError("Invalid text node"); + if (isLineBreak(node)) return 0; + return node.nodeValue.length; +} + +/** + * Gets the closest text node. + * + * @param {Node} node + * @returns {Node} + */ +export function getClosestTextNode(node) { + if (isTextNode(node)) return node; + if (isInline(node)) return node.firstChild; + if (isParagraph(node)) return node.firstChild.firstChild; + if (isRoot(node)) return node.firstChild.firstChild.firstChild; + throw new Error("Cannot find a text node"); +} diff --git a/frontend/text-editor/editor/content/dom/TextNode.test.js b/frontend/text-editor/editor/content/dom/TextNode.test.js new file mode 100644 index 000000000..d97e1395f --- /dev/null +++ b/frontend/text-editor/editor/content/dom/TextNode.test.js @@ -0,0 +1,26 @@ +import { describe, test, expect } from 'vitest'; +import { isTextNode, getTextNodeLength } from './TextNode.js'; +import { createLineBreak } from './LineBreak.js'; + +/* @vitest-environment jsdom */ +describe("TextNode", () => { + test("isTextNode should return true when the passed node is a Text", () => { + expect(isTextNode(new Text("Hello, World!"))).toBe(true); + expect(isTextNode(Infinity)).toBe(false); + expect(isTextNode(true)).toBe(false); + expect(isTextNode("hola")).toBe(false); + expect(isTextNode({})).toBe(false); + expect(isTextNode([])).toBe(false); + expect(() => isTextNode(undefined)).toThrowError('Invalid text node'); + expect(() => isTextNode(null)).toThrowError('Invalid text node'); + expect(() => isTextNode(0)).toThrowError('Invalid text node'); + }); + + test("getTextNodeLength should return the length of the text node or 0 if it is a
", () => { + expect(getTextNodeLength(new Text("Hello, World!"))).toBe(13); + expect(getTextNodeLength(createLineBreak())).toBe(0); + expect(() => getTextNodeLength(undefined)).toThrowError('Invalid text node'); + expect(() => getTextNodeLength(null)).toThrowError('Invalid text node'); + expect(() => getTextNodeLength(0)).toThrowError('Invalid text node'); + }); +}); diff --git a/frontend/text-editor/editor/content/dom/TextNodeIterator.js b/frontend/text-editor/editor/content/dom/TextNodeIterator.js new file mode 100644 index 000000000..588a96c53 --- /dev/null +++ b/frontend/text-editor/editor/content/dom/TextNodeIterator.js @@ -0,0 +1,250 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Iterator direction. + * + * @enum {number} + */ +export const TextNodeIteratorDirection = { + FORWARD: 1, + BACKWARD: 0, +}; + +/** + * TextNodeIterator + */ +export class TextNodeIterator { + /** + * Returns if a specific node is a text node. + * + * @param {Node} node + * @returns {boolean} + */ + static isTextNode(node) { + return ( + node.nodeType === Node.TEXT_NODE || + (node.nodeType === Node.ELEMENT_NODE && node.nodeName === "BR") + ); + } + + /** + * Returns if a specific node is a container node. + * + * @param {Node} node + * @returns {boolean} + */ + static isContainerNode(node) { + return node.nodeType === Node.ELEMENT_NODE && node.nodeName !== "BR"; + } + + /** + * Finds a node from an initial node and down the tree. + * + * @param {Node} startNode + * @param {Node} rootNode + * @param {Set} skipNodes + * @param {number} direction + * @returns {Node} + */ + static findDown( + startNode, + rootNode, + skipNodes = new Set(), + direction = TextNodeIteratorDirection.FORWARD + ) { + if (startNode === rootNode) { + return TextNodeIterator.findDown( + direction === TextNodeIteratorDirection.FORWARD + ? startNode.firstChild + : startNode.lastChild, + rootNode, + skipNodes, + direction + ); + } + + // NOTE: This should not use the SafeGuard + // module. + let safeGuard = Date.now(); + let currentNode = startNode; + while (currentNode) { + if (Date.now() - safeGuard >= 1000) { + throw new Error("Iteration timeout"); + } + if (skipNodes.has(currentNode)) { + currentNode = + direction === TextNodeIteratorDirection.FORWARD + ? currentNode.nextSibling + : currentNode.previousSibling; + continue; + } + if (TextNodeIterator.isTextNode(currentNode)) { + return currentNode; + } else if (TextNodeIterator.isContainerNode(currentNode)) { + return TextNodeIterator.findDown( + direction === TextNodeIteratorDirection.FORWARD + ? currentNode.firstChild + : currentNode.lastChild, + rootNode, + skipNodes, + direction + ); + } + currentNode = + direction === TextNodeIteratorDirection.FORWARD + ? currentNode.nextSibling + : currentNode.previousSibling; + } + return null; + } + + /** + * Finds a node from an initial node and up the tree. + * + * @param {Node} startNode + * @param {Node} rootNode + * @param {Set} backTrack + * @param {number} direction + * @returns {Node} + */ + static findUp( + startNode, + rootNode, + backTrack = new Set(), + direction = TextNodeIteratorDirection.FORWARD + ) { + backTrack.add(startNode); + if (TextNodeIterator.isTextNode(startNode)) { + return TextNodeIterator.findUp( + startNode.parentNode, + rootNode, + backTrack, + direction + ); + } else if (TextNodeIterator.isContainerNode(startNode)) { + const found = TextNodeIterator.findDown( + startNode, + rootNode, + backTrack, + direction + ); + if (found) { + return found; + } + if (startNode !== rootNode) { + return TextNodeIterator.findUp( + startNode.parentNode, + rootNode, + backTrack, + direction + ); + } + } + return null; + } + + /** + * This is the root text node. + * + * @type {HTMLElement} + */ + #rootNode = null; + + /** + * This is the current text node. + * + * @type {Text|null} + */ + #currentNode = null; + + /** + * Constructor + * + * @param {HTMLElement} rootNode + */ + constructor(rootNode) { + if (!(rootNode instanceof HTMLElement)) { + throw new TypeError("Invalid root node"); + } + this.#rootNode = rootNode; + this.#currentNode = TextNodeIterator.findDown(rootNode, rootNode); + } + + /** + * Current node we're into. + * + * @type {TextNode|HTMLBRElement} + */ + get currentNode() { + return this.#currentNode; + } + + set currentNode(newCurrentNode) { + const isContained = + (newCurrentNode.compareDocumentPosition(this.#rootNode) & + Node.DOCUMENT_POSITION_CONTAINS) === + Node.DOCUMENT_POSITION_CONTAINS; + if ( + !(newCurrentNode instanceof Node) || + !TextNodeIterator.isTextNode(newCurrentNode) || + !isContained + ) { + throw new TypeError("Invalid new current node"); + } + this.#currentNode = newCurrentNode; + } + + /** + * Returns the next Text node or
element or null if there are. + * + * @returns {Text|HTMLBRElement} + */ + nextNode() { + if (!this.#currentNode) return null; + + const nextNode = TextNodeIterator.findUp( + this.#currentNode, + this.#rootNode, + new Set(), + TextNodeIteratorDirection.FORWARD + ); + + if (!nextNode) { + return null; + } + + this.#currentNode = nextNode; + return this.#currentNode; + } + + /** + * Returns the previous Text node or
element or null. + * + * @returns {Text|HTMLBRElement} + */ + previousNode() { + if (!this.#currentNode) return null; + + const previousNode = TextNodeIterator.findUp( + this.#currentNode, + this.#rootNode, + new Set(), + TextNodeIteratorDirection.BACKWARD + ); + + if (!previousNode) { + return null; + } + + this.#currentNode = previousNode; + return this.#currentNode; + } +} + +export default TextNodeIterator; diff --git a/frontend/text-editor/editor/content/dom/TextNodeIterator.test.js b/frontend/text-editor/editor/content/dom/TextNodeIterator.test.js new file mode 100644 index 000000000..811ffc3dc --- /dev/null +++ b/frontend/text-editor/editor/content/dom/TextNodeIterator.test.js @@ -0,0 +1,70 @@ +import { describe, test, expect } from "vitest"; +import TextNodeIterator from "./TextNodeIterator.js"; +import { createInline } from "./Inline.js"; +import { createParagraph } from "./Paragraph.js"; +import { createRoot } from "./Root.js"; +import { createLineBreak } from "./LineBreak.js"; + +/* @vitest-environment jsdom */ +describe("TextNodeIterator", () => { + test("Create a new TextNodeIterator with an invalid root should throw", () => { + expect(() => new TextNodeIterator(null)).toThrowError("Invalid root node"); + expect(() => new TextNodeIterator(Infinity)).toThrowError( + "Invalid root node" + ); + expect(() => new TextNodeIterator(1)).toThrowError("Invalid root node"); + expect(() => new TextNodeIterator("hola")).toThrowError( + "Invalid root node" + ); + }); + + test("Create a new TextNodeIterator and iterate only over text nodes", () => { + const rootNode = createRoot([ + createParagraph([ + createInline(new Text("Hello, ")), + createInline(new Text("World!")), + createInline(new Text("Whatever")), + ]), + createParagraph([createInline(createLineBreak())]), + createParagraph([createInline(new Text("This is a ")), createInline(new Text("test"))]), + createParagraph([createInline(new Text("Hi!"))]), + ]); + + const textNodeIterator = new TextNodeIterator(rootNode); + expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, "); + textNodeIterator.nextNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("World!"); + textNodeIterator.nextNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever"); + textNodeIterator.nextNode(); + expect(textNodeIterator.currentNode.nodeType).toBe(Node.ELEMENT_NODE); + expect(textNodeIterator.currentNode.nodeName).toBe("BR"); + textNodeIterator.nextNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("This is a "); + textNodeIterator.nextNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("test"); + textNodeIterator.nextNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("Hi!"); + textNodeIterator.previousNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("test"); + textNodeIterator.previousNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("This is a "); + textNodeIterator.previousNode(); + expect(textNodeIterator.currentNode.nodeType).toBe(Node.ELEMENT_NODE); + expect(textNodeIterator.currentNode.nodeName).toBe("BR"); + textNodeIterator.previousNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever"); + textNodeIterator.previousNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("World!"); + textNodeIterator.previousNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, "); + textNodeIterator.nextNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("World!"); + textNodeIterator.previousNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, "); + textNodeIterator.nextNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("World!"); + textNodeIterator.nextNode(); + expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever"); + }); +}); diff --git a/frontend/text-editor/editor/controllers/ChangeController.js b/frontend/text-editor/editor/controllers/ChangeController.js new file mode 100644 index 000000000..8ca9ef571 --- /dev/null +++ b/frontend/text-editor/editor/controllers/ChangeController.js @@ -0,0 +1,92 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Change controller is responsible of notifying when a change happens. + */ +export class ChangeController extends EventTarget { + /** + * Keeps the timeout id. + * + * @type {number} + */ + #timeout = null; + + /** + * Keeps the time at which we're going to + * call the debounced change calls. + * + * @type {number} + */ + #time = 1000; + + /** + * Keeps if we have some pending changes or not. + * + * @type {boolean} + */ + #hasPendingChanges = false; + + /** + * Constructor + * + * @param {number} [time=500] + */ + constructor(time = 500) { + super() + if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) { + throw new TypeError("Invalid time"); + } + this.#time = time ?? 500; + } + + /** + * Indicates that there are some pending changes. + * + * @type {boolean} + */ + get hasPendingChanges() { + return this.#hasPendingChanges; + } + + #onTimeout = () => { + this.dispatchEvent(new Event("change")); + }; + + /** + * Tells the ChangeController that a change has been made + * but that you need to delay the notification (and debounce) + * for sometime. + */ + notifyDebounced() { + this.#hasPendingChanges = true; + clearTimeout(this.#timeout); + this.#timeout = setTimeout(this.#onTimeout, this.#time); + } + + /** + * Tells the ChangeController that a change should be notified + * immediately. + */ + notifyImmediately() { + clearTimeout(this.#timeout); + this.#onTimeout(); + } + + /** + * Disposes the referenced resources. + */ + dispose() { + if (this.hasPendingChanges) { + this.notifyImmediately(); + } + clearTimeout(this.#timeout); + } +} + +export default ChangeController; diff --git a/frontend/text-editor/editor/controllers/ChangeController.test.js b/frontend/text-editor/editor/controllers/ChangeController.test.js new file mode 100644 index 000000000..d5cb47852 --- /dev/null +++ b/frontend/text-editor/editor/controllers/ChangeController.test.js @@ -0,0 +1,36 @@ +import { expect, describe, test, vi } from 'vitest' +import ChangeController from './ChangeController' + +describe("ChangeController", () => { + test("Creating a ChangeController without a valid time should throw", () => { + expect(() => new ChangeController(Infinity)).toThrowError('Invalid time') + }); + + test("A ChangeController should dispatch an event when `notifyImmediately` is called", () => { + const changeListener = vi.fn(); + const changeController = new ChangeController(10); + changeController.addEventListener("change", changeListener) + changeController.notifyImmediately(); + expect(changeController.hasPendingChanges).toBe(false); + expect(changeListener).toBeCalled(1); + }); + + test("A ChangeController should dispatch an event when `notifyDebounced` is called", async () => { + return new Promise((resolve) => { + const changeController = new ChangeController(10); + changeController.addEventListener("change", () => resolve()); + changeController.notifyDebounced(); + expect(changeController.hasPendingChanges).toBe(true); + }); + }); + + test("A ChangeController should dispatch an event when `notifyDebounced` is called and disposed is called right after", async () => { + return new Promise((resolve) => { + const changeController = new ChangeController(10); + changeController.addEventListener("change", () => resolve()); + changeController.notifyDebounced(); + expect(changeController.hasPendingChanges).toBe(true); + changeController.dispose(); + }); + }); +}); diff --git a/frontend/text-editor/editor/controllers/SafeGuard.js b/frontend/text-editor/editor/controllers/SafeGuard.js new file mode 100644 index 000000000..e3afedc18 --- /dev/null +++ b/frontend/text-editor/editor/controllers/SafeGuard.js @@ -0,0 +1,34 @@ +/** + * Max. amount of time we should allow. + * + * @type {number} + */ +const SAFE_GUARD_TIME = 1000; + +/** + * Time at which the safeguard started. + * + * @type {number} + */ +let startTime = Date.now(); + +/** + * Marks the start of the safeguard. + */ +export function start() { + startTime = Date.now(); +} + +/** + * Checks if the safeguard should throw. + */ +export function update() { + if (Date.now - startTime >= SAFE_GUARD_TIME) { + throw new Error('Safe guard timeout'); + } +} + +export default { + start, + update, +} diff --git a/frontend/text-editor/editor/controllers/SelectionController.js b/frontend/text-editor/editor/controllers/SelectionController.js new file mode 100644 index 000000000..1c9681d81 --- /dev/null +++ b/frontend/text-editor/editor/controllers/SelectionController.js @@ -0,0 +1,1740 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import { createLineBreak, isLineBreak } from "../content/dom/LineBreak.js"; +import { + createInline, + createInlineFrom, + getInline, + getInlineLength, + isInline, + isInlineStart, + isInlineEnd, + setInlineStyles, + mergeInlines, + splitInline, + createEmptyInline, +} from "../content/dom/Inline.js"; +import { + createEmptyParagraph, + isEmptyParagraph, + getParagraph, + isParagraph, + isParagraphStart, + isParagraphEnd, + setParagraphStyles, + splitParagraph, + splitParagraphAtNode, + mergeParagraphs, + fixParagraph, +} from "../content/dom/Paragraph.js"; +import { + removeBackward, + removeForward, + replaceWith, + insertInto, + removeSlice, +} from "../content/Text.js"; +import { getTextNodeLength, getClosestTextNode, isTextNode } from "../content/dom/TextNode.js"; +import TextNodeIterator from "../content/dom/TextNodeIterator.js"; +import TextEditor from "../TextEditor.js"; +import CommandMutations from "../commands/CommandMutations.js"; +import { setRootStyles } from "../content/dom/Root.js"; +import { SelectionDirection } from "./SelectionDirection.js"; +import SafeGuard from "./SafeGuard.js"; + +const SAFE_GUARD = true; +const SAFE_GUARD_TIME = true; + +/** + * Supported options for the SelectionController. + * + * @typedef {Object} SelectionControllerOptions + * @property {Object} [debug] An object with references to DOM elements that will keep all the debugging values. + */ + +/** + * SelectionController uses the same concepts used by the Selection API but extending it to support + * our own internal model based on paragraphs (in drafconst textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, "))]), + createEmptyParagraph(), + createParagraph([createInline(new Text("World!"))]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.childNodes.item(2).firstChild.firstChild, + 0 + ); + selectionController.mergeBackwardParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.textContent).toBe("Hello, "); + expect(textEditorMock.root.lastChild.textContent).toBe("World!"); + t.js they were called blocks) and inlines. + */ +export class SelectionController extends EventTarget { + /** + * Reference to the text editor. + * + * @type {TextEditor} + */ + #textEditor = null; + + /** + * Selection. + * + * @type {Selection} + */ + #selection = null; + + /** + * Set of ranges (this should always have one) + * + * @type {Set} + */ + #ranges = new Set(); + + /** + * Current range (.rangeAt 0) + * + * @type {Range} + */ + #range = null; + + /** + * @type {Node} + */ + #focusNode = null; + + /** + * @type {number} + */ + #focusOffset = 0; + + /** + * @type {Node} + */ + #anchorNode = null; + + /** + * @type {number} + */ + #anchorOffset = 0; + + /** + * Saved selection. + * + * @type {object} + */ + #savedSelection = null; + + /** + * TextNodeIterator that allows us to move + * around the root element but only through + *
and #text nodes. + * + * @type {TextNodeIterator} + */ + #textNodeIterator = null; + + /** + * CSSStyleDeclaration that we can mutate + * to handle style changes. + * + * @type {CSSStyleDeclaration} + */ + #currentStyle = null; + + /** + * Element used to have a custom CSSStyleDeclaration + * that we can modify to handle style changes when the + * selection is changed. + * + * @type {HTMLDivElement} + */ + #inertElement = null; + + /** + * @type {SelectionControllerDebug} + */ + #debug = null; + + /** + * Command Mutations. + * + * @type {CommandMutations} + */ + #mutations = new CommandMutations(); + + /** + * Style defaults. + * + * @type {Object.} + */ + #styleDefaults = null; + + /** + * Fix for Chrome. + */ + #fixInsertCompositionText = false; + + /** + * Constructor + * + * @param {TextEditor} textEditor + * @param {Selection} selection + * @param {SelectionControllerOptions} [options] + */ + constructor(textEditor, selection, options) { + super(); + // FIXME: We can't check if it is an instanceof TextEditor + // because tests use TextEditorMock. + /* + if (!(textEditor instanceof TextEditor)) { + throw new TypeError("Invalid EventTarget"); + } + */ + this.#debug = options?.debug; + this.#styleDefaults = options?.styleDefaults; + this.#selection = selection; + this.#textEditor = textEditor; + this.#textNodeIterator = new TextNodeIterator(this.#textEditor.element); + + // Setups everything. + this.#setup(); + } + + /** + * Styles of the current inline. + * + * @type {CSSStyleDeclaration} + */ + get currentStyle() { + return this.#currentStyle; + } + + /** + * Applies the default styles to the currentStyle + * CSSStyleDeclaration. + */ + #applyDefaultStylesToCurrentStyle() { + if (this.#styleDefaults) { + for (const [name, value] of Object.entries(this.#styleDefaults)) { + this.#currentStyle.setProperty( + name, + value + (name === "font-size" ? "px" : "") + ); + } + } + } + + /** + * Applies some styles to the currentStyle + * CSSStyleDeclaration + * + * @param {HTMLElement} element + */ + #applyStylesToCurrentStyle(element) { + for (let index = 0; index < element.style.length; index++) { + const styleName = element.style.item(index); + const styleValue = element.style.getPropertyValue(styleName); + this.#currentStyle.setProperty(styleName, styleValue); + } + } + + /** + * Updates current styles based on the currently selected inline. + * + * @param {HTMLSpanElement} inline + * @returns {SelectionController} + */ + #updateCurrentStyle(inline) { + this.#applyDefaultStylesToCurrentStyle(); + const root = inline.parentElement.parentElement; + this.#applyStylesToCurrentStyle(root); + const paragraph = inline.parentElement; + this.#applyStylesToCurrentStyle(paragraph); + this.#applyStylesToCurrentStyle(inline); + return this; + } + + /** + * This is called on every `selectionchange` because it is dispatched + * only by the `document` object. + * + * @param {Event} e + */ + #onSelectionChange = (e) => { + // If we're outside the contenteditable element, then + // we return. + if (!this.hasFocus) { + return; + } + + let focusNodeChanges = false; + let anchorNodeChanges = false; + + if (this.#focusNode !== this.#selection.focusNode) { + this.#focusNode = this.#selection.focusNode; + focusNodeChanges = true; + } + this.#focusOffset = this.#selection.focusOffset; + + if (this.#anchorNode !== this.#selection.anchorNode) { + this.#anchorNode = this.#selection.anchorNode; + anchorNodeChanges = true; + } + this.#anchorOffset = this.#selection.anchorOffset; + + // We need to handle multi selection from firefox + // and remove all the old ranges and just keep the + // last one added. + if (this.#selection.rangeCount > 1) { + for (let index = 0; index < this.#selection.rangeCount; index++) { + const range = this.#selection.getRangeAt(index); + if (this.#ranges.has(range)) { + this.#ranges.delete(range); + this.#selection.removeRange(range); + } else { + this.#ranges.add(range); + this.#range = range; + } + } + } else if (this.#selection.rangeCount > 0) { + const range = this.#selection.getRangeAt(0); + this.#range = range; + this.#ranges.clear(); + this.#ranges.add(range); + } else { + this.#range = null; + this.#ranges.clear(); + } + + // If focus node changed, we need to retrieve all the + // styles of the current inline and dispatch an event + // to notify that the styles have changed. + if (focusNodeChanges) { + this.#notifyStyleChange(); + } + + if (this.#fixInsertCompositionText) { + this.#fixInsertCompositionText = false; + const lineBreak = fixParagraph(this.focusNode); + this.collapse(lineBreak, 0); + } + + if (this.#debug) { + this.#debug.update(this); + } + }; + + /** + * Notifies that the styles have changed. + */ + #notifyStyleChange() { + const inline = this.focusInline; + if (inline) { + this.#updateCurrentStyle(inline); + this.dispatchEvent( + new CustomEvent("stylechange", { + detail: this.#currentStyle, + }) + ); + } + } + + /** + * Setups + */ + #setup() { + // This element is not attached to the DOM + // so it doesn't trigger style or layout calculations. + // That's why it's called "inertElement". + this.#inertElement = document.createElement("div"); + this.#currentStyle = this.#inertElement.style; + this.#applyDefaultStylesToCurrentStyle(); + + if (this.#selection.rangeCount > 0) { + const range = this.#selection.getRangeAt(0); + this.#range = range; + this.#ranges.add(range); + } + + // If there are more than one range, we should remove + // them because this is a feature not supported by browsers + // like Safari and Chrome. + if (this.#selection.rangeCount > 1) { + for (let index = 1; index < this.#selection.rangeCount; index++) { + this.#selection.removeRange(index); + } + } + document.addEventListener("selectionchange", this.#onSelectionChange); + } + + /** + * Returns a Range-like object. + * + * @returns {RangeLike} + */ + #getSavedRange() { + if (!this.#range) { + return { + collapsed: true, + commonAncestorContainer: null, + startContainer: null, + startOffset: 0, + endContainer: null, + endOffset: 0, + }; + } + return { + collapsed: this.#range.collapsed, + commonAncestorContainer: this.#range.commonAncestorContainer, + startContainer: this.#range.startContainer, + startOffset: this.#range.startOffset, + endContainer: this.#range.endContainer, + endOffset: this.#range.endOffset, + }; + } + + /** + * Saves the current selection and returns the client rects. + * + * @returns {boolean} + */ + saveSelection() { + this.#savedSelection = { + isCollapsed: this.#selection.isCollapsed, + focusNode: this.#selection.focusNode, + focusOffset: this.#selection.focusOffset, + anchorNode: this.#selection.anchorNode, + anchorOffset: this.#selection.anchorOffset, + range: this.#getSavedRange(), + }; + return true; + } + + /** + * Restores a saved selection if there's any. + * + * @returns {boolean} + */ + restoreSelection() { + if (!this.#savedSelection) return false; + + if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) { + if (this.#savedSelection.anchorNode === this.#savedSelection.focusNode) { + this.#selection.setPosition(this.#savedSelection.focusNode, this.#savedSelection.focusOffset); + } else { + this.#selection.setBaseAndExtent( + this.#savedSelection.anchorNode, + this.#savedSelection.anchorOffset, + this.#savedSelection.focusNode, + this.#savedSelection.focusOffset + ); + } + } + this.#savedSelection = null; + return true; + } + + /** + * Marks the start of a mutation. + * + * Clears all the mutations kept in CommandMutations. + */ + startMutation() { + this.#mutations.clear(); + if (!this.#focusNode) return false; + return true; + } + + /** + * Marks the end of a mutation. + * + * @returns + */ + endMutation() { + return this.#mutations; + } + + /** + * Selects all content. + */ + selectAll() { + this.#selection.selectAllChildren(this.#textEditor.root); + return this; + } + + /** + * Moves cursor to end. + */ + cursorToEnd() { + const range = document.createRange(); //Create a range (a range is a like the selection but invisible) + range.selectNodeContents(this.#textEditor.element); + range.collapse(false); + this.#selection.removeAllRanges(); + this.#selection.addRange(range); + return this; + } + + /** + * Collapses a selection. + * + * @param {Node} node + * @param {number} offset + */ + collapse(node, offset) { + const nodeOffset = (node.nodeType === Node.TEXT_NODE && offset >= node.nodeValue.length) + ? node.nodeValue.length + : offset + + return this.setSelection( + node, + nodeOffset, + node, + nodeOffset + ); + } + + /** + * Sets base and extent. + * + * @param {Node} anchorNode + * @param {number} anchorOffset + * @param {Node} [focusNode=anchorNode] + * @param {number} [focusOffset=anchorOffset] + */ + setSelection(anchorNode, anchorOffset, focusNode = anchorNode, focusOffset = anchorOffset) { + if (!anchorNode.isConnected) { + throw new Error('Invalid anchorNode') + } + if (!focusNode.isConnected) { + throw new Error('Invalid focusNode') + } + if (this.#savedSelection) { + this.#savedSelection.isCollapsed = + focusNode === anchorNode && anchorOffset === focusOffset; + this.#savedSelection.focusNode = focusNode; + this.#savedSelection.focusOffset = focusOffset; + this.#savedSelection.anchorNode = anchorNode; + this.#savedSelection.anchorOffset = anchorOffset; + + this.#savedSelection.range.collapsed = this.#savedSelection.isCollapsed; + const position = focusNode.compareDocumentPosition(anchorNode); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + this.#savedSelection.range.startContainer = focusNode; + this.#savedSelection.range.startOffset = focusOffset; + this.#savedSelection.range.endContainer = anchorNode; + this.#savedSelection.range.endOffset = anchorOffset; + } else { + this.#savedSelection.range.startContainer = anchorNode; + this.#savedSelection.range.startOffset = anchorOffset; + this.#savedSelection.range.endContainer = focusNode; + this.#savedSelection.range.endOffset = focusOffset; + } + } else { + this.#anchorNode = anchorNode; + this.#anchorOffset = anchorOffset; + if (anchorNode === focusNode) { + this.#focusNode = this.#anchorNode; + this.#focusOffset = this.#anchorOffset; + this.#selection.setPosition(anchorNode, anchorOffset); + } else { + this.#focusNode = focusNode; + this.#focusOffset = focusOffset; + this.#selection.setBaseAndExtent( + anchorNode, + anchorOffset, + focusNode, + focusOffset + ); + } + } + } + + /** + * Disposes the current resources. + */ + dispose() { + document.removeEventListener("selectionchange", this.#onSelectionChange); + this.#textEditor = null; + this.#ranges.clear(); + this.#ranges = null; + this.#range = null; + this.#selection = null; + this.#focusNode = null; + this.#anchorNode = null; + this.#mutations.dispose(); + this.#mutations = null; + } + + /** + * Returns the current selection. + * + * @type {Selection} + */ + get selection() { + return this.#selection; + } + + /** + * Returns the current range. + * + * @type {Range} + */ + get range() { + return this.#range; + } + + /** + * Indicates the direction of the selection + * + * @type {SelectionDirection} + */ + get direction() { + if (this.isCollapsed) { + return SelectionDirection.NONE; + } + if (this.focusNode !== this.anchorNode) { + return this.startContainer === this.focusNode + ? SelectionDirection.BACKWARD + : SelectionDirection.FORWARD; + } + return this.focusOffset < this.anchorOffset + ? SelectionDirection.BACKWARD + : SelectionDirection.FORWARD; + } + + /** + * Indicates that the editor element has the + * focus. + * + * @type {boolean} + */ + get hasFocus() { + return document.activeElement === this.#textEditor.element; + } + + /** + * Returns true if the selection is collapsed (caret) + * or false otherwise. + * + * @type {boolean} + */ + get isCollapsed() { + if (this.#savedSelection) { + return this.#savedSelection.isCollapsed; + } + return this.#selection.isCollapsed; + } + + /** + * Current or saved anchor node. + * + * @type {Node} + */ + get anchorNode() { + if (this.#savedSelection) { + return this.#savedSelection.anchorNode; + } + return this.#anchorNode; + } + + /** + * Current or saved anchor offset. + * + * @type {number} + */ + get anchorOffset() { + if (this.#savedSelection) { + return this.#savedSelection.anchorOffset; + } + return this.#selection.anchorOffset; + } + + /** + * Indicates that the caret is at the start of the node. + * + * @type {boolean} + */ + get anchorAtStart() { + return this.anchorOffset === 0; + } + + /** + * Indicates that the caret is at the end of the node. + * + * @type {boolean} + */ + get anchorAtEnd() { + return this.anchorOffset === this.anchorNode.nodeValue.length; + } + + /** + * Current or saved focus node. + * + * @type {Node} + */ + get focusNode() { + if (this.#savedSelection) { + return this.#savedSelection.focusNode; + } + if (!this.#focusNode) + console.trace("focusNode", this.#focusNode); + return this.#focusNode; + } + + /** + * Current or saved focus offset. + * + * @type {number} + */ + get focusOffset() { + if (this.#savedSelection) { + return this.#savedSelection.focusOffset; + } + return this.#focusOffset; + } + + /** + * Indicates that the caret is at the start of the node. + * + * @type {boolean} + */ + get focusAtStart() { + return this.focusOffset === 0; + } + + /** + * Indicates that the caret is at the end of the node. + * + * @type {boolean} + */ + get focusAtEnd() { + return this.focusOffset === this.focusNode.nodeValue.length; + } + + /** + * Returns the paragraph in the focus node + * of the current selection. + * + * @type {HTMLElement|null} + */ + get focusParagraph() { + return getParagraph(this.focusNode); + } + + /** + * Returns the inline in the focus node + * of the current selection. + * + * @type {HTMLElement|null} + */ + get focusInline() { + return getInline(this.focusNode); + } + + /** + * Returns the current paragraph in the anchor + * node of the current selection. + * + * @type {HTMLElement|null} + */ + get anchorParagraph() { + return getParagraph(this.anchorNode); + } + + /** + * Returns the current inline in the anchor + * node of the current selection. + * + * @type {HTMLElement|null} + */ + get anchorInline() { + return getInline(this.anchorNode); + } + + /** + * Start container of the current range. + */ + get startContainer() { + if (this.#savedSelection) { + return this.#savedSelection?.range?.startContainer; + } + return this.#range?.startContainer; + } + + /** + * `startOffset` of the current range. + * + * @type {number|null} + */ + get startOffset() { + if (this.#savedSelection) { + return this.#savedSelection?.range?.startOffset; + } + return this.#range?.startOffset; + } + + /** + * Start paragraph of the current range. + * + * @type {HTMLElement|null} + */ + get startParagraph() { + const startContainer = this.startContainer; + if (!startContainer) return null; + return getParagraph(startContainer); + } + + /** + * Start inline of the current page. + * + * @type {HTMLElement|null} + */ + get startInline() { + const startContainer = this.startContainer; + if (!startContainer) return null; + return getInline(startContainer); + } + + /** + * End container of the current range. + * + * @type {Node} + */ + get endContainer() { + if (this.#savedSelection) { + return this.#savedSelection?.range?.endContainer; + } + return this.#range?.endContainer; + } + + /** + * `endOffset` of the current range + * + * @type {HTMLElement|null} + */ + get endOffset() { + if (this.#savedSelection) { + return this.#savedSelection?.range?.endOffset; + } + return this.#range?.endOffset; + } + + /** + * Paragraph element of the `endContainer` of + * the current range. + * + * @type {HTMLElement|null} + */ + get endParagraph() { + const endContainer = this.endContainer; + if (!endContainer) return null; + return getParagraph(endContainer); + } + + /** + * Inline element of the `endContainer` of + * the current range. + * + * @type {HTMLElement|null} + */ + get endInline() { + const endContainer = this.endContainer; + if (!endContainer) return null; + return getInline(endContainer); + } + + /** + * Returns true if the anchor node and the focus + * node are the same text nodes. + * + * @type {boolean} + */ + get isTextSame() { + return ( + this.isTextFocus === this.isTextAnchor && + this.focusNode === this.anchorNode + ); + } + + /** + * Indicates that focus node is a text node. + * + * @type {boolean} + */ + get isTextFocus() { + return this.focusNode.nodeType === Node.TEXT_NODE; + } + + /** + * Indicates that anchor node is a text node. + * + * @type {boolean} + */ + get isTextAnchor() { + return this.anchorNode.nodeType === Node.TEXT_NODE; + } + + /** + * Is true if the current focus node is a inline. + * + * @type {boolean} + */ + get isInlineFocus() { + return isInline(this.focusNode); + } + + /** + * Is true if the current anchor node is a inline. + * + * @type {boolean} + */ + get isInlineAnchor() { + return isInline(this.anchorNode); + } + + /** + * Is true if the current focus node is a paragraph. + * + * @type {boolean} + */ + get isParagraphFocus() { + return isParagraph(this.focusNode); + } + + /** + * Is true if the current anchor node is a paragraph. + * + * @type {boolean} + */ + get isParagraphAnchor() { + return isParagraph(this.anchorNode); + } + + /** + * Is true if the current focus node is a line break. + * + * @type {boolean} + */ + get isLineBreakFocus() { + return ( + isLineBreak(this.focusNode) || + (isInline(this.focusNode) && isLineBreak(this.focusNode.firstChild)) + ); + } + + /** + * Indicates that we have multiple nodes selected. + * + * @type {boolean} + */ + get isMulti() { + return this.focusNode !== this.anchorNode; + } + + /** + * Indicates that we have selected multiple + * paragraph elements. + * + * @type {boolean} + */ + get isMultiParagraph() { + return this.isMulti && this.focusParagraph !== this.anchorParagraph; + } + + /** + * Indicates that we have selected multiple + * inline elements. + * + * @type {boolean} + */ + get isMultiInline() { + return this.isMulti && this.focusInline !== this.anchorInline; + } + + /** + * Indicates that the caret (only the caret) + * is at the start of an inline. + * + * @type {boolean} + */ + get isInlineStart() { + if (!this.isCollapsed) return false; + return isInlineStart(this.focusNode, this.focusOffset); + } + + /** + * Indicates that the caret (only the caret) + * is at the end of an inline. This value doesn't + * matter when dealing with selections. + * + * @type {boolean} + */ + get isInlineEnd() { + if (!this.isCollapsed) return false; + return isInlineEnd(this.focusNode, this.focusOffset); + } + + /** + * Indicates that we're in the starting position of a paragraph. + * + * @type {boolean} + */ + get isParagraphStart() { + if (!this.isCollapsed) return false; + return isParagraphStart(this.focusNode, this.focusOffset); + } + + /** + * Indicates that we're in the ending position of a paragraph. + * + * @type {boolean} + */ + get isParagraphEnd() { + if (!this.isCollapsed) return false; + return isParagraphEnd(this.focusNode, this.focusOffset); + } + + /** + * Insert pasted fragment. + * + * @param {DocumentFragment} fragment + */ + insertPaste(fragment) { + const numParagraphs = fragment.children.length; + if (this.isParagraphStart) { + this.focusParagraph.before(fragment); + } else if (this.isParagraphEnd) { + this.focusParagraph.after(fragment); + } else { + const newParagraph = splitParagraph( + this.focusParagraph, + this.focusInline, + this.focusOffset + ); + this.focusParagraph.after(fragment, newParagraph); + } + } + + /** + * Replaces data with pasted fragment + * + * @param {DocumentFragment} fragment + */ + replaceWithPaste(fragment) { + const numParagraphs = fragment.children.length; + this.removeSelected(); + this.insertPaste(fragment); + } + + /** + * Replaces the current line break with text + * + * @param {string} text + */ + replaceLineBreak(text) { + const newText = new Text(text); + this.focusInline.replaceChildren(newText); + this.collapse(newText, text.length); + } + + /** + * Removes text forward from the current position. + */ + removeForwardText() { + this.#textNodeIterator.currentNode = this.focusNode; + + const removedData = removeForward( + this.focusNode.nodeValue, + this.focusOffset + ); + + if (this.focusNode.nodeValue !== removedData) { + this.focusNode.nodeValue = removedData; + } + + const paragraph = this.focusParagraph; + if (!paragraph) throw new Error("Cannot find paragraph"); + const inline = this.focusInline; + if (!inline) throw new Error("Cannot find inline"); + + const nextTextNode = this.#textNodeIterator.nextNode(); + if (this.focusNode.nodeValue === "") { + this.focusNode.remove(); + } + + if (paragraph.childNodes.length === 1 && inline.childNodes.length === 0) { + const lineBreak = createLineBreak(); + inline.appendChild(lineBreak); + return this.collapse(lineBreak, 0); + } else if ( + paragraph.childNodes.length > 1 && + inline.childNodes.length === 0 + ) { + inline.remove(); + return this.collapse(nextTextNode, 0); + } + return this.collapse(this.focusNode, this.focusOffset); + } + + /** + * Removes text backward from the current caret position. + */ + removeBackwardText() { + this.#textNodeIterator.currentNode = this.focusNode; + + // Remove the character from the string. + const removedData = removeBackward( + this.focusNode.nodeValue, + this.focusOffset + ); + + if (this.focusNode.nodeValue !== removedData) { + this.focusNode.nodeValue = removedData; + } + + // If the focusNode has content we don't need to do + // anything else. + if (this.focusOffset - 1 > 0) { + return this.collapse(this.focusNode, this.focusOffset - 1); + } + + const paragraph = this.focusParagraph; + if (!paragraph) throw new Error("Cannot find paragraph"); + const inline = this.focusInline; + if (!inline) throw new Error("Cannot find inline"); + + const previousTextNode = this.#textNodeIterator.previousNode(); + if (this.focusNode.nodeValue === "") { + this.focusNode.remove(); + } + + if (paragraph.children.length === 1 && inline.childNodes.length === 0) { + const lineBreak = createLineBreak(); + inline.appendChild(lineBreak); + return this.collapse(lineBreak, 0); + } else if ( + paragraph.children.length > 1 && + inline.childNodes.length === 0 + ) { + inline.remove(); + return this.collapse(previousTextNode, getTextNodeLength(previousTextNode)); + } + + return this.collapse(this.focusNode, this.focusOffset - 1); + } + + /** + * Inserts some text in the caret position. + * + * @param {string} newText + */ + insertText(newText) { + this.focusNode.nodeValue = insertInto( + this.focusNode.nodeValue, + this.focusOffset, + newText + ); + this.#mutations.update(this.focusInline); + return this.collapse(this.focusNode, this.focusOffset + newText.length); + } + + /** + * Replaces the currently focus element + * with some text. + * + * @param {string} newText + */ + insertIntoFocus(newText) { + if (this.isTextFocus) { + this.focusNode.nodeValue = insertInto( + this.focusNode.nodeValue, + this.focusOffset, + newText + ); + } else if (this.isLineBreakFocus) { + const textNode = new Text(newText); + this.focusNode.replaceWith(textNode); + this.collapse(textNode, newText.length); + } else { + throw new Error('Unknown node type'); + } + } + + /** + * Replaces currently selected text. + * + * @param {string} newText + */ + replaceText(newText) { + const startOffset = Math.min(this.anchorOffset, this.focusOffset); + const endOffset = Math.max(this.anchorOffset, this.focusOffset); + if (this.isTextFocus) { + this.focusNode.nodeValue = replaceWith( + this.focusNode.nodeValue, + startOffset, + endOffset, + newText + ); + } else if (this.isLineBreakFocus) { + this.focusNode.replaceWith(new Text(newText)); + } else { + throw new Error('Unknown node type'); + } + this.#mutations.update(this.focusInline); + return this.collapse(this.focusNode, startOffset + newText.length); + } + + /** + * Replaces the selected inlines with new text. + * + * @param {string} newText + */ + replaceInlines(newText) { + const currentParagraph = this.focusParagraph; + + // This is the special (and fast) case where we're + // removing everything inside a paragraph. + if ( + this.startInline === currentParagraph.firstChild && + this.startOffset === 0 && + this.endInline === currentParagraph.lastChild && + this.endOffset === currentParagraph.lastChild.textContent.length + ) { + const newTextNode = new Text(newText); + currentParagraph.replaceChildren( + createInline(newTextNode, this.anchorInline.style) + ); + return this.collapse(newTextNode, newTextNode.nodeValue.length); + } + + this.removeSelected(); + this.insertIntoFocus(newText); + + /* + this.focusNode.nodeValue = insertInto( + this.focusNode.nodeValue, + this.focusOffset, + newText + ); + */ + + // FIXME: I'm not sure if we should merge inlines when they share the same styles. + // For example: if we have > 2 inlines and the start inline and the end inline + // share the same styles, maybe we should merge them? + // mergeInlines(startInline, endInline); + return this.collapse(this.focusNode, this.focusOffset + newText.length); + } + + /** + * Replaces paragraphs with text. + * + * @param {string} newText + */ + replaceParagraphs(newText) { + const currentParagraph = this.focusParagraph; + + this.removeSelected(); + this.insertIntoFocus(newText); + + for (const child of currentParagraph.children) { + if (child.textContent === "") { + child.remove(); + } + } + + /* + this.focusNode.nodeValue = insertInto( + this.focusNode.nodeValue, + this.focusOffset, + newText + ); + */ + } + + /** + * Inserts a new paragraph after the current paragraph. + */ + insertParagraphAfter() { + const currentParagraph = this.focusParagraph; + const newParagraph = createEmptyParagraph(this.#currentStyle); + currentParagraph.after(newParagraph); + this.#mutations.update(currentParagraph); + this.#mutations.add(newParagraph); + return this.collapse(newParagraph.firstChild.firstChild, 0); + } + + /** + * Inserts a new paragraph before the current paragraph. + */ + insertParagraphBefore() { + const currentParagraph = this.focusParagraph; + const newParagraph = createEmptyParagraph(this.#currentStyle); + currentParagraph.before(newParagraph); + this.#mutations.update(currentParagraph); + this.#mutations.add(newParagraph); + return this.collapse(currentParagraph.firstChild.firstChild, 0); + } + + /** + * Splits the current paragraph. + */ + splitParagraph() { + const currentParagraph = this.focusParagraph; + const newParagraph = splitParagraph( + this.focusParagraph, + this.focusInline, + this.#focusOffset + ); + this.focusParagraph.after(newParagraph); + this.#mutations.update(currentParagraph); + this.#mutations.add(newParagraph); + return this.collapse(newParagraph.firstChild.firstChild, 0); + } + + /** + * Inserts a new paragraph. + */ + insertParagraph() { + if (this.isParagraphEnd) { + return this.insertParagraphAfter(); + } else if (this.isParagraphStart) { + return this.insertParagraphBefore(); + } + return this.splitParagraph(); + } + + /** + * Replaces the currently selected content with + * a paragraph. + */ + replaceWithParagraph() { + const currentParagraph = this.focusParagraph; + const currentInline = this.focusInline; + + this.removeSelected(); + + const newParagraph = splitParagraph( + currentParagraph, + currentInline, + this.focusOffset + ); + currentParagraph.after(newParagraph); + + this.#mutations.update(currentParagraph); + this.#mutations.add(newParagraph); + + // FIXME: Missing collapse? + } + + /** + * Removes a paragraph in backward direction. + */ + removeBackwardParagraph() { + const previousParagraph = this.focusParagraph.previousElementSibling; + if (!previousParagraph) { + return; + } + const paragraphToBeRemoved = this.focusParagraph; + paragraphToBeRemoved.remove(); + const previousInline = + previousParagraph.children.length > 1 + ? previousParagraph.lastElementChild + : previousParagraph.firstChild; + const previousOffset = isLineBreak(previousInline.firstChild) + ? 0 + : previousInline.firstChild.nodeValue.length; + this.#mutations.remove(paragraphToBeRemoved); + return this.collapse(previousInline.firstChild, previousOffset); + } + + /** + * Merges the previous paragraph with the current paragraph. + */ + mergeBackwardParagraph() { + const currentParagraph = this.focusParagraph; + const previousParagraph = this.focusParagraph.previousElementSibling; + if (!previousParagraph) { + return; + } + let previousInline = previousParagraph.lastChild; + const previousOffset = getInlineLength(previousInline); + if (isEmptyParagraph(previousParagraph)) { + previousParagraph.replaceChildren(...currentParagraph.children); + previousInline = previousParagraph.firstChild; + currentParagraph.remove(); + } else { + mergeParagraphs(previousParagraph, currentParagraph); + } + this.#mutations.remove(currentParagraph); + this.#mutations.update(previousParagraph); + return this.collapse(previousInline.firstChild, previousOffset); + } + + /** + * Merges the next paragraph with the current paragraph. + */ + mergeForwardParagraph() { + const currentParagraph = this.focusParagraph; + const nextParagraph = this.focusParagraph.nextElementSibling; + if (!nextParagraph) { + return; + } + mergeParagraphs(this.focusParagraph, nextParagraph); + this.#mutations.update(currentParagraph); + this.#mutations.remove(nextParagraph); + + // FIXME: Missing collapse? + } + + /** + * Removes the forward paragraph. + */ + removeForwardParagraph() { + const nextParagraph = this.focusParagraph.nextSibling; + if (!nextParagraph) { + return; + } + const paragraphToBeRemoved = this.focusParagraph; + paragraphToBeRemoved.remove(); + const nextInline = nextParagraph.firstChild; + const nextOffset = this.focusOffset; + this.#mutations.remove(paragraphToBeRemoved); + return this.collapse(nextInline.firstChild, nextOffset); + } + + /** + * Cleans up all the affected paragraphs. + * + * @param {Set} affectedParagraphs + * @param {Set} affectedInlines + */ + cleanUp(affectedParagraphs, affectedInlines) { + // Remove empty inlines + for (const inline of affectedInlines) { + if (inline.textContent === "") { + inline.remove(); + this.#mutations.remove(inline); + } + } + + // Remove empty paragraphs. + for (const paragraph of affectedParagraphs) { + if (paragraph.children.length === 0) { + paragraph.remove(); + this.#mutations.remove(paragraph); + } + } + } + + /** + * Removes the selected content. + * + * @param {RemoveSelectedOptions} [options] + */ + removeSelected(options) { + if (this.isCollapsed) return; + + const affectedInlines = new Set(); + const affectedParagraphs = new Set(); + + const startNode = getClosestTextNode(this.#range.startContainer); + const endNode = getClosestTextNode(this.#range.endContainer); + const startOffset = this.#range.startOffset; + const endOffset = this.#range.endOffset; + + let previousNode = null; + let nextNode = null; + + // This is the simplest case, when the startNode and the endNode + // are the same and they're a textNode. + if (startNode === endNode) { + this.#textNodeIterator.currentNode = startNode; + previousNode = this.#textNodeIterator.previousNode(); + + this.#textNodeIterator.currentNode = startNode; + nextNode = this.#textNodeIterator.nextNode(); + + const inline = getInline(startNode); + const paragraph = getParagraph(startNode); + affectedInlines.add(inline); + affectedParagraphs.add(paragraph); + + const newNodeValue = removeSlice( + startNode.nodeValue, + startOffset, + endOffset + ); + if (newNodeValue === "") { + const lineBreak = createLineBreak(); + inline.replaceChildren(lineBreak); + return this.collapse(lineBreak, 0); + } + startNode.nodeValue = newNodeValue; + return this.collapse(startNode, startOffset); + } + + // If startNode and endNode are different, + // then we should process every text node from + // start to end. + + // Select initial node. + this.#textNodeIterator.currentNode = startNode; + + const startInline = getInline(startNode); + const startParagraph = getParagraph(startNode); + const endInline = getInline(endNode); + const endParagraph = getParagraph(endNode); + + SafeGuard.start(); + do { + SafeGuard.update(); + + const currentNode = this.#textNodeIterator.currentNode; + + // We retrieve the inline and paragraph of the + // current node. + const inline = getInline(this.#textNodeIterator.currentNode); + const paragraph = getParagraph(this.#textNodeIterator.currentNode); + + let shouldRemoveNodeCompletely = false; + if (this.#textNodeIterator.currentNode === startNode) { + if (startOffset === 0) { + // We should remove this node completely. + shouldRemoveNodeCompletely = true; + } else { + // We should remove this node partially. + currentNode.nodeValue = currentNode.nodeValue.slice(0, startOffset); + } + } else if (this.#textNodeIterator.currentNode === endNode) { + if (isLineBreak(endNode) + || (isTextNode(endNode) + && endOffset === endNode.nodeValue.length)) { + // We should remove this node completely. + shouldRemoveNodeCompletely = true; + } else { + // We should remove this node partially. + currentNode.nodeValue = currentNode.nodeValue.slice(endOffset); + } + } else { + // We should remove this node completely. + shouldRemoveNodeCompletely = true; + } + + this.#textNodeIterator.nextNode(); + + // Realizamos el borrado del nodo actual. + if (shouldRemoveNodeCompletely) { + currentNode.remove(); + if (currentNode === startNode) { + continue; + } + if (currentNode === endNode) { + break; + } + + if (inline.childNodes.length === 0) { + inline.remove(); + } + if (paragraph !== startParagraph && paragraph.children.length === 0) { + paragraph.remove(); + } + } + + if (currentNode === endNode) { + break; + } + + } while (this.#textNodeIterator.currentNode); + + if (startParagraph !== endParagraph) { + const mergedParagraph = mergeParagraphs(startParagraph, endParagraph); + if (mergedParagraph.children.length === 0) { + const newEmptyInline = createEmptyInline(this.#currentStyle); + mergedParagraph.appendChild(newEmptyInline); + return this.collapse(newEmptyInline.firstChild, 0); + } + } + + if (startInline.childNodes.length === 0 && endInline.childNodes.length > 0) { + startInline.remove(); + return this.collapse(endNode, 0); + } else if (startInline.childNodes.length > 0 && endInline.childNodes.length === 0) { + endInline.remove(); + return this.collapse(startNode, startOffset); + } else if (startInline.childNodes.length === 0 && endInline.childNodes.length === 0) { + const previousInline = startInline.previousElementSibling; + const nextInline = endInline.nextElementSibling; + startInline.remove(); + endInline.remove(); + if (previousInline) { + return this.collapse(previousInline.firstChild, previousInline.firstChild.nodeValue.length); + } + if (nextInline) { + return this.collapse(nextInline.firstChild, 0); + } + const newEmptyInline = createEmptyInline(this.#currentStyle); + startParagraph.appendChild(newEmptyInline); + return this.collapse(newEmptyInline.firstChild, 0); + } + + return this.collapse(startNode, startOffset); + } + + /** + * Applies styles from the startNode to the endNode. + * + * @param {Node} startNode + * @param {number} startOffset + * @param {Node} endNode + * @param {number} endOffset + * @param {Object.|CSSStyleDeclaration} newStyles + * @returns {void} + */ + #applyStylesTo(startNode, startOffset, endNode, endOffset, newStyles) { + // Applies the necessary styles to the root element. + const root = this.#textEditor.root; + setRootStyles(root, newStyles); + + // If the startContainer and endContainer are the same + // node, then we can apply styles directly to that + // node. + if (startNode === endNode && startNode.nodeType === Node.TEXT_NODE) { + // The styles are applied to the node completelly. + if (startOffset === 0 && endOffset === endNode.nodeValue.length) { + const paragraph = this.startParagraph; + const inline = this.startInline; + setParagraphStyles(paragraph, newStyles); + setInlineStyles(inline, newStyles); + + // The styles are applied to a part of the node. + } else if (startOffset !== endOffset) { + const paragraph = this.startParagraph; + setParagraphStyles(paragraph, newStyles); + const inline = this.startInline; + const midText = startNode.splitText(startOffset); + const endText = midText.splitText(endOffset - startOffset); + const midInline = createInlineFrom(inline, midText, newStyles); + inline.after(midInline); + if (endText.length > 0) { + const endInline = createInline(endText, inline.style); + midInline.after(endInline); + } + + // FIXME: This can change focus <-> anchor order. + this.setSelection(midText, 0, midText, midText.nodeValue.length); + + // The styles are applied to the paragraph. + } else { + const paragraph = this.startParagraph; + setParagraphStyles(paragraph, newStyles); + } + return this.#notifyStyleChange(); + + // If the startContainer and endContainer are different + // then we need to iterate through those nodes to apply + // the styles. + } else if (startNode !== endNode) { + SafeGuard.start(); + const expectedEndNode = getClosestTextNode(endNode); + this.#textNodeIterator.currentNode = getClosestTextNode(startNode); + do { + SafeGuard.update(); + + const paragraph = getParagraph(this.#textNodeIterator.currentNode); + setParagraphStyles(paragraph, newStyles); + const inline = getInline(this.#textNodeIterator.currentNode); + // If we're at the start node and offset is greater than 0 + // then we should split the inline and apply styles to that + // new inline. + if ( + this.#textNodeIterator.currentNode === startNode && + startOffset > 0 + ) { + const newInline = splitInline(inline, startOffset); + setInlineStyles(newInline, newStyles); + inline.after(newInline); + // If we're at the start node and offset is equal to 0 + // or current node is different to start node and + // different to end node or we're at the end node + // and the offset is equalto the node length + } else if ( + (this.#textNodeIterator.currentNode === startNode && + startOffset === 0) || + (this.#textNodeIterator.currentNode !== startNode && + this.#textNodeIterator.currentNode !== endNode) || + (this.#textNodeIterator.currentNode === endNode && + endOffset === endNode.nodeValue.length) + ) { + setInlineStyles(inline, newStyles); + + // If we're at end node + } else if ( + this.#textNodeIterator.currentNode === endNode && + endOffset < endNode.nodeValue.length + ) { + const newInline = splitInline(inline, endOffset); + setInlineStyles(inline, newStyles); + inline.after(newInline); + } + + // We've reached the final node so we can return safely. + if (this.#textNodeIterator.currentNode === expectedEndNode) return; + + this.#textNodeIterator.nextNode(); + } while (this.#textNodeIterator.currentNode); + } + + return this.#notifyStyleChange(); + } + + /** + * Applies styles to selection + * + * @param {Object.} newStyles + * @returns {void} + */ + applyStyles(newStyles) { + return this.#applyStylesTo( + this.startContainer, + this.startOffset, + this.endContainer, + this.endOffset, + newStyles + ); + } + + /** + * BROWSER FIXES + */ + fixInsertCompositionText() { + this.#fixInsertCompositionText = true; + } +} + +export default SelectionController; diff --git a/frontend/text-editor/editor/controllers/SelectionController.test.js b/frontend/text-editor/editor/controllers/SelectionController.test.js new file mode 100644 index 000000000..2047ae884 --- /dev/null +++ b/frontend/text-editor/editor/controllers/SelectionController.test.js @@ -0,0 +1,1166 @@ +import { expect, describe, test } from "vitest"; +import TextEditor from "~/editor/TextEditor"; +import { createRoot } from "~/editor/content/dom/Root"; +import { createEmptyParagraph, createParagraph } from "~/editor/content/dom/Paragraph"; +import { createInline } from "~/editor/content/dom/Inline"; +import { createLineBreak } from "~/editor/content/dom/LineBreak"; +import { TextEditorMock } from "~/test/TextEditorMock"; +import { SelectionController } from "./SelectionController"; +import { SelectionDirection } from "./SelectionDirection"; + +/* @vitest-environment jsdom */ + +/** + * Utility function to make focus and selections work properly in JSDOM. + * + * @param {Selection} selection + * @param {TextEditor} textEditor + * @param {Node} focusNode + * @param {number} [focusOffset=0] + * @param {Node} [anchorNode=null] + * @param {number} [anchorOffset=0] + */ +function focus(selection, textEditor, focusNode, focusOffset = 0, anchorNode = focusNode, anchorOffset = focusOffset) { + textEditor.element.focus(); + selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); + document.dispatchEvent(new Event("selectionchange")); +} + +describe("SelectionController", () => { + test("`selection` should return the Selection object kept by the SelectionController", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + expect(selectionController.selection).toBe(selection); + }); + + test("`range` should return the Range object kept by the SelectionController", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + // When the editor hasn't been focused + // range is null. + expect(selectionController.range).toBe(null); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + root.firstChild.firstChild.firstChild, + 0 + ); + expect(selectionController.range).toBeInstanceOf(Range); + }); + + test("`focusAtStart` should return `true` if the offset is 0", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + root.firstChild.firstChild.firstChild, + 0 + ); + expect(selectionController.focusAtStart).toBe(true); + }); + + test("`focusAtEnd` should return `true` if the offset is the length of the `textContent`", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello, World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + "Hello, World!".length, + root.firstChild.firstChild.firstChild, + 0 + ); + expect(selectionController.focusAtEnd).toBe(true); + }); + + test("`anchorAtStart` should return `true` if the offset is 0", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + root.firstChild.firstChild.firstChild, + 0 + ); + expect(selectionController.anchorAtStart).toBe(true); + }); + + test("`anchorAtEnd` should return `true` if the offset is the length of the `textContent`", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + root.firstChild.firstChild.firstChild, + "Hello, World!".length + ); + expect(selectionController.anchorAtEnd).toBe(true); + }); + + test("`direction` should return the direction of the focus and anchor nodes", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello, World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + root.firstChild.firstChild.firstChild, + 0 + ); + expect(selectionController.direction).toBe(SelectionDirection.NONE); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 5, + root.firstChild.firstChild.firstChild, + 0 + ); + expect(selectionController.direction).toBe(SelectionDirection.FORWARD); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + root.firstChild.firstChild.firstChild, + 5 + ); + expect(selectionController.direction).toBe(SelectionDirection.BACKWARD); + }); + + test("`insertText` should insert some text in a Text node", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + "Hello".length + ); + selectionController.insertText(", World!"); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf(HTMLSpanElement); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe("inline"); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(Text); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe("Hello, World!"); + }); + + test("`replaceLineBreak` should replace a
with some text", () => { + const textEditorMock = TextEditorMock.createTextEditorMockEmpty(); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild + ); + selectionController.replaceLineBreak("Hello, World!"); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf( + HTMLDivElement + ); + expect(textEditorMock.root.firstChild.dataset.itype).toBe( + "paragraph" + ); + expect( + textEditorMock.root.firstChild.firstChild + ).toBeInstanceOf(HTMLSpanElement); + expect( + textEditorMock.root.firstChild.firstChild.dataset.itype + ).toBe("inline"); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + ).toBeInstanceOf(Text); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + .nodeValue + ).toBe("Hello, World!"); + }); + + test("`removeBackwardText` should remove text in backward direction (backspace)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello, World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + "Hello, World!".length + ); + selectionController.removeBackwardText(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf( + HTMLDivElement + ); + expect(textEditorMock.root.firstChild.dataset.itype).toBe( + "paragraph" + ); + expect( + textEditorMock.root.firstChild.firstChild + ).toBeInstanceOf(HTMLSpanElement); + expect( + textEditorMock.root.firstChild.firstChild.dataset.itype + ).toBe("inline"); + expect(textEditorMock.root.textContent).toBe("Hello, World"); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + ).toBeInstanceOf(Text); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + .nodeValue + ).toBe("Hello, World"); + }); + + test("`removeBackwardText` should remove text in backward direction (backspace) and create a new empty paragraph when there's nothing left", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("H"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + "H".length + ); + selectionController.removeBackwardText(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe(""); + }); + + test("`mergeBackwardParagraph` should merge two paragraphs in backward direction (backspace)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, "))]), + createParagraph([createInline(new Text("World!"))]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.childNodes.item(1).firstChild.firstChild, + 0 + ); + selectionController.mergeBackwardParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.children.length).toBe(1); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + }); + + test("`mergeBackwardParagraph` should merge two paragraphs in backward direction (backspace)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, "))]), + createEmptyParagraph(), + createParagraph([createInline(new Text("World!"))]) + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + focus( + selection, + textEditorMock, + root.childNodes.item(2).firstChild.firstChild, + 0 + ); + selectionController.mergeBackwardParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe("inline"); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.textContent).toBe("Hello, "); + expect(textEditorMock.root.lastChild.textContent).toBe("World!"); + }); + + test("`mergeForwardParagraph` should merge two paragraphs in forward direction (backspace)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, "))]), + createParagraph([createInline(new Text("World!"))]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + root.firstChild.firstChild.firstChild.nodeValue.length + ); + selectionController.mergeForwardParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.children.length).toBe(1); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + }); + + test("`mergeForwardParagraph` should merge two paragraphs in forward direction (backspace)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, "))]), + createEmptyParagraph(), + createParagraph([createInline(new Text("World!"))]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.childNodes.item(2).firstChild.firstChild, + 0 + ); + selectionController.mergeBackwardParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.textContent).toBe("Hello, "); + expect(textEditorMock.root.lastChild.textContent).toBe("World!"); + }); + + test("`removeForwardText` should remove text in forward direction (delete)", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + ); + selectionController.removeForwardText(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf( + HTMLDivElement + ); + expect(textEditorMock.root.firstChild.dataset.itype).toBe( + "paragraph" + ); + expect( + textEditorMock.root.firstChild.firstChild + ).toBeInstanceOf(HTMLSpanElement); + expect( + textEditorMock.root.firstChild.firstChild.dataset.itype + ).toBe("inline"); + expect(textEditorMock.root.textContent).toBe("ello, World!"); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + ).toBeInstanceOf(Text); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + .nodeValue + ).toBe("ello, World!"); + }); + + test("`replaceText` should replace the selected text", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 7, + root.firstChild.firstChild.firstChild, + 12 + ); + selectionController.replaceText("Mundo"); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf( + HTMLDivElement + ); + expect(textEditorMock.root.firstChild.dataset.itype).toBe( + "paragraph" + ); + expect( + textEditorMock.root.firstChild.firstChild + ).toBeInstanceOf(HTMLSpanElement); + expect( + textEditorMock.root.firstChild.firstChild.dataset.itype + ).toBe("inline"); + expect(textEditorMock.root.textContent).toBe("Hello, Mundo!"); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + ).toBeInstanceOf(Text); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + .nodeValue + ).toBe("Hello, Mundo!"); + }); + + test("`replaceInlines` should replace the selected text in multiple inlines (2 completelly selected)", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithParagraph([ + createInline(new Text("Hello, ")), + createInline(new Text("World!")) + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + root.firstChild.lastChild.firstChild, + "World!".length + ); + selectionController.replaceInlines("Mundo"); + + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf( + HTMLDivElement + ); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe( + "paragraph" + ); + expect( + textEditorMock.root.firstChild.firstChild + ).toBeInstanceOf(HTMLSpanElement); + expect( + textEditorMock.root.firstChild.firstChild.dataset.itype + ).toBe("inline"); + expect(textEditorMock.root.textContent).toBe("Mundo"); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + ).toBeInstanceOf(Text); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + .nodeValue + ).toBe("Mundo"); + }); + + test("`replaceInlines` should replace the selected text in multiple inlines (2 partially selected)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([ + createInline(new Text("Hello, ")), + createInline(new Text("World!")), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 2, + root.firstChild.lastChild.firstChild, + "World!".length - 3 + ); + selectionController.replaceInlines("Mundo"); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(2); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("HeMundold!"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "HeMundo" + ); + expect(textEditorMock.root.firstChild.lastChild.firstChild).toBeInstanceOf( + Text + ); + expect(textEditorMock.root.firstChild.lastChild.firstChild.nodeValue).toBe( + "ld!" + ); + }); + + test("`replaceInlines` should replace the selected text in multiple inlines (1 partially selected, 1 completelly selected)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([ + createInline(new Text("Hello, ")), + createInline(new Text("World!")), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 2, + root.firstChild.lastChild.firstChild, + "World!".length + ); + selectionController.replaceInlines("Mundo"); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("HeMundo"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "HeMundo" + ); + }); + + test("`replaceInlines` should replace the selected text in multiple inlines (1 completelly selected, 1 partially selected)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([ + createInline(new Text("Hello, ")), + createInline(new Text("World!")), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + root.firstChild.lastChild.firstChild, + "World!".length - 3 + ); + selectionController.replaceInlines("Mundo"); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("Mundold!"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "Mundold!" + ); + }); + + test("`removeSelected` removes a word", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello, World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 7, + root.firstChild.lastChild.firstChild, + "Hello, World!".length - 1 + ); + selectionController.removeSelected(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("Hello, !"); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + ).toBeInstanceOf(Text); + expect( + textEditorMock.root.firstChild.firstChild.firstChild.nodeValue + ).toBe("Hello, !"); + }); + + test("`removeSelected` multiple inlines", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([ + createInline(new Text("Hello, ")), + createInline(new Text("World!")), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + root.firstChild.lastChild.firstChild, + "World!".length + ); + selectionController.removeSelected(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe(""); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + HTMLBRElement + ); + }); + + test("`removeSelected` multiple paragraphs", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([ + createInline(new Text("Hello, ")) + ]), + createParagraph([ + createInline(createLineBreak()) + ]), + createParagraph([ + createInline(new Text("World!")) + ]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.lastElementChild, + 0, + root.children.item(1).firstChild, + 0 + ); + selectionController.removeSelected(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children).toHaveLength(2); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "Hello, " + ); + expect(textEditorMock.root.lastChild.firstChild.firstChild).toBeInstanceOf( + Text + ); + expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe( + "World!" + ); + }); + + test("`removeSelected` and `removeBackwardParagraph`", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, World!"))]), + createParagraph([createInline(createLineBreak())]), + createParagraph([createInline(new Text("This is a test"))]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.lastElementChild.firstElementChild.firstChild, // This is a test text + 0, + root.lastElementChild.firstElementChild.firstChild, + "This is a test".length + ); + selectionController.removeSelected(); + selectionController.removeBackwardParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children).toHaveLength(2); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect( + textEditorMock.root.firstChild.firstChild.firstChild + ).toBeInstanceOf(Text); + expect( + textEditorMock.root.firstChild.firstChild.firstChild.nodeValue + ).toBe("Hello, World!"); + }); + + test("`removeSelected` and `removeForwardParagraph`", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, World!"))]), + createParagraph([createInline(createLineBreak())]), + createParagraph([createInline(new Text("This is a test"))]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstElementChild.firstElementChild.firstChild, // This is a test text + 0, + root.firstElementChild.firstElementChild.firstChild, + "Hello, World!".length + ); + selectionController.removeSelected(); + selectionController.removeForwardParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children).toHaveLength(2); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("This is a test"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + HTMLBRElement + ); + expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe( + "This is a test" + ); + }); + + test("performing a `removeSelected` after a `removeSelected` should do nothing", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, World!"))]), + createParagraph([createInline(createLineBreak())]), + createParagraph([createInline(new Text("This is a test"))]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstElementChild.firstElementChild.firstChild, // This is a test text + 0, + root.firstElementChild.firstElementChild.firstChild, + "Hello, World!".length + ); + selectionController.removeSelected(); + + // This should do nothing. + selectionController.removeSelected(); + + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children).toHaveLength(3); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.textContent).toBe("This is a test"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + HTMLBRElement + ); + expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe( + "This is a test" + ); + }); + + test("`removeSelected` removes everything", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, World!"))]), + createParagraph([createInline(createLineBreak())]), + createParagraph([createInline(new Text("This is a test"))]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstElementChild.firstElementChild.firstChild, // This is a test text + 0, + root.lastElementChild.firstElementChild.firstChild, + "This is a test".length + ); + selectionController.removeSelected(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children).toHaveLength(1); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.textContent).toBe(""); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + HTMLBRElement + ); + }); + + test("`removeSelected` removes everything and insert text", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([createInline(new Text("Hello, World!"))]), + createParagraph([createInline(createLineBreak())]), + createParagraph([createInline(new Text("This is a test"))]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstElementChild.firstElementChild.firstChild, // This is a test text + 0, + root.lastElementChild.firstElementChild.firstChild, + "This is a test".length + ); + selectionController.removeSelected(); + selectionController.replaceLineBreak("Hello, World!"); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children).toHaveLength(1); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.children).toHaveLength(1); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "Hello, World!" + ); + }); + + test('`applyStyles` to text', () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello, World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + root.firstChild.firstChild.firstChild.nodeValue.length - 1, + root.firstChild.firstChild.firstChild, + root.firstChild.firstChild.firstChild.nodeValue.length - 6 + ); + selectionController.applyStyles({ + "font-weight": "bold" + }); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.length).toBe(1); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.children.length).toBe(3); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe("inline"); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.children.item(0).textContent).toBe("Hello, "); + expect(textEditorMock.root.firstChild.children.item(1).textContent).toBe("World"); + expect(textEditorMock.root.firstChild.children.item(2).textContent).toBe("!"); + }); + + test('`applyStyles` to inlines', () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraph([ + createInline(new Text("Hello, "), { + "font-style": "italic" + }), + createInline(new Text("World!"), { + "font-style": "oblique" + }) + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 2, + root.firstChild.lastChild.firstChild, + root.firstChild.lastChild.firstChild.nodeValue.length - 3 + ); + selectionController.applyStyles({ + "font-weight": "bold" + }); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.length).toBe(1); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.children.length).toBe(4); + expect(textEditorMock.root.firstChild.children.item(0).dataset.itype).toBe("inline"); + expect(textEditorMock.root.firstChild.children.item(0).textContent).toBe("He"); + expect(textEditorMock.root.firstChild.children.item(1).dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.firstChild.children.item(1).textContent).toBe("llo, "); + expect(textEditorMock.root.firstChild.children.item(2).dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.firstChild.children.item(2).textContent).toBe("Wor"); + expect(textEditorMock.root.firstChild.children.item(3).dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.firstChild.children.item(3).textContent).toBe("ld!"); + }); + + test('`applyStyles` to paragraphs', () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([ + createInline(new Text("Hello, "), { + "font-style": "italic", + }), + ]), + createParagraph([ + createInline(new Text("World!"), { + "font-style": "oblique", + }), + ]), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 2, + root.lastChild.firstChild.firstChild, + root.lastChild.firstChild.firstChild.nodeValue.length - 3 + ); + selectionController.applyStyles({ + "font-weight": "bold", + }); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement + ); + expect(textEditorMock.root.firstChild.children.length).toBe(2); + expect(textEditorMock.root.firstChild.children.item(0).dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.firstChild.children.item(0).textContent).toBe( + "He" + ); + expect(textEditorMock.root.firstChild.children.item(1).dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.firstChild.children.item(1).textContent).toBe( + "llo, " + ); + expect(textEditorMock.root.lastChild.children.length).toBe(2); + expect(textEditorMock.root.lastChild.children.item(0).dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.lastChild.children.item(0).textContent).toBe( + "Wor" + ); + expect(textEditorMock.root.lastChild.children.item(1).dataset.itype).toBe( + "inline" + ); + expect(textEditorMock.root.lastChild.children.item(1).textContent).toBe( + "ld!" + ); + }); +}); diff --git a/frontend/text-editor/editor/controllers/SelectionDirection.js b/frontend/text-editor/editor/controllers/SelectionDirection.js new file mode 100644 index 000000000..07e17ba98 --- /dev/null +++ b/frontend/text-editor/editor/controllers/SelectionDirection.js @@ -0,0 +1,16 @@ +/** + * Indicates the direction of the selection. + * + * @readonly + * @enum {number} + */ +export const SelectionDirection = { + /** The anchorNode is behind the focusNode */ + FORWARD: 1, + /** The focusNode and the anchorNode are collapsed */ + NONE: 0, + /** The focusNode is behind the anchorNode */ + BACKWARD: -1, +}; + +export default SelectionDirection; diff --git a/frontend/text-editor/editor/debug/SelectionControllerDebug.js b/frontend/text-editor/editor/debug/SelectionControllerDebug.js new file mode 100644 index 000000000..d79ec09e2 --- /dev/null +++ b/frontend/text-editor/editor/debug/SelectionControllerDebug.js @@ -0,0 +1,75 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Used for handling debugging. + */ +export class SelectionControllerDebug { + /** + * @type {Object.} + */ + #elements = null; + + /** + * Constructor + * + * @param {Object.} elements List of elements used to debug the SelectionController + */ + constructor(elements) { + this.#elements = elements; + } + + getNodeDescription(node, offset) { + if (!node) return "null"; + return `${node.nodeName} ${ + node.nodeType === Node.TEXT_NODE + ? node.nodeValue + (typeof offset === "number" ? `(${offset})` : "") + : node.dataset.itype + }`; + } + + update(selectionController) { + this.#elements.direction.value = selectionController.direction; + this.#elements.multiElement.checked = selectionController.isMulti; + this.#elements.multiInlineElement.checked = + selectionController.isMultiInline; + this.#elements.multiParagraphElement.checked = + selectionController.isMultiParagraph; + this.#elements.isParagraphStart.checked = + selectionController.isParagraphStart; + this.#elements.isParagraphEnd.checked = selectionController.isParagraphEnd; + this.#elements.isInlineStart.checked = selectionController.isInlineStart; + this.#elements.isInlineEnd.checked = selectionController.isInlineEnd; + this.#elements.isTextAnchor.checked = selectionController.isTextAnchor; + this.#elements.isTextFocus.checked = selectionController.isTextFocus; + this.#elements.focusNode.value = this.getNodeDescription( + selectionController.focusNode, + selectionController.focusOffset + ); + this.#elements.focusOffset.value = selectionController.focusOffset; + this.#elements.anchorNode.value = this.getNodeDescription( + selectionController.anchorNode, + selectionController.anchorOffset + ); + this.#elements.anchorOffset.value = selectionController.anchorOffset; + this.#elements.focusInline.value = this.getNodeDescription( + selectionController.focusInline + ); + this.#elements.anchorInline.value = this.getNodeDescription( + selectionController.anchorInline + ); + this.#elements.focusParagraph.value = this.getNodeDescription( + selectionController.focusParagraph + ); + this.#elements.anchorParagraph.value = this.getNodeDescription( + selectionController.anchorParagraph + ); + this.#elements.startContainer.value = this.getNodeDescription(selectionController.startContainer); + this.#elements.endContainer.value = this.getNodeDescription(selectionController.endContainer); + } +} diff --git a/frontend/text-editor/editor/layout/LayoutType.js b/frontend/text-editor/editor/layout/LayoutType.js new file mode 100644 index 000000000..fd6fbb70d --- /dev/null +++ b/frontend/text-editor/editor/layout/LayoutType.js @@ -0,0 +1,19 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Enumeration of types of layout. + * + * @enum {string} + */ +export const LayoutType = { + FULL: "full", + PARTIAL: "partial", +}; + +export default LayoutType; diff --git a/frontend/text-editor/editor/selection/Imposter.js b/frontend/text-editor/editor/selection/Imposter.js new file mode 100644 index 000000000..992d486d8 --- /dev/null +++ b/frontend/text-editor/editor/selection/Imposter.js @@ -0,0 +1,31 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +/** + * Creates a new selection imposter from a list of client rects. + * + * @param {DOMRect} referenceRect + * @param {DOMRectList} clientRects + * @returns {DocumentFragment} + */ +export function createSelectionImposterFromClientRects( + referenceRect, + clientRects +) { + const fragment = document.createDocumentFragment(); + for (const rect of clientRects) { + const rectElement = document.createElement("div"); + rectElement.className = "selection-imposter-rect"; + rectElement.style.left = `${rect.x - referenceRect.x}px`; + rectElement.style.top = `${rect.y - referenceRect.y}px`; + rectElement.style.width = `${rect.width}px`; + rectElement.style.height = `${rect.height}px`; + fragment.appendChild(rectElement); + } + return fragment; +} diff --git a/frontend/text-editor/editor/selection/Imposter.spec.js b/frontend/text-editor/editor/selection/Imposter.spec.js new file mode 100644 index 000000000..b0b619a87 --- /dev/null +++ b/frontend/text-editor/editor/selection/Imposter.spec.js @@ -0,0 +1,14 @@ +import { expect, test } from "vitest"; +import { createSelectionImposterFromClientRects } from "./Imposter.js"; + +/* @vitest-environment jsdom */ +test("Create selection DOM rects from client rects", () => { + const rect = new DOMRect(20, 20, 100, 50); + const clientRects = [ + new DOMRect(20, 20, 100, 20), + new DOMRect(20, 50, 50, 20) + ]; + const fragment = createSelectionImposterFromClientRects(rect, clientRects); + expect(fragment).toBeInstanceOf(DocumentFragment); + expect(fragment.childNodes).toHaveLength(2); +}); diff --git a/frontend/text-editor/index.html b/frontend/text-editor/index.html new file mode 100644 index 000000000..2410dd764 --- /dev/null +++ b/frontend/text-editor/index.html @@ -0,0 +1,226 @@ + + + + + + + + + + Penpot - Text Editor Playground + + + +
+
+ Styles + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Debug +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ +
+ + + diff --git a/frontend/text-editor/jsconfig.json b/frontend/text-editor/jsconfig.json new file mode 100644 index 000000000..555103d55 --- /dev/null +++ b/frontend/text-editor/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "~/*": ["./*"] + } + } +} diff --git a/frontend/text-editor/main.js b/frontend/text-editor/main.js new file mode 100644 index 000000000..dbcbc91e4 --- /dev/null +++ b/frontend/text-editor/main.js @@ -0,0 +1,216 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import "./style.css"; +import "./editor/TextEditor.css"; +import { TextEditor } from "./editor/TextEditor"; +import { SelectionControllerDebug } from "./editor/debug/SelectionControllerDebug"; + +const searchParams = new URLSearchParams(location.search); +const debug = searchParams.has("debug") + ? searchParams.get("debug").split(",") + : []; + +const textEditorSelectionImposterElement = document.getElementById( + "text-editor-selection-imposter" +); + +const textEditorElement = document.querySelector(".text-editor-content"); +const textEditor = new TextEditor(textEditorElement, { + styleDefaults: { + "font-family": "sourcesanspro", + "font-size": "14", + "font-weight": "400", + "font-style": "normal", + "line-height": "1.2", + "letter-spacing": "0", + "direction": "ltr", + "text-align": "left", + "text-transform": "none", + "text-decoration": "none", + "--typography-ref-id": '["~#\'",null]', + "--typography-ref-file": '["~#\'",null]', + "--font-id": '["~#\'","sourcesanspro"]', + "--fills": '[["^ ","~:fill-color","#000000","~:fill-opacity",1]]' + }, + selectionImposterElement: textEditorSelectionImposterElement, + debug: new SelectionControllerDebug({ + direction: document.getElementById("direction"), + multiElement: document.getElementById("multi"), + multiInlineElement: document.getElementById("multi-inline"), + multiParagraphElement: document.getElementById("multi-paragraph"), + isParagraphStart: document.getElementById("is-paragraph-start"), + isParagraphEnd: document.getElementById("is-paragraph-end"), + isInlineStart: document.getElementById("is-inline-start"), + isInlineEnd: document.getElementById("is-inline-end"), + isTextAnchor: document.getElementById("is-text-anchor"), + isTextFocus: document.getElementById("is-text-focus"), + focusNode: document.getElementById("focus-node"), + focusOffset: document.getElementById("focus-offset"), + focusInline: document.getElementById("focus-inline"), + focusParagraph: document.getElementById("focus-paragraph"), + anchorNode: document.getElementById("anchor-node"), + anchorOffset: document.getElementById("anchor-offset"), + anchorInline: document.getElementById("anchor-inline"), + anchorParagraph: document.getElementById("anchor-paragraph"), + startContainer: document.getElementById("start-container"), + startOffset: document.getElementById("start-offset"), + endContainer: document.getElementById("end-container"), + endOffset: document.getElementById("end-offset"), + }), +}); + +const fontFamilyElement = document.getElementById("font-family"); +const fontSizeElement = document.getElementById("font-size"); +const fontWeightElement = document.getElementById("font-weight"); +const fontStyleElement = document.getElementById("font-style"); + +const directionLTRElement = document.getElementById("direction-ltr"); +const directionRTLElement = document.getElementById("direction-rtl"); + +const lineHeightElement = document.getElementById("line-height"); +const letterSpacingElement = document.getElementById("letter-spacing"); + +const textAlignLeftElement = document.getElementById("text-align-left"); +const textAlignCenterElement = document.getElementById("text-align-center"); +const textAlignRightElement = document.getElementById("text-align-right"); +const textAlignJustifyElement = document.getElementById("text-align-justify"); + +function onDirectionChange(e) { + if (debug.includes("events")) { + console.log(e); + } + if (e.target.checked) { + textEditor.applyStylesToSelection({ + "direction": e.target.value + }); + } +} + +directionLTRElement.addEventListener("change", onDirectionChange); +directionRTLElement.addEventListener("change", onDirectionChange); + +function onTextAlignChange(e) { + if (debug.includes("events")) { + console.log(e); + } + if (e.target.checked) { + textEditor.applyStylesToSelection({ + "text-align": e.target.value + }); + } +} + +textAlignLeftElement.addEventListener("change", onTextAlignChange); +textAlignCenterElement.addEventListener("change", onTextAlignChange); +textAlignRightElement.addEventListener("change", onTextAlignChange); +textAlignJustifyElement.addEventListener("change", onTextAlignChange); + +fontFamilyElement.addEventListener("change", (e) => { + if (debug.includes("events")) { + console.log(e); + } + textEditor.applyStylesToSelection({ + "font-family": e.target.value, + }); +}); + +fontWeightElement.addEventListener("change", (e) => { + if (debug.includes("events")) { + console.log(e); + } + textEditor.applyStylesToSelection({ + "font-weight": e.target.value, + }); +}); + +fontSizeElement.addEventListener("change", (e) => { + if (debug.includes("events")) { + console.log(e); + } + textEditor.applyStylesToSelection({ + "font-size": e.target.value, + }); +}); + +lineHeightElement.addEventListener("change", (e) => { + if (debug.includes("events")) { + console.log(e); + } + textEditor.applyStylesToSelection({ + "line-height": e.target.value + }) +}) + +letterSpacingElement.addEventListener("change", (e) => { + if (debug.includes("events")) { + console.log(e); + } + textEditor.applyStylesToSelection({ + "letter-spacing": e.target.value + }) +}) + +fontStyleElement.addEventListener("change", (e) => { + if (debug.includes("events")) { + console.log(e); + } + textEditor.applyStylesToSelection({ + "font-style": e.target.value, + }); +}); + +function formatHTML(html, options) { + const spaces = options?.spaces ?? 4; + let indent = 0; + return html.replace(/<\/?(.*?)>/g, (fullMatch) => { + let str = fullMatch + "\n"; + if (fullMatch.startsWith("") --indent; + } + return str; + }); +} + +const outputElement = document.getElementById("output"); +textEditorElement.addEventListener("input", (e) => { + if (debug.includes("events")) { + console.log(e); + } + outputElement.textContent = formatHTML(textEditor.element.innerHTML); +}); + +textEditor.addEventListener("stylechange", (e) => { + if (debug.includes("events")) { + console.log(e); + } + const fontSize = parseInt(e.detail.getPropertyValue("font-size"), 10); + const fontWeight = e.detail.getPropertyValue("font-weight"); + const fontStyle = e.detail.getPropertyValue("font-style"); + const fontFamily = e.detail.getPropertyValue("font-family"); + + fontFamilyElement.value = fontFamily; + fontSizeElement.value = fontSize; + fontStyleElement.value = fontStyle; + fontWeightElement.value = fontWeight; + + const textAlign = e.detail.getPropertyValue("text-align"); + textAlignLeftElement.checked = textAlign === "left"; + textAlignCenterElement.checked = textAlign === "center"; + textAlignRightElement.checked = textAlign === "right"; + textAlignJustifyElement.checked = textAlign === "justify"; + + const direction = e.detail.getPropertyValue("direction"); + directionLTRElement.checked = direction === "ltr"; + directionRTLElement.checked = direction === "rtl"; +}); diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json new file mode 100644 index 000000000..6ad6d9cb4 --- /dev/null +++ b/frontend/text-editor/package.json @@ -0,0 +1,30 @@ +{ + "name": "@penpot/text-editor", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "editor/TextEditor.js", + "scripts": { + "dev": "vite", + "build": "./scripts/build.sh", + "preview": "vite preview", + "coverage": "vitest run --coverage", + "test": "vitest --run", + "test:watch": "vitest", + "test:watch:ui": "vitest --ui", + "test:watch:e2e": "vitest --browser" + }, + "devDependencies": { + "@playwright/test": "^1.45.1", + "@types/node": "^20.14.10", + "@vitest/browser": "^1.6.0", + "@vitest/coverage-v8": "^1.6.0", + "@vitest/ui": "^1.6.0", + "esbuild": "^0.24.0", + "jsdom": "^25.0.0", + "playwright": "^1.45.1", + "vite": "^5.3.1", + "vitest": "^1.6.0" + }, + "packageManager": "yarn@4.3.1" +} diff --git a/frontend/text-editor/public/javascript.svg b/frontend/text-editor/public/javascript.svg new file mode 100644 index 000000000..f9abb2b72 --- /dev/null +++ b/frontend/text-editor/public/javascript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/text-editor/public/vite.svg b/frontend/text-editor/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/frontend/text-editor/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/text-editor/scripts/build.sh b/frontend/text-editor/scripts/build.sh new file mode 100755 index 000000000..0e316b616 --- /dev/null +++ b/frontend/text-editor/scripts/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +esbuild --bundle --minify --sourcemap --target=es2021 --format=esm --platform=browser editor/TextEditor.js --outfile=dist/TextEditor.mjs diff --git a/frontend/text-editor/style.css b/frontend/text-editor/style.css new file mode 100644 index 000000000..a2e71ddda --- /dev/null +++ b/frontend/text-editor/style.css @@ -0,0 +1,14 @@ +:root { + background-color: #333; + color: #eee; +} + +.text-editor-container { + background-color: white; +} + +#output { + font-family: monospace; + padding: 1rem; + border: 1px solid #333; +} diff --git a/frontend/text-editor/test/TextEditorMock.js b/frontend/text-editor/test/TextEditorMock.js new file mode 100644 index 000000000..f41cb2107 --- /dev/null +++ b/frontend/text-editor/test/TextEditorMock.js @@ -0,0 +1,127 @@ +import { createRoot } from "~/editor/content/dom/Root"; +import { createParagraph } from "~/editor/content/dom/Paragraph"; +import { createEmptyInline, createInline } from "~/editor/content/dom/Inline"; +import { createLineBreak } from "~/editor/content/dom/LineBreak"; + +export class TextEditorMock extends EventTarget { + /** + * Returns the template used for the text editor mock. + * + * @returns {HTMLDivElement} + */ + static getTemplate() { + const container = document.createElement("div"); + container.id = "test"; + container.innerHTML = `
+
+
+
`; + document.body.appendChild(container); + return container; + } + + /** + * Creates an editor with a custom root. + * + * @param {HTMLDivElement} root + * @returns {HTMLDivElement} + */ + static createTextEditorMockWithRoot(root) { + const container = TextEditorMock.getTemplate(); + const selectionImposterElement = container.querySelector( + ".text-editor-selection-imposter" + ); + const textEditorMock = new TextEditorMock( + container.querySelector(".text-editor-content"), + { + root, + selectionImposterElement, + } + ); + return textEditorMock; + } + + /** + * Creates a TextEditor mock with paragraphs. + * + * @param {Array} paragraphs + * @returns + */ + static createTextEditorMockWithParagraphs(paragraphs) { + const root = createRoot(paragraphs); + return this.createTextEditorMockWithRoot(root); + } + + /** + * Creates an empty TextEditor mock. + * + * @returns + */ + static createTextEditorMockEmpty() { + const root = createRoot([ + createParagraph([createInline(createLineBreak())]), + ]); + return this.createTextEditorMockWithRoot(root); + } + + /** + * Creates a TextEditor mock with some text. + * + * NOTE: If the text is empty an empty inline will be + * created. + * + * @param {string} text + * @returns + */ + static createTextEditorMockWithText(text) { + return this.createTextEditorMockWithParagraphs([ + createParagraph([ + text.length === 0 + ? createEmptyInline() + : createInline(new Text(text)) + ]), + ]); + } + + /** + * Creates a TextEditor mock with some inlines and + * only one paragraph. + * + * @param {Array} inlines + * @returns + */ + static createTextEditorMockWithParagraph(inlines) { + return this.createTextEditorMockWithParagraphs([createParagraph(inlines)]); + } + + #element = null; + #root = null; + #selectionImposterElement = null; + + constructor(element, options) { + super(); + this.#element = element; + this.#root = options?.root; + this.#selectionImposterElement = options?.selectionImposterElement; + this.#element.appendChild(options?.root); + } + + get element() { + return this.#element; + } + + get root() { + return this.#root; + } +} + +export default TextEditorMock; diff --git a/frontend/text-editor/vite.config.js b/frontend/text-editor/vite.config.js new file mode 100644 index 000000000..4ff019fc1 --- /dev/null +++ b/frontend/text-editor/vite.config.js @@ -0,0 +1,46 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; +import { coverageConfigDefaults } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + "~": resolve("."), + }, + }, + build: { + minify: false, + sourcemap: true, + lib: { + entry: "editor/TextEditor.js", + name: "TextEditor", + fileName: "TextEditor", + formats: ["es"], + }, + terserOptions: { + compress: true, + mangle: true, + }, + }, + test: { + coverage: { + enabled: true, + exclude: ["main.js", "**/scripts/**", ...coverageConfigDefaults.exclude], + }, + poolOptions: { + threads: { + singleThread: true, + }, + }, + environmentOptions: { + jsdom: { + resources: "usable", + }, + }, + browser: { + name: "chromium", + provider: "playwright", + }, + exclude: ["main.js", "**/scripts/**", "**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/frontend/text-editor/yarn.lock b/frontend/text-editor/yarn.lock new file mode 100644 index 000000000..20f460e3c --- /dev/null +++ b/frontend/text-editor/yarn.lock @@ -0,0 +1,3072 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@ampproject/remapping@npm:^2.2.1": + version: 2.3.0 + resolution: "@ampproject/remapping@npm:2.3.0" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.24.7": + version: 7.24.7 + resolution: "@babel/helper-string-parser@npm:7.24.7" + checksum: 10c0/47840c7004e735f3dc93939c77b099bb41a64bf3dda0cae62f60e6f74a5ff80b63e9b7cf77b5ec25a324516381fc994e1f62f922533236a8e3a6af57decb5e1e + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.24.7": + version: 7.24.7 + resolution: "@babel/helper-validator-identifier@npm:7.24.7" + checksum: 10c0/87ad608694c9477814093ed5b5c080c2e06d44cb1924ae8320474a74415241223cc2a725eea2640dd783ff1e3390e5f95eede978bc540e870053152e58f1d651 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.24.4": + version: 7.24.7 + resolution: "@babel/parser@npm:7.24.7" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/8b244756872185a1c6f14b979b3535e682ff08cb5a2a5fd97cc36c017c7ef431ba76439e95e419d43000c5b07720495b00cf29a7f0d9a483643d08802b58819b + languageName: node + linkType: hard + +"@babel/types@npm:^7.24.0, @babel/types@npm:^7.8.3": + version: 7.24.7 + resolution: "@babel/types@npm:7.24.7" + dependencies: + "@babel/helper-string-parser": "npm:^7.24.7" + "@babel/helper-validator-identifier": "npm:^7.24.7" + to-fast-properties: "npm:^2.0.0" + checksum: 10c0/d9ecbfc3eb2b05fb1e6eeea546836ac30d990f395ef3fe3f75ced777a222c3cfc4489492f72e0ce3d9a5a28860a1ce5f81e66b88cf5088909068b3ff4fab72c1 + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^0.2.3": + version: 0.2.3 + resolution: "@bcoe/v8-coverage@npm:0.2.3" + checksum: 10c0/6b80ae4cb3db53f486da2dc63b6e190a74c8c3cca16bb2733f234a0b6a9382b09b146488ae08e2b22cf00f6c83e20f3e040a2f7894f05c045c946d6a090b1d52 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/aix-ppc64@npm:0.21.5" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/aix-ppc64@npm:0.24.0" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm64@npm:0.21.5" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/android-arm64@npm:0.24.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm@npm:0.21.5" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/android-arm@npm:0.24.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-x64@npm:0.21.5" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/android-x64@npm:0.24.0" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-arm64@npm:0.21.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/darwin-arm64@npm:0.24.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-x64@npm:0.21.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/darwin-x64@npm:0.24.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-arm64@npm:0.21.5" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/freebsd-arm64@npm:0.24.0" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-x64@npm:0.21.5" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/freebsd-x64@npm:0.24.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm64@npm:0.21.5" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-arm64@npm:0.24.0" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm@npm:0.21.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-arm@npm:0.24.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ia32@npm:0.21.5" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-ia32@npm:0.24.0" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-loong64@npm:0.21.5" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-loong64@npm:0.24.0" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-mips64el@npm:0.21.5" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-mips64el@npm:0.24.0" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ppc64@npm:0.21.5" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-ppc64@npm:0.24.0" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-riscv64@npm:0.21.5" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-riscv64@npm:0.24.0" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-s390x@npm:0.21.5" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-s390x@npm:0.24.0" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-x64@npm:0.21.5" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-x64@npm:0.24.0" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/netbsd-x64@npm:0.21.5" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/netbsd-x64@npm:0.24.0" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/openbsd-arm64@npm:0.24.0" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/openbsd-x64@npm:0.21.5" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/openbsd-x64@npm:0.24.0" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/sunos-x64@npm:0.21.5" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/sunos-x64@npm:0.24.0" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-arm64@npm:0.21.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/win32-arm64@npm:0.24.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-ia32@npm:0.21.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/win32-ia32@npm:0.24.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-x64@npm:0.21.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/win32-x64@npm:0.24.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@isaacs/cliui@npm:^8.0.2": + version: 8.0.2 + resolution: "@isaacs/cliui@npm:8.0.2" + dependencies: + string-width: "npm:^5.1.2" + string-width-cjs: "npm:string-width@^4.2.0" + strip-ansi: "npm:^7.0.1" + strip-ansi-cjs: "npm:strip-ansi@^6.0.1" + wrap-ansi: "npm:^8.1.0" + wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" + checksum: 10c0/b1bf42535d49f11dc137f18d5e4e63a28c5569de438a221c369483731e9dac9fb797af554e8bf02b6192d1e5eba6e6402cf93900c3d0ac86391d00d04876789e + languageName: node + linkType: hard + +"@istanbuljs/schema@npm:^0.1.2": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a + languageName: node + linkType: hard + +"@jest/schemas@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/schemas@npm:29.6.3" + dependencies: + "@sinclair/typebox": "npm:^0.27.8" + checksum: 10c0/b329e89cd5f20b9278ae1233df74016ebf7b385e0d14b9f4c1ad18d096c4c19d1e687aa113a9c976b16ec07f021ae53dea811fb8c1248a50ac34fbe009fdf6be + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.5 + resolution: "@jridgewell/gen-mapping@npm:0.3.5" + dependencies: + "@jridgewell/set-array": "npm:^1.2.1" + "@jridgewell/sourcemap-codec": "npm:^1.4.10" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/1be4fd4a6b0f41337c4f5fdf4afc3bd19e39c3691924817108b82ffcb9c9e609c273f936932b9fba4b3a298ce2eb06d9bff4eb1cc3bd81c4f4ee1b4917e25feb + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/set-array@npm:^1.2.1": + version: 1.2.1 + resolution: "@jridgewell/set-array@npm:1.2.1" + checksum: 10c0/2a5aa7b4b5c3464c895c802d8ae3f3d2b92fcbe84ad12f8d0bfbb1f5ad006717e7577ee1fd2eac00c088abe486c7adb27976f45d2941ff6b0b92b2c3302c60f4 + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15": + version: 1.4.15 + resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" + checksum: 10c0/0c6b5ae663087558039052a626d2d7ed5208da36cfd707dcc5cea4a07cfc918248403dcb5989a8f7afaf245ce0573b7cc6fd94c4a30453bd10e44d9363940ba5 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24": + version: 0.3.25 + resolution: "@jridgewell/trace-mapping@npm:0.3.25" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/3d1ce6ebc69df9682a5a8896b414c6537e428a1d68b02fcc8363b04284a8ca0df04d0ee3013132252ab14f2527bc13bea6526a912ecb5658f0e39fd2860b4df4 + languageName: node + linkType: hard + +"@nodelib/fs.scandir@npm:2.1.5": + version: 2.1.5 + resolution: "@nodelib/fs.scandir@npm:2.1.5" + dependencies: + "@nodelib/fs.stat": "npm:2.0.5" + run-parallel: "npm:^1.1.9" + checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb + languageName: node + linkType: hard + +"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": + version: 2.0.5 + resolution: "@nodelib/fs.stat@npm:2.0.5" + checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d + languageName: node + linkType: hard + +"@nodelib/fs.walk@npm:^1.2.3": + version: 1.2.8 + resolution: "@nodelib/fs.walk@npm:1.2.8" + dependencies: + "@nodelib/fs.scandir": "npm:2.1.5" + fastq: "npm:^1.6.0" + checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 + languageName: node + linkType: hard + +"@npmcli/agent@npm:^2.0.0": + version: 2.2.2 + resolution: "@npmcli/agent@npm:2.2.2" + dependencies: + agent-base: "npm:^7.1.0" + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.1" + lru-cache: "npm:^10.0.1" + socks-proxy-agent: "npm:^8.0.3" + checksum: 10c0/325e0db7b287d4154ecd164c0815c08007abfb07653cc57bceded17bb7fd240998a3cbdbe87d700e30bef494885eccc725ab73b668020811d56623d145b524ae + languageName: node + linkType: hard + +"@npmcli/fs@npm:^3.1.0": + version: 3.1.1 + resolution: "@npmcli/fs@npm:3.1.1" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/c37a5b4842bfdece3d14dfdb054f73fe15ed2d3da61b34ff76629fb5b1731647c49166fd2a8bf8b56fcfa51200382385ea8909a3cbecdad612310c114d3f6c99 + languageName: node + linkType: hard + +"@penpot/text-editor@workspace:.": + version: 0.0.0-use.local + resolution: "@penpot/text-editor@workspace:." + dependencies: + "@playwright/test": "npm:^1.45.1" + "@types/node": "npm:^20.14.10" + "@vitest/browser": "npm:^1.6.0" + "@vitest/coverage-v8": "npm:^1.6.0" + "@vitest/ui": "npm:^1.6.0" + esbuild: "npm:^0.24.0" + jsdom: "npm:^25.0.0" + playwright: "npm:^1.45.1" + vite: "npm:^5.3.1" + vitest: "npm:^1.6.0" + languageName: unknown + linkType: soft + +"@pkgjs/parseargs@npm:^0.11.0": + version: 0.11.0 + resolution: "@pkgjs/parseargs@npm:0.11.0" + checksum: 10c0/5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd + languageName: node + linkType: hard + +"@playwright/test@npm:^1.45.1": + version: 1.45.1 + resolution: "@playwright/test@npm:1.45.1" + dependencies: + playwright: "npm:1.45.1" + bin: + playwright: cli.js + checksum: 10c0/ba214addee06e846041b819b8bcc2b04dae1beb36d05cd0942bb0fc7f9742002c881e2058b75aba37a8baef9a3aaff66e818b20b8013e9020d2cc28ff0c655d7 + languageName: node + linkType: hard + +"@polka/url@npm:^1.0.0-next.24": + version: 1.0.0-next.25 + resolution: "@polka/url@npm:1.0.0-next.25" + checksum: 10c0/ef61f0a0fe94bb6e1143fc5b9d5a12e6ca9dbd2c57843ebf81db432c21b9f1005c09e8a1ef8b6d5ddfa42146ca65b640feb2d353bd0d3546da46ba59e48a5349 + languageName: node + linkType: hard + +"@rollup/rollup-android-arm-eabi@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.18.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-android-arm64@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-android-arm64@npm:4.18.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.18.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.18.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.18.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.18.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.18.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-musl@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.18.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-powerpc64le-gnu@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.18.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.18.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.18.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-gnu@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.18.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-musl@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.18.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.18.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-ia32-msvc@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.18.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.18.0": + version: 4.18.0 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.18.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@sinclair/typebox@npm:^0.27.8": + version: 0.27.8 + resolution: "@sinclair/typebox@npm:0.27.8" + checksum: 10c0/ef6351ae073c45c2ac89494dbb3e1f87cc60a93ce4cde797b782812b6f97da0d620ae81973f104b43c9b7eaa789ad20ba4f6a1359f1cc62f63729a55a7d22d4e + languageName: node + linkType: hard + +"@types/estree@npm:1.0.5, @types/estree@npm:^1.0.0": + version: 1.0.5 + resolution: "@types/estree@npm:1.0.5" + checksum: 10c0/b3b0e334288ddb407c7b3357ca67dbee75ee22db242ca7c56fe27db4e1a31989cb8af48a84dd401deb787fe10cc6b2ab1ee82dc4783be87ededbe3d53c79c70d + languageName: node + linkType: hard + +"@types/node@npm:^20.14.10": + version: 20.14.10 + resolution: "@types/node@npm:20.14.10" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10c0/0b06cff14365c2d0085dc16cc8cbea5c40ec09cfc1fea966be9eeecf35562760bfde8f88e86de6edfaf394501236e229d9c1084fad04fb4dec472ae245d8ae69 + languageName: node + linkType: hard + +"@vitest/browser@npm:^1.6.0": + version: 1.6.0 + resolution: "@vitest/browser@npm:1.6.0" + dependencies: + "@vitest/utils": "npm:1.6.0" + magic-string: "npm:^0.30.5" + sirv: "npm:^2.0.4" + peerDependencies: + playwright: "*" + vitest: 1.6.0 + webdriverio: "*" + peerDependenciesMeta: + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + checksum: 10c0/1c1025ff78bb014cd4f6a6d2ead0cbc355893df68566ea6a393f53f369ee669d239f1127bc1347f6aa1ae2d363cdecb049f9bff30def99b9c833981e272ee208 + languageName: node + linkType: hard + +"@vitest/coverage-v8@npm:^1.6.0": + version: 1.6.0 + resolution: "@vitest/coverage-v8@npm:1.6.0" + dependencies: + "@ampproject/remapping": "npm:^2.2.1" + "@bcoe/v8-coverage": "npm:^0.2.3" + debug: "npm:^4.3.4" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-lib-source-maps: "npm:^5.0.4" + istanbul-reports: "npm:^3.1.6" + magic-string: "npm:^0.30.5" + magicast: "npm:^0.3.3" + picocolors: "npm:^1.0.0" + std-env: "npm:^3.5.0" + strip-literal: "npm:^2.0.0" + test-exclude: "npm:^6.0.0" + peerDependencies: + vitest: 1.6.0 + checksum: 10c0/a7beaf2a88b628a9dc16ddca7589f2b2e4681598e6788d68423dffbb06c608edc52b2dd421ada069eb3cfd83f8f592ddd6e8b8db2d037bf13965a56c5e5835ac + languageName: node + linkType: hard + +"@vitest/expect@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/expect@npm:1.6.0" + dependencies: + "@vitest/spy": "npm:1.6.0" + "@vitest/utils": "npm:1.6.0" + chai: "npm:^4.3.10" + checksum: 10c0/a4351f912a70543e04960f5694f1f1ac95f71a856a46e87bba27d3eb72a08c5d11d35021cbdc6077452a152e7d93723fc804bba76c2cc53c8896b7789caadae3 + languageName: node + linkType: hard + +"@vitest/runner@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/runner@npm:1.6.0" + dependencies: + "@vitest/utils": "npm:1.6.0" + p-limit: "npm:^5.0.0" + pathe: "npm:^1.1.1" + checksum: 10c0/27d67fa51f40effe0e41ee5f26563c12c0ef9a96161f806036f02ea5eb9980c5cdf305a70673942e7a1e3d472d4d7feb40093ae93024ef1ccc40637fc65b1d2f + languageName: node + linkType: hard + +"@vitest/snapshot@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/snapshot@npm:1.6.0" + dependencies: + magic-string: "npm:^0.30.5" + pathe: "npm:^1.1.1" + pretty-format: "npm:^29.7.0" + checksum: 10c0/be027fd268d524589ff50c5fad7b4faa1ac5742b59ac6c1dc6f5a3930aad553560e6d8775e90ac4dfae4be746fc732a6f134ba95606a1519707ce70db3a772a5 + languageName: node + linkType: hard + +"@vitest/spy@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/spy@npm:1.6.0" + dependencies: + tinyspy: "npm:^2.2.0" + checksum: 10c0/df66ea6632b44fb76ef6a65c1abbace13d883703aff37cd6d062add6dcd1b883f19ce733af8e0f7feb185b61600c6eb4042a518e4fb66323d0690ec357f9401c + languageName: node + linkType: hard + +"@vitest/ui@npm:^1.6.0": + version: 1.6.0 + resolution: "@vitest/ui@npm:1.6.0" + dependencies: + "@vitest/utils": "npm:1.6.0" + fast-glob: "npm:^3.3.2" + fflate: "npm:^0.8.1" + flatted: "npm:^3.2.9" + pathe: "npm:^1.1.1" + picocolors: "npm:^1.0.0" + sirv: "npm:^2.0.4" + peerDependencies: + vitest: 1.6.0 + checksum: 10c0/1d2f971f1efb9f9b22af2881e5ed66ceca8a7f091c31bfb6181ea2cb63c0cf3ff00272433892a38be67721dad26c3502b084f3c8d63c574f743ed45ae0016582 + languageName: node + linkType: hard + +"@vitest/utils@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/utils@npm:1.6.0" + dependencies: + diff-sequences: "npm:^29.6.3" + estree-walker: "npm:^3.0.3" + loupe: "npm:^2.3.7" + pretty-format: "npm:^29.7.0" + checksum: 10c0/8b0d19835866455eb0b02b31c5ca3d8ad45f41a24e4c7e1f064b480f6b2804dc895a70af332f14c11ed89581011b92b179718523f55f5b14787285a0321b1301 + languageName: node + linkType: hard + +"abbrev@npm:^2.0.0": + version: 2.0.0 + resolution: "abbrev@npm:2.0.0" + checksum: 10c0/f742a5a107473946f426c691c08daba61a1d15942616f300b5d32fd735be88fef5cba24201757b6c407fd564555fb48c751cfa33519b2605c8a7aadd22baf372 + languageName: node + linkType: hard + +"acorn-walk@npm:^8.3.2": + version: 8.3.3 + resolution: "acorn-walk@npm:8.3.3" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10c0/4a9e24313e6a0a7b389e712ba69b66b455b4cb25988903506a8d247e7b126f02060b05a8a5b738a9284214e4ca95f383dd93443a4ba84f1af9b528305c7f243b + languageName: node + linkType: hard + +"acorn@npm:^8.11.0, acorn@npm:^8.11.3": + version: 8.12.1 + resolution: "acorn@npm:8.12.1" + bin: + acorn: bin/acorn + checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 + languageName: node + linkType: hard + +"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1": + version: 7.1.1 + resolution: "agent-base@npm:7.1.1" + dependencies: + debug: "npm:^4.3.4" + checksum: 10c0/e59ce7bed9c63bf071a30cc471f2933862044c97fd9958967bfe22521d7a0f601ce4ed5a8c011799d0c726ca70312142ae193bbebb60f576b52be19d4a363b50 + languageName: node + linkType: hard + +"aggregate-error@npm:^3.0.0": + version: 3.1.0 + resolution: "aggregate-error@npm:3.1.0" + dependencies: + clean-stack: "npm:^2.0.0" + indent-string: "npm:^4.0.0" + checksum: 10c0/a42f67faa79e3e6687a4923050e7c9807db3848a037076f791d10e092677d65c1d2d863b7848560699f40fc0502c19f40963fb1cd1fb3d338a7423df8e45e039 + languageName: node + linkType: hard + +"ansi-regex@npm:^5.0.1": + version: 5.0.1 + resolution: "ansi-regex@npm:5.0.1" + checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 + languageName: node + linkType: hard + +"ansi-regex@npm:^6.0.1": + version: 6.0.1 + resolution: "ansi-regex@npm:6.0.1" + checksum: 10c0/cbe16dbd2c6b2735d1df7976a7070dd277326434f0212f43abf6d87674095d247968209babdaad31bb00882fa68807256ba9be340eec2f1004de14ca75f52a08 + languageName: node + linkType: hard + +"ansi-styles@npm:^4.0.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: "npm:^2.0.1" + checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 + languageName: node + linkType: hard + +"ansi-styles@npm:^5.0.0": + version: 5.2.0 + resolution: "ansi-styles@npm:5.2.0" + checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df + languageName: node + linkType: hard + +"ansi-styles@npm:^6.1.0": + version: 6.2.1 + resolution: "ansi-styles@npm:6.2.1" + checksum: 10c0/5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c + languageName: node + linkType: hard + +"assertion-error@npm:^1.1.0": + version: 1.1.0 + resolution: "assertion-error@npm:1.1.0" + checksum: 10c0/25456b2aa333250f01143968e02e4884a34588a8538fbbf65c91a637f1dbfb8069249133cd2f4e530f10f624d206a664e7df30207830b659e9f5298b00a4099b + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee + languageName: node + linkType: hard + +"brace-expansion@npm:^1.1.7": + version: 1.1.11 + resolution: "brace-expansion@npm:1.1.11" + dependencies: + balanced-match: "npm:^1.0.0" + concat-map: "npm:0.0.1" + checksum: 10c0/695a56cd058096a7cb71fb09d9d6a7070113c7be516699ed361317aca2ec169f618e28b8af352e02ab4233fb54eb0168460a40dc320bab0034b36ab59aaad668 + languageName: node + linkType: hard + +"brace-expansion@npm:^2.0.1": + version: 2.0.1 + resolution: "brace-expansion@npm:2.0.1" + dependencies: + balanced-match: "npm:^1.0.0" + checksum: 10c0/b358f2fe060e2d7a87aa015979ecea07f3c37d4018f8d6deb5bd4c229ad3a0384fe6029bb76cd8be63c81e516ee52d1a0673edbe2023d53a5191732ae3c3e49f + languageName: node + linkType: hard + +"braces@npm:^3.0.3": + version: 3.0.3 + resolution: "braces@npm:3.0.3" + dependencies: + fill-range: "npm:^7.1.1" + checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04 + languageName: node + linkType: hard + +"cac@npm:^6.7.14": + version: 6.7.14 + resolution: "cac@npm:6.7.14" + checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 + languageName: node + linkType: hard + +"cacache@npm:^18.0.0": + version: 18.0.3 + resolution: "cacache@npm:18.0.3" + dependencies: + "@npmcli/fs": "npm:^3.1.0" + fs-minipass: "npm:^3.0.0" + glob: "npm:^10.2.2" + lru-cache: "npm:^10.0.1" + minipass: "npm:^7.0.3" + minipass-collect: "npm:^2.0.1" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + p-map: "npm:^4.0.0" + ssri: "npm:^10.0.0" + tar: "npm:^6.1.11" + unique-filename: "npm:^3.0.0" + checksum: 10c0/dfda92840bb371fb66b88c087c61a74544363b37a265023223a99965b16a16bbb87661fe4948718d79df6e0cc04e85e62784fbcf1832b2a5e54ff4c46fbb45b7 + languageName: node + linkType: hard + +"chai@npm:^4.3.10": + version: 4.4.1 + resolution: "chai@npm:4.4.1" + dependencies: + assertion-error: "npm:^1.1.0" + check-error: "npm:^1.0.3" + deep-eql: "npm:^4.1.3" + get-func-name: "npm:^2.0.2" + loupe: "npm:^2.3.6" + pathval: "npm:^1.1.1" + type-detect: "npm:^4.0.8" + checksum: 10c0/91590a8fe18bd6235dece04ccb2d5b4ecec49984b50924499bdcd7a95c02cb1fd2a689407c19bb854497bde534ef57525cfad6c7fdd2507100fd802fbc2aefbd + languageName: node + linkType: hard + +"check-error@npm:^1.0.3": + version: 1.0.3 + resolution: "check-error@npm:1.0.3" + dependencies: + get-func-name: "npm:^2.0.2" + checksum: 10c0/94aa37a7315c0e8a83d0112b5bfb5a8624f7f0f81057c73e4707729cdd8077166c6aefb3d8e2b92c63ee130d4a2ff94bad46d547e12f3238cc1d78342a973841 + languageName: node + linkType: hard + +"chownr@npm:^2.0.0": + version: 2.0.0 + resolution: "chownr@npm:2.0.0" + checksum: 10c0/594754e1303672171cc04e50f6c398ae16128eb134a88f801bf5354fd96f205320f23536a045d9abd8b51024a149696e51231565891d4efdab8846021ecf88e6 + languageName: node + linkType: hard + +"clean-stack@npm:^2.0.0": + version: 2.2.0 + resolution: "clean-stack@npm:2.2.0" + checksum: 10c0/1f90262d5f6230a17e27d0c190b09d47ebe7efdd76a03b5a1127863f7b3c9aec4c3e6c8bb3a7bbf81d553d56a1fd35728f5a8ef4c63f867ac8d690109742a8c1 + languageName: node + linkType: hard + +"color-convert@npm:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: "npm:~1.1.4" + checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 + languageName: node + linkType: hard + +"color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + +"concat-map@npm:0.0.1": + version: 0.0.1 + resolution: "concat-map@npm:0.0.1" + checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f + languageName: node + linkType: hard + +"confbox@npm:^0.1.7": + version: 0.1.7 + resolution: "confbox@npm:0.1.7" + checksum: 10c0/18b40c2f652196a833f3f1a5db2326a8a579cd14eacabfe637e4fc8cb9b68d7cf296139a38c5e7c688ce5041bf46f9adce05932d43fde44cf7e012840b5da111 + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3": + version: 7.0.3 + resolution: "cross-spawn@npm:7.0.3" + dependencies: + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 + languageName: node + linkType: hard + +"cssstyle@npm:^4.0.1": + version: 4.0.1 + resolution: "cssstyle@npm:4.0.1" + dependencies: + rrweb-cssom: "npm:^0.6.0" + checksum: 10c0/cadf9a8b23e11f4c6d63f21291096a0b0be868bd4ab9c799daa2c5b18330e39e5281605f01da906e901b42f742df0f3b3645af6465e83377ff7d15a88ee432a0 + languageName: node + linkType: hard + +"data-urls@npm:^5.0.0": + version: 5.0.0 + resolution: "data-urls@npm:5.0.0" + dependencies: + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + checksum: 10c0/1b894d7d41c861f3a4ed2ae9b1c3f0909d4575ada02e36d3d3bc584bdd84278e20709070c79c3b3bff7ac98598cb191eb3e86a89a79ea4ee1ef360e1694f92ad + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.1.1, debug@npm:^4.3.4": + version: 4.3.5 + resolution: "debug@npm:4.3.5" + dependencies: + ms: "npm:2.1.2" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/082c375a2bdc4f4469c99f325ff458adad62a3fc2c482d59923c260cb08152f34e2659f72b3767db8bb2f21ca81a60a42d1019605a412132d7b9f59363a005cc + languageName: node + linkType: hard + +"decimal.js@npm:^10.4.3": + version: 10.4.3 + resolution: "decimal.js@npm:10.4.3" + checksum: 10c0/6d60206689ff0911f0ce968d40f163304a6c1bc739927758e6efc7921cfa630130388966f16bf6ef6b838cb33679fbe8e7a78a2f3c478afce841fd55ac8fb8ee + languageName: node + linkType: hard + +"deep-eql@npm:^4.1.3": + version: 4.1.4 + resolution: "deep-eql@npm:4.1.4" + dependencies: + type-detect: "npm:^4.0.0" + checksum: 10c0/264e0613493b43552fc908f4ff87b8b445c0e6e075656649600e1b8a17a57ee03e960156fce7177646e4d2ddaf8e5ee616d76bd79929ff593e5c79e4e5e6c517 + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 + languageName: node + linkType: hard + +"eastasianwidth@npm:^0.2.0": + version: 0.2.0 + resolution: "eastasianwidth@npm:0.2.0" + checksum: 10c0/26f364ebcdb6395f95124fda411f63137a4bfb5d3a06453f7f23dfe52502905bd84e0488172e0f9ec295fdc45f05c23d5d91baf16bd26f0fe9acd777a188dc39 + languageName: node + linkType: hard + +"emoji-regex@npm:^8.0.0": + version: 8.0.0 + resolution: "emoji-regex@npm:8.0.0" + checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010 + languageName: node + linkType: hard + +"emoji-regex@npm:^9.2.2": + version: 9.2.2 + resolution: "emoji-regex@npm:9.2.2" + checksum: 10c0/af014e759a72064cf66e6e694a7fc6b0ed3d8db680427b021a89727689671cefe9d04151b2cad51dbaf85d5ba790d061cd167f1cf32eb7b281f6368b3c181639 + languageName: node + linkType: hard + +"encoding@npm:^0.1.13": + version: 0.1.13 + resolution: "encoding@npm:0.1.13" + dependencies: + iconv-lite: "npm:^0.6.2" + checksum: 10c0/36d938712ff00fe1f4bac88b43bcffb5930c1efa57bbcdca9d67e1d9d6c57cfb1200fb01efe0f3109b2ce99b231f90779532814a81370a1bd3274a0f58585039 + languageName: node + linkType: hard + +"entities@npm:^4.4.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"err-code@npm:^2.0.2": + version: 2.0.3 + resolution: "err-code@npm:2.0.3" + checksum: 10c0/b642f7b4dd4a376e954947550a3065a9ece6733ab8e51ad80db727aaae0817c2e99b02a97a3d6cecc648a97848305e728289cf312d09af395403a90c9d4d8a66 + languageName: node + linkType: hard + +"esbuild@npm:^0.21.3": + version: 0.21.5 + resolution: "esbuild@npm:0.21.5" + dependencies: + "@esbuild/aix-ppc64": "npm:0.21.5" + "@esbuild/android-arm": "npm:0.21.5" + "@esbuild/android-arm64": "npm:0.21.5" + "@esbuild/android-x64": "npm:0.21.5" + "@esbuild/darwin-arm64": "npm:0.21.5" + "@esbuild/darwin-x64": "npm:0.21.5" + "@esbuild/freebsd-arm64": "npm:0.21.5" + "@esbuild/freebsd-x64": "npm:0.21.5" + "@esbuild/linux-arm": "npm:0.21.5" + "@esbuild/linux-arm64": "npm:0.21.5" + "@esbuild/linux-ia32": "npm:0.21.5" + "@esbuild/linux-loong64": "npm:0.21.5" + "@esbuild/linux-mips64el": "npm:0.21.5" + "@esbuild/linux-ppc64": "npm:0.21.5" + "@esbuild/linux-riscv64": "npm:0.21.5" + "@esbuild/linux-s390x": "npm:0.21.5" + "@esbuild/linux-x64": "npm:0.21.5" + "@esbuild/netbsd-x64": "npm:0.21.5" + "@esbuild/openbsd-x64": "npm:0.21.5" + "@esbuild/sunos-x64": "npm:0.21.5" + "@esbuild/win32-arm64": "npm:0.21.5" + "@esbuild/win32-ia32": "npm:0.21.5" + "@esbuild/win32-x64": "npm:0.21.5" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/fa08508adf683c3f399e8a014a6382a6b65542213431e26206c0720e536b31c09b50798747c2a105a4bbba1d9767b8d3615a74c2f7bf1ddf6d836cd11eb672de + languageName: node + linkType: hard + +"esbuild@npm:^0.24.0": + version: 0.24.0 + resolution: "esbuild@npm:0.24.0" + dependencies: + "@esbuild/aix-ppc64": "npm:0.24.0" + "@esbuild/android-arm": "npm:0.24.0" + "@esbuild/android-arm64": "npm:0.24.0" + "@esbuild/android-x64": "npm:0.24.0" + "@esbuild/darwin-arm64": "npm:0.24.0" + "@esbuild/darwin-x64": "npm:0.24.0" + "@esbuild/freebsd-arm64": "npm:0.24.0" + "@esbuild/freebsd-x64": "npm:0.24.0" + "@esbuild/linux-arm": "npm:0.24.0" + "@esbuild/linux-arm64": "npm:0.24.0" + "@esbuild/linux-ia32": "npm:0.24.0" + "@esbuild/linux-loong64": "npm:0.24.0" + "@esbuild/linux-mips64el": "npm:0.24.0" + "@esbuild/linux-ppc64": "npm:0.24.0" + "@esbuild/linux-riscv64": "npm:0.24.0" + "@esbuild/linux-s390x": "npm:0.24.0" + "@esbuild/linux-x64": "npm:0.24.0" + "@esbuild/netbsd-x64": "npm:0.24.0" + "@esbuild/openbsd-arm64": "npm:0.24.0" + "@esbuild/openbsd-x64": "npm:0.24.0" + "@esbuild/sunos-x64": "npm:0.24.0" + "@esbuild/win32-arm64": "npm:0.24.0" + "@esbuild/win32-ia32": "npm:0.24.0" + "@esbuild/win32-x64": "npm:0.24.0" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/9f1aadd8d64f3bff422ae78387e66e51a5e09de6935a6f987b6e4e189ed00fdc2d1bc03d2e33633b094008529c8b6e06c7ad1a9782fb09fec223bf95998c0683 + languageName: node + linkType: hard + +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + +"execa@npm:^8.0.1": + version: 8.0.1 + resolution: "execa@npm:8.0.1" + dependencies: + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^8.0.1" + human-signals: "npm:^5.0.0" + is-stream: "npm:^3.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^5.1.0" + onetime: "npm:^6.0.0" + signal-exit: "npm:^4.1.0" + strip-final-newline: "npm:^3.0.0" + checksum: 10c0/2c52d8775f5bf103ce8eec9c7ab3059909ba350a5164744e9947ed14a53f51687c040a250bda833f906d1283aa8803975b84e6c8f7a7c42f99dc8ef80250d1af + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.1 + resolution: "exponential-backoff@npm:3.1.1" + checksum: 10c0/160456d2d647e6019640bd07111634d8c353038d9fa40176afb7cd49b0548bdae83b56d05e907c2cce2300b81cae35d800ef92fefb9d0208e190fa3b7d6bb579 + languageName: node + linkType: hard + +"fast-glob@npm:^3.3.2": + version: 3.3.2 + resolution: "fast-glob@npm:3.3.2" + dependencies: + "@nodelib/fs.stat": "npm:^2.0.2" + "@nodelib/fs.walk": "npm:^1.2.3" + glob-parent: "npm:^5.1.2" + merge2: "npm:^1.3.0" + micromatch: "npm:^4.0.4" + checksum: 10c0/42baad7b9cd40b63e42039132bde27ca2cb3a4950d0a0f9abe4639ea1aa9d3e3b40f98b1fe31cbc0cc17b664c9ea7447d911a152fa34ec5b72977b125a6fc845 + languageName: node + linkType: hard + +"fastq@npm:^1.6.0": + version: 1.17.1 + resolution: "fastq@npm:1.17.1" + dependencies: + reusify: "npm:^1.0.4" + checksum: 10c0/1095f16cea45fb3beff558bb3afa74ca7a9250f5a670b65db7ed585f92b4b48381445cd328b3d87323da81e43232b5d5978a8201bde84e0cd514310f1ea6da34 + languageName: node + linkType: hard + +"fflate@npm:^0.8.1": + version: 0.8.2 + resolution: "fflate@npm:0.8.2" + checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293 + languageName: node + linkType: hard + +"fill-range@npm:^7.1.1": + version: 7.1.1 + resolution: "fill-range@npm:7.1.1" + dependencies: + to-regex-range: "npm:^5.0.1" + checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018 + languageName: node + linkType: hard + +"flatted@npm:^3.2.9": + version: 3.3.1 + resolution: "flatted@npm:3.3.1" + checksum: 10c0/324166b125ee07d4ca9bcf3a5f98d915d5db4f39d711fba640a3178b959919aae1f7cfd8aabcfef5826ed8aa8a2aa14cc85b2d7d18ff638ddf4ae3df39573eaf + languageName: node + linkType: hard + +"foreground-child@npm:^3.1.0": + version: 3.2.1 + resolution: "foreground-child@npm:3.2.1" + dependencies: + cross-spawn: "npm:^7.0.0" + signal-exit: "npm:^4.0.1" + checksum: 10c0/9a53a33dbd87090e9576bef65fb4a71de60f6863a8062a7b11bc1cbe3cc86d428677d7c0b9ef61cdac11007ac580006f78bd5638618d564cfd5e6fd713d6878f + languageName: node + linkType: hard + +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + mime-types: "npm:^2.1.12" + checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e + languageName: node + linkType: hard + +"fs-minipass@npm:^2.0.0": + version: 2.1.0 + resolution: "fs-minipass@npm:2.1.0" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/703d16522b8282d7299337539c3ed6edddd1afe82435e4f5b76e34a79cd74e488a8a0e26a636afc2440e1a23b03878e2122e3a2cfe375a5cf63c37d92b86a004 + languageName: node + linkType: hard + +"fs-minipass@npm:^3.0.0": + version: 3.0.3 + resolution: "fs-minipass@npm:3.0.3" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94 + languageName: node + linkType: hard + +"fs.realpath@npm:^1.0.0": + version: 1.0.0 + resolution: "fs.realpath@npm:1.0.0" + checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 + languageName: node + linkType: hard + +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": + version: 2.0.2 + resolution: "get-func-name@npm:2.0.2" + checksum: 10c0/89830fd07623fa73429a711b9daecdb304386d237c71268007f788f113505ef1d4cc2d0b9680e072c5082490aec9df5d7758bf5ac6f1c37062855e8e3dc0b9df + languageName: node + linkType: hard + +"get-stream@npm:^8.0.1": + version: 8.0.1 + resolution: "get-stream@npm:8.0.1" + checksum: 10c0/5c2181e98202b9dae0bb4a849979291043e5892eb40312b47f0c22b9414fc9b28a3b6063d2375705eb24abc41ecf97894d9a51f64ff021511b504477b27b4290 + languageName: node + linkType: hard + +"glob-parent@npm:^5.1.2": + version: 5.1.2 + resolution: "glob-parent@npm:5.1.2" + dependencies: + is-glob: "npm:^4.0.1" + checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee + languageName: node + linkType: hard + +"glob@npm:^10.2.2, glob@npm:^10.3.10": + version: 10.4.3 + resolution: "glob@npm:10.4.3" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10c0/bea148e5dae96c17e2764f4764c72376a6ab7072b27a21e861ae4af6f97f3e810d79d67f64de52f63ce1d7fdb73b7306f61c65b48d0f61ca7c8647ce8acaf9a7 + languageName: node + linkType: hard + +"glob@npm:^7.1.4": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.1.1" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1 + languageName: node + linkType: hard + +"html-encoding-sniffer@npm:^4.0.0": + version: 4.0.0 + resolution: "html-encoding-sniffer@npm:4.0.0" + dependencies: + whatwg-encoding: "npm:^3.1.1" + checksum: 10c0/523398055dc61ac9b34718a719cb4aa691e4166f29187e211e1607de63dc25ac7af52ca7c9aead0c4b3c0415ffecb17326396e1202e2e86ff4bca4c0ee4c6140 + languageName: node + linkType: hard + +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + +"http-cache-semantics@npm:^4.1.1": + version: 4.1.1 + resolution: "http-cache-semantics@npm:4.1.1" + checksum: 10c0/ce1319b8a382eb3cbb4a37c19f6bfe14e5bb5be3d09079e885e8c513ab2d3cd9214902f8a31c9dc4e37022633ceabfc2d697405deeaf1b8f3552bb4ed996fdfc + languageName: node + linkType: hard + +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.5": + version: 7.0.5 + resolution: "https-proxy-agent@npm:7.0.5" + dependencies: + agent-base: "npm:^7.0.2" + debug: "npm:4" + checksum: 10c0/2490e3acec397abeb88807db52cac59102d5ed758feee6df6112ab3ccd8325e8a1ce8bce6f4b66e5470eca102d31e425ace904242e4fa28dbe0c59c4bafa7b2c + languageName: node + linkType: hard + +"human-signals@npm:^5.0.0": + version: 5.0.0 + resolution: "human-signals@npm:5.0.0" + checksum: 10c0/5a9359073fe17a8b58e5a085e9a39a950366d9f00217c4ff5878bd312e09d80f460536ea6a3f260b5943a01fe55c158d1cea3fc7bee3d0520aeef04f6d915c82 + languageName: node + linkType: hard + +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 + languageName: node + linkType: hard + +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 + languageName: node + linkType: hard + +"indent-string@npm:^4.0.0": + version: 4.0.0 + resolution: "indent-string@npm:4.0.0" + checksum: 10c0/1e1904ddb0cb3d6cce7cd09e27a90184908b7a5d5c21b92e232c93579d314f0b83c246ffb035493d0504b1e9147ba2c9b21df0030f48673fba0496ecd698161f + languageName: node + linkType: hard + +"inflight@npm:^1.0.4": + version: 1.0.6 + resolution: "inflight@npm:1.0.6" + dependencies: + once: "npm:^1.3.0" + wrappy: "npm:1" + checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 + languageName: node + linkType: hard + +"inherits@npm:2": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 + languageName: node + linkType: hard + +"ip-address@npm:^9.0.5": + version: 9.0.5 + resolution: "ip-address@npm:9.0.5" + dependencies: + jsbn: "npm:1.1.0" + sprintf-js: "npm:^1.1.3" + checksum: 10c0/331cd07fafcb3b24100613e4b53e1a2b4feab11e671e655d46dc09ee233da5011284d09ca40c4ecbdfe1d0004f462958675c224a804259f2f78d2465a87824bc + languageName: node + linkType: hard + +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^3.0.0": + version: 3.0.0 + resolution: "is-fullwidth-code-point@npm:3.0.0" + checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc + languageName: node + linkType: hard + +"is-glob@npm:^4.0.1": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" + dependencies: + is-extglob: "npm:^2.1.1" + checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a + languageName: node + linkType: hard + +"is-lambda@npm:^1.0.1": + version: 1.0.1 + resolution: "is-lambda@npm:1.0.1" + checksum: 10c0/85fee098ae62ba6f1e24cf22678805473c7afd0fb3978a3aa260e354cb7bcb3a5806cf0a98403188465efedec41ab4348e8e4e79305d409601323855b3839d4d + languageName: node + linkType: hard + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 + languageName: node + linkType: hard + +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 + languageName: node + linkType: hard + +"is-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "is-stream@npm:3.0.0" + checksum: 10c0/eb2f7127af02ee9aa2a0237b730e47ac2de0d4e76a4a905a50a11557f2339df5765eaea4ceb8029f1efa978586abe776908720bfcb1900c20c6ec5145f6f29d8 + languageName: node + linkType: hard + +"isexe@npm:^2.0.0": + version: 2.0.0 + resolution: "isexe@npm:2.0.0" + checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d + languageName: node + linkType: hard + +"isexe@npm:^3.1.1": + version: 3.1.1 + resolution: "isexe@npm:3.1.1" + checksum: 10c0/9ec257654093443eb0a528a9c8cbba9c0ca7616ccb40abd6dde7202734d96bb86e4ac0d764f0f8cd965856aacbff2f4ce23e730dc19dfb41e3b0d865ca6fdcc7 + languageName: node + linkType: hard + +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^5.0.4": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.23" + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.6": + version: 3.1.7 + resolution: "istanbul-reports@npm:3.1.7" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/a379fadf9cf8dc5dfe25568115721d4a7eb82fbd50b005a6672aff9c6989b20cc9312d7865814e0859cd8df58cbf664482e1d3604be0afde1f7fc3ccc1394a51 + languageName: node + linkType: hard + +"jackspeak@npm:^3.1.2": + version: 3.4.1 + resolution: "jackspeak@npm:3.4.1" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + "@pkgjs/parseargs": "npm:^0.11.0" + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 10c0/4cba6fc728c5460328c9fcf42f1fa780c204f8456f123b04f4b4c996e1e2af2b28ff25876bcdc19b7639dd6eb6ff6dc4a613d27f67e62cd92c4dfe2123fdaa35 + languageName: node + linkType: hard + +"js-tokens@npm:^9.0.0": + version: 9.0.0 + resolution: "js-tokens@npm:9.0.0" + checksum: 10c0/4ad1c12f47b8c8b2a3a99e29ef338c1385c7b7442198a425f3463f3537384dab6032012791bfc2f056ea5ecdb06b1ed4f70e11a3ab3f388d3dcebfe16a52b27d + languageName: node + linkType: hard + +"jsbn@npm:1.1.0": + version: 1.1.0 + resolution: "jsbn@npm:1.1.0" + checksum: 10c0/4f907fb78d7b712e11dea8c165fe0921f81a657d3443dde75359ed52eb2b5d33ce6773d97985a089f09a65edd80b11cb75c767b57ba47391fee4c969f7215c96 + languageName: node + linkType: hard + +"jsdom@npm:^25.0.0": + version: 25.0.0 + resolution: "jsdom@npm:25.0.0" + dependencies: + cssstyle: "npm:^4.0.1" + data-urls: "npm:^5.0.0" + decimal.js: "npm:^10.4.3" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^4.0.0" + http-proxy-agent: "npm:^7.0.2" + https-proxy-agent: "npm:^7.0.5" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.12" + parse5: "npm:^7.1.2" + rrweb-cssom: "npm:^0.7.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^4.1.4" + w3c-xmlserializer: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^3.1.1" + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + ws: "npm:^8.18.0" + xml-name-validator: "npm:^5.0.0" + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/1552bcfb816b2c69ae159ba0cd79e8964030c106cc0cb2deb20a64c1ca54e1ea41352b9802d89b7cf823e43e6d74ed7289abff4aacc95b1b2bc936570aab3594 + languageName: node + linkType: hard + +"local-pkg@npm:^0.5.0": + version: 0.5.0 + resolution: "local-pkg@npm:0.5.0" + dependencies: + mlly: "npm:^1.4.2" + pkg-types: "npm:^1.0.3" + checksum: 10c0/f61cbd00d7689f275558b1a45c7ff2a3ddf8472654123ed880215677b9adfa729f1081e50c27ffb415cdb9fa706fb755fec5e23cdd965be375c8059e87ff1cc9 + languageName: node + linkType: hard + +"loupe@npm:^2.3.6, loupe@npm:^2.3.7": + version: 2.3.7 + resolution: "loupe@npm:2.3.7" + dependencies: + get-func-name: "npm:^2.0.1" + checksum: 10c0/71a781c8fc21527b99ed1062043f1f2bb30bdaf54fa4cf92463427e1718bc6567af2988300bc243c1f276e4f0876f29e3cbf7b58106fdc186915687456ce5bf4 + languageName: node + linkType: hard + +"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": + version: 10.4.0 + resolution: "lru-cache@npm:10.4.0" + checksum: 10c0/2ea052e95431b50d82ac5d657e0308fed3e58031ec6153a7e019c586fc1255521273b6b14b9598745c869556338d5f31d4059f648ea2475e88df49ea140d9e07 + languageName: node + linkType: hard + +"magic-string@npm:^0.30.5": + version: 0.30.10 + resolution: "magic-string@npm:0.30.10" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.4.15" + checksum: 10c0/aa9ca17eae571a19bce92c8221193b6f93ee8511abb10f085e55ffd398db8e4c089a208d9eac559deee96a08b7b24d636ea4ab92f09c6cf42a7d1af51f7fd62b + languageName: node + linkType: hard + +"magicast@npm:^0.3.3": + version: 0.3.4 + resolution: "magicast@npm:0.3.4" + dependencies: + "@babel/parser": "npm:^7.24.4" + "@babel/types": "npm:^7.24.0" + source-map-js: "npm:^1.2.0" + checksum: 10c0/7ebaaac397b13c31ca05e6d9649296751d76749b945d10a0800107872119fbdf267acdb604571d25e38ec6fd7ab3568a951b6e76eaef1caba9eaa11778fd9783 + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + +"make-fetch-happen@npm:^13.0.0": + version: 13.0.1 + resolution: "make-fetch-happen@npm:13.0.1" + dependencies: + "@npmcli/agent": "npm:^2.0.0" + cacache: "npm:^18.0.0" + http-cache-semantics: "npm:^4.1.1" + is-lambda: "npm:^1.0.1" + minipass: "npm:^7.0.2" + minipass-fetch: "npm:^3.0.0" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^0.6.3" + proc-log: "npm:^4.2.0" + promise-retry: "npm:^2.0.1" + ssri: "npm:^10.0.0" + checksum: 10c0/df5f4dbb6d98153b751bccf4dc4cc500de85a96a9331db9805596c46aa9f99d9555983954e6c1266d9f981ae37a9e4647f42b9a4bb5466f867f4012e582c9e7e + languageName: node + linkType: hard + +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 10c0/867fdbb30a6d58b011449b8885601ec1690c3e41c759ecd5a9d609094f7aed0096c37823ff4a7190ef0b8f22cc86beb7049196ff68c016e3b3c671d0dac91ce5 + languageName: node + linkType: hard + +"merge2@npm:^1.3.0": + version: 1.4.1 + resolution: "merge2@npm:1.4.1" + checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb + languageName: node + linkType: hard + +"micromatch@npm:^4.0.4": + version: 4.0.7 + resolution: "micromatch@npm:4.0.7" + dependencies: + braces: "npm:^3.0.3" + picomatch: "npm:^2.3.1" + checksum: 10c0/58fa99bc5265edec206e9163a1d2cec5fabc46a5b473c45f4a700adce88c2520456ae35f2b301e4410fb3afb27e9521fb2813f6fc96be0a48a89430e0916a772 + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mimic-fn@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-fn@npm:4.0.0" + checksum: 10c0/de9cc32be9996fd941e512248338e43407f63f6d497abe8441fa33447d922e927de54d4cc3c1a3c6d652857acd770389d5a3823f311a744132760ce2be15ccbf + languageName: node + linkType: hard + +"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: "npm:^1.1.7" + checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + languageName: node + linkType: hard + +"minimatch@npm:^9.0.4": + version: 9.0.5 + resolution: "minimatch@npm:9.0.5" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10c0/de96cf5e35bdf0eab3e2c853522f98ffbe9a36c37797778d2665231ec1f20a9447a7e567cb640901f89e4daaa95ae5d70c65a9e8aa2bb0019b6facbc3c0575ed + languageName: node + linkType: hard + +"minipass-collect@npm:^2.0.1": + version: 2.0.1 + resolution: "minipass-collect@npm:2.0.1" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e + languageName: node + linkType: hard + +"minipass-fetch@npm:^3.0.0": + version: 3.0.5 + resolution: "minipass-fetch@npm:3.0.5" + dependencies: + encoding: "npm:^0.1.13" + minipass: "npm:^7.0.3" + minipass-sized: "npm:^1.0.3" + minizlib: "npm:^2.1.2" + dependenciesMeta: + encoding: + optional: true + checksum: 10c0/9d702d57f556274286fdd97e406fc38a2f5c8d15e158b498d7393b1105974b21249289ec571fa2b51e038a4872bfc82710111cf75fae98c662f3d6f95e72152b + languageName: node + linkType: hard + +"minipass-flush@npm:^1.0.5": + version: 1.0.5 + resolution: "minipass-flush@npm:1.0.5" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/2a51b63feb799d2bb34669205eee7c0eaf9dce01883261a5b77410c9408aa447e478efd191b4de6fc1101e796ff5892f8443ef20d9544385819093dbb32d36bd + languageName: node + linkType: hard + +"minipass-pipeline@npm:^1.2.4": + version: 1.2.4 + resolution: "minipass-pipeline@npm:1.2.4" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/cbda57cea20b140b797505dc2cac71581a70b3247b84480c1fed5ca5ba46c25ecc25f68bfc9e6dcb1a6e9017dab5c7ada5eab73ad4f0a49d84e35093e0c643f2 + languageName: node + linkType: hard + +"minipass-sized@npm:^1.0.3": + version: 1.0.3 + resolution: "minipass-sized@npm:1.0.3" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/298f124753efdc745cfe0f2bdfdd81ba25b9f4e753ca4a2066eb17c821f25d48acea607dfc997633ee5bf7b6dfffb4eee4f2051eb168663f0b99fad2fa4829cb + languageName: node + linkType: hard + +"minipass@npm:^3.0.0": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10c0/a114746943afa1dbbca8249e706d1d38b85ed1298b530f5808ce51f8e9e941962e2a5ad2e00eae7dd21d8a4aae6586a66d4216d1a259385e9d0358f0c1eba16c + languageName: node + linkType: hard + +"minipass@npm:^5.0.0": + version: 5.0.0 + resolution: "minipass@npm:5.0.0" + checksum: 10c0/a91d8043f691796a8ac88df039da19933ef0f633e3d7f0d35dcd5373af49131cf2399bfc355f41515dc495e3990369c3858cd319e5c2722b4753c90bf3152462 + languageName: node + linkType: hard + +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 + languageName: node + linkType: hard + +"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": + version: 2.1.2 + resolution: "minizlib@npm:2.1.2" + dependencies: + minipass: "npm:^3.0.0" + yallist: "npm:^4.0.0" + checksum: 10c0/64fae024e1a7d0346a1102bb670085b17b7f95bf6cfdf5b128772ec8faf9ea211464ea4add406a3a6384a7d87a0cd1a96263692134323477b4fb43659a6cab78 + languageName: node + linkType: hard + +"mkdirp@npm:^1.0.3": + version: 1.0.4 + resolution: "mkdirp@npm:1.0.4" + bin: + mkdirp: bin/cmd.js + checksum: 10c0/46ea0f3ffa8bc6a5bc0c7081ffc3907777f0ed6516888d40a518c5111f8366d97d2678911ad1a6882bf592fa9de6c784fea32e1687bb94e1f4944170af48a5cf + languageName: node + linkType: hard + +"mlly@npm:^1.4.2, mlly@npm:^1.7.1": + version: 1.7.1 + resolution: "mlly@npm:1.7.1" + dependencies: + acorn: "npm:^8.11.3" + pathe: "npm:^1.1.2" + pkg-types: "npm:^1.1.1" + ufo: "npm:^1.5.3" + checksum: 10c0/d836a7b0adff4d118af41fb93ad4d9e57f80e694a681185280ba220a4607603c19e86c80f9a6c57512b04280567f2599e3386081705c5b5fd74c9ddfd571d0fa + languageName: node + linkType: hard + +"mrmime@npm:^2.0.0": + version: 2.0.0 + resolution: "mrmime@npm:2.0.0" + checksum: 10c0/312b35ed288986aec90955410b21ed7427fd1e4ee318cb5fc18765c8d029eeded9444faa46589e5b1ed6b35fb2054a802ac8dcb917ddf6b3e189cb3bf11a965c + languageName: node + linkType: hard + +"ms@npm:2.1.2": + version: 2.1.2 + resolution: "ms@npm:2.1.2" + checksum: 10c0/a437714e2f90dbf881b5191d35a6db792efbca5badf112f87b9e1c712aace4b4b9b742dd6537f3edf90fd6f684de897cec230abde57e87883766712ddda297cc + languageName: node + linkType: hard + +"nanoid@npm:^3.3.7": + version: 3.3.7 + resolution: "nanoid@npm:3.3.7" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/e3fb661aa083454f40500473bb69eedb85dc160e763150b9a2c567c7e9ff560ce028a9f833123b618a6ea742e311138b591910e795614a629029e86e180660f3 + languageName: node + linkType: hard + +"negotiator@npm:^0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 10.1.0 + resolution: "node-gyp@npm:10.1.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.6" + make-fetch-happen: "npm:^13.0.0" + nopt: "npm:^7.0.0" + proc-log: "npm:^3.0.0" + semver: "npm:^7.3.5" + tar: "npm:^6.1.2" + which: "npm:^4.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/9cc821111ca244a01fb7f054db7523ab0a0cd837f665267eb962eb87695d71fb1e681f9e21464cc2fd7c05530dc4c81b810bca1a88f7d7186909b74477491a3c + languageName: node + linkType: hard + +"nopt@npm:^7.0.0": + version: 7.2.1 + resolution: "nopt@npm:7.2.1" + dependencies: + abbrev: "npm:^2.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/a069c7c736767121242037a22a788863accfa932ab285a1eb569eb8cd534b09d17206f68c37f096ae785647435e0c5a5a0a67b42ec743e481a455e5ae6a6df81 + languageName: node + linkType: hard + +"npm-run-path@npm:^5.1.0": + version: 5.3.0 + resolution: "npm-run-path@npm:5.3.0" + dependencies: + path-key: "npm:^4.0.0" + checksum: 10c0/124df74820c40c2eb9a8612a254ea1d557ddfab1581c3e751f825e3e366d9f00b0d76a3c94ecd8398e7f3eee193018622677e95816e8491f0797b21e30b2deba + languageName: node + linkType: hard + +"nwsapi@npm:^2.2.12": + version: 2.2.12 + resolution: "nwsapi@npm:2.2.12" + checksum: 10c0/95e9623d63df111405503df8c5d800e26f71675d319e2c9c70cddfa31e5ace1d3f8b6d98d354544fc156a1506d920ec291e303fab761e4f99296868e199a466e + languageName: node + linkType: hard + +"once@npm:^1.3.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 + languageName: node + linkType: hard + +"onetime@npm:^6.0.0": + version: 6.0.0 + resolution: "onetime@npm:6.0.0" + dependencies: + mimic-fn: "npm:^4.0.0" + checksum: 10c0/4eef7c6abfef697dd4479345a4100c382d73c149d2d56170a54a07418c50816937ad09500e1ed1e79d235989d073a9bade8557122aee24f0576ecde0f392bb6c + languageName: node + linkType: hard + +"p-limit@npm:^5.0.0": + version: 5.0.0 + resolution: "p-limit@npm:5.0.0" + dependencies: + yocto-queue: "npm:^1.0.0" + checksum: 10c0/574e93b8895a26e8485eb1df7c4b58a1a6e8d8ae41b1750cc2cc440922b3d306044fc6e9a7f74578a883d46802d9db72b30f2e612690fcef838c173261b1ed83 + languageName: node + linkType: hard + +"p-map@npm:^4.0.0": + version: 4.0.0 + resolution: "p-map@npm:4.0.0" + dependencies: + aggregate-error: "npm:^3.0.0" + checksum: 10c0/592c05bd6262c466ce269ff172bb8de7c6975afca9b50c975135b974e9bdaafbfe80e61aaaf5be6d1200ba08b30ead04b88cfa7e25ff1e3b93ab28c9f62a2c75 + languageName: node + linkType: hard + +"package-json-from-dist@npm:^1.0.0": + version: 1.0.0 + resolution: "package-json-from-dist@npm:1.0.0" + checksum: 10c0/e3ffaf6ac1040ab6082a658230c041ad14e72fabe99076a2081bb1d5d41210f11872403fc09082daf4387fc0baa6577f96c9c0e94c90c394fd57794b66aa4033 + languageName: node + linkType: hard + +"parse5@npm:^7.1.2": + version: 7.1.2 + resolution: "parse5@npm:7.1.2" + dependencies: + entities: "npm:^4.4.0" + checksum: 10c0/297d7af8224f4b5cb7f6617ecdae98eeaed7f8cbd78956c42785e230505d5a4f07cef352af10d3006fa5c1544b76b57784d3a22d861ae071bbc460c649482bf4 + languageName: node + linkType: hard + +"path-is-absolute@npm:^1.0.0": + version: 1.0.1 + resolution: "path-is-absolute@npm:1.0.1" + checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 + languageName: node + linkType: hard + +"path-key@npm:^3.1.0": + version: 3.1.1 + resolution: "path-key@npm:3.1.1" + checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c + languageName: node + linkType: hard + +"path-key@npm:^4.0.0": + version: 4.0.0 + resolution: "path-key@npm:4.0.0" + checksum: 10c0/794efeef32863a65ac312f3c0b0a99f921f3e827ff63afa5cb09a377e202c262b671f7b3832a4e64731003fa94af0263713962d317b9887bd1e0c48a342efba3 + languageName: node + linkType: hard + +"path-scurry@npm:^1.11.1": + version: 1.11.1 + resolution: "path-scurry@npm:1.11.1" + dependencies: + lru-cache: "npm:^10.2.0" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + checksum: 10c0/32a13711a2a505616ae1cc1b5076801e453e7aae6ac40ab55b388bb91b9d0547a52f5aaceff710ea400205f18691120d4431e520afbe4266b836fadede15872d + languageName: node + linkType: hard + +"pathe@npm:^1.1.1, pathe@npm:^1.1.2": + version: 1.1.2 + resolution: "pathe@npm:1.1.2" + checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 + languageName: node + linkType: hard + +"pathval@npm:^1.1.1": + version: 1.1.1 + resolution: "pathval@npm:1.1.1" + checksum: 10c0/f63e1bc1b33593cdf094ed6ff5c49c1c0dc5dc20a646ca9725cc7fe7cd9995002d51d5685b9b2ec6814342935748b711bafa840f84c0bb04e38ff40a335c94dc + languageName: node + linkType: hard + +"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1": + version: 1.0.1 + resolution: "picocolors@npm:1.0.1" + checksum: 10c0/c63cdad2bf812ef0d66c8db29583802355d4ca67b9285d846f390cc15c2f6ccb94e8cb7eb6a6e97fc5990a6d3ad4ae42d86c84d3146e667c739a4234ed50d400 + languageName: node + linkType: hard + +"picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be + languageName: node + linkType: hard + +"pkg-types@npm:^1.0.3, pkg-types@npm:^1.1.1": + version: 1.1.3 + resolution: "pkg-types@npm:1.1.3" + dependencies: + confbox: "npm:^0.1.7" + mlly: "npm:^1.7.1" + pathe: "npm:^1.1.2" + checksum: 10c0/4cd2c9442dd5e4ae0c61cbd8fdaa92a273939749b081f78150ce9a3f4e625cca0375607386f49f103f0720b239d02369bf181c3ea6c80cf1028a633df03706ad + languageName: node + linkType: hard + +"playwright-core@npm:1.45.1": + version: 1.45.1 + resolution: "playwright-core@npm:1.45.1" + bin: + playwright-core: cli.js + checksum: 10c0/607ad31ce1e85e2042107954eeed2cb7de5f387b42d9c8c19baa5c1ea4c2ea621bf233094ed86be45de625eeece33b280847ff641ff1bb9acaddee040e17bea1 + languageName: node + linkType: hard + +"playwright@npm:1.45.1, playwright@npm:^1.45.1": + version: 1.45.1 + resolution: "playwright@npm:1.45.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.45.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/549e8621b120258ff53e93fcf3b2994a835aa084097ea533a9f4b53ff993308f3617cf00943c6975f88b66068890a6bf9d61b4ffdd73b7d8f45a5d284b6f284b + languageName: node + linkType: hard + +"postcss@npm:^8.4.39": + version: 8.4.39 + resolution: "postcss@npm:8.4.39" + dependencies: + nanoid: "npm:^3.3.7" + picocolors: "npm:^1.0.1" + source-map-js: "npm:^1.2.0" + checksum: 10c0/16f5ac3c4e32ee76d1582b3c0dcf1a1fdb91334a45ad755eeb881ccc50318fb8d64047de4f1601ac96e30061df203f0f2e2edbdc0bfc49b9c57bc9fb9bedaea3 + languageName: node + linkType: hard + +"pretty-format@npm:^29.7.0": + version: 29.7.0 + resolution: "pretty-format@npm:29.7.0" + dependencies: + "@jest/schemas": "npm:^29.6.3" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: 10c0/edc5ff89f51916f036c62ed433506b55446ff739358de77207e63e88a28ca2894caac6e73dcb68166a606e51c8087d32d400473e6a9fdd2dbe743f46c9c0276f + languageName: node + linkType: hard + +"proc-log@npm:^3.0.0": + version: 3.0.0 + resolution: "proc-log@npm:3.0.0" + checksum: 10c0/f66430e4ff947dbb996058f6fd22de2c66612ae1a89b097744e17fb18a4e8e7a86db99eda52ccf15e53f00b63f4ec0b0911581ff2aac0355b625c8eac509b0dc + languageName: node + linkType: hard + +"proc-log@npm:^4.2.0": + version: 4.2.0 + resolution: "proc-log@npm:4.2.0" + checksum: 10c0/17db4757c2a5c44c1e545170e6c70a26f7de58feb985091fb1763f5081cab3d01b181fb2dd240c9f4a4255a1d9227d163d5771b7e69c9e49a561692db865efb9 + languageName: node + linkType: hard + +"promise-retry@npm:^2.0.1": + version: 2.0.1 + resolution: "promise-retry@npm:2.0.1" + dependencies: + err-code: "npm:^2.0.2" + retry: "npm:^0.12.0" + checksum: 10c0/9c7045a1a2928094b5b9b15336dcd2a7b1c052f674550df63cc3f36cd44028e5080448175b6f6ca32b642de81150f5e7b1a98b728f15cb069f2dd60ac2616b96 + languageName: node + linkType: hard + +"psl@npm:^1.1.33": + version: 1.9.0 + resolution: "psl@npm:1.9.0" + checksum: 10c0/6a3f805fdab9442f44de4ba23880c4eba26b20c8e8e0830eff1cb31007f6825dace61d17203c58bfe36946842140c97a1ba7f67bc63ca2d88a7ee052b65d97ab + languageName: node + linkType: hard + +"punycode@npm:^2.1.1, punycode@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 + languageName: node + linkType: hard + +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 10c0/3258bc3dbdf322ff2663619afe5947c7926a6ef5fb78ad7d384602974c467fadfc8272af44f5eb8cddd0d011aae8fabf3a929a8eee4b86edcc0a21e6bd10f9aa + languageName: node + linkType: hard + +"queue-microtask@npm:^1.2.2": + version: 1.2.3 + resolution: "queue-microtask@npm:1.2.3" + checksum: 10c0/900a93d3cdae3acd7d16f642c29a642aea32c2026446151f0778c62ac089d4b8e6c986811076e1ae180a694cedf077d453a11b58ff0a865629a4f82ab558e102 + languageName: node + linkType: hard + +"react-is@npm:^18.0.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 + languageName: node + linkType: hard + +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267 + languageName: node + linkType: hard + +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe + languageName: node + linkType: hard + +"reusify@npm:^1.0.4": + version: 1.0.4 + resolution: "reusify@npm:1.0.4" + checksum: 10c0/c19ef26e4e188f408922c46f7ff480d38e8dfc55d448310dfb518736b23ed2c4f547fb64a6ed5bdba92cd7e7ddc889d36ff78f794816d5e71498d645ef476107 + languageName: node + linkType: hard + +"rollup@npm:^4.13.0": + version: 4.18.0 + resolution: "rollup@npm:4.18.0" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.18.0" + "@rollup/rollup-android-arm64": "npm:4.18.0" + "@rollup/rollup-darwin-arm64": "npm:4.18.0" + "@rollup/rollup-darwin-x64": "npm:4.18.0" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.18.0" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.18.0" + "@rollup/rollup-linux-arm64-gnu": "npm:4.18.0" + "@rollup/rollup-linux-arm64-musl": "npm:4.18.0" + "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.18.0" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.18.0" + "@rollup/rollup-linux-s390x-gnu": "npm:4.18.0" + "@rollup/rollup-linux-x64-gnu": "npm:4.18.0" + "@rollup/rollup-linux-x64-musl": "npm:4.18.0" + "@rollup/rollup-win32-arm64-msvc": "npm:4.18.0" + "@rollup/rollup-win32-ia32-msvc": "npm:4.18.0" + "@rollup/rollup-win32-x64-msvc": "npm:4.18.0" + "@types/estree": "npm:1.0.5" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-powerpc64le-gnu": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/7d0239f029c48d977e0d0b942433bed9ca187d2328b962fc815fc775d0fdf1966ffcd701fef265477e999a1fb01bddcc984fc675d1b9d9864bf8e1f1f487e23e + languageName: node + linkType: hard + +"rrweb-cssom@npm:^0.6.0": + version: 0.6.0 + resolution: "rrweb-cssom@npm:0.6.0" + checksum: 10c0/3d9d90d53c2349ea9c8509c2690df5a4ef930c9cf8242aeb9425d4046f09d712bb01047e00da0e1c1dab5db35740b3d78fd45c3e7272f75d3724a563f27c30a3 + languageName: node + linkType: hard + +"rrweb-cssom@npm:^0.7.1": + version: 0.7.1 + resolution: "rrweb-cssom@npm:0.7.1" + checksum: 10c0/127b8ca6c8aac45e2755abbae6138d4a813b1bedc2caabf79466ae83ab3cfc84b5bfab513b7033f0aa4561c7753edf787d0dd01163ceacdee2e8eb1b6bf7237e + languageName: node + linkType: hard + +"run-parallel@npm:^1.1.9": + version: 1.2.0 + resolution: "run-parallel@npm:1.2.0" + dependencies: + queue-microtask: "npm:^1.2.2" + checksum: 10c0/200b5ab25b5b8b7113f9901bfe3afc347e19bb7475b267d55ad0eb86a62a46d77510cb0f232507c9e5d497ebda569a08a9867d0d14f57a82ad5564d991588b39 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 + languageName: node + linkType: hard + +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74 + languageName: node + linkType: hard + +"semver@npm:^7.3.5, semver@npm:^7.5.3": + version: 7.6.2 + resolution: "semver@npm:7.6.2" + bin: + semver: bin/semver.js + checksum: 10c0/97d3441e97ace8be4b1976433d1c32658f6afaff09f143e52c593bae7eef33de19e3e369c88bd985ce1042c6f441c80c6803078d1de2a9988080b66684cbb30c + languageName: node + linkType: hard + +"shebang-command@npm:^2.0.0": + version: 2.0.0 + resolution: "shebang-command@npm:2.0.0" + dependencies: + shebang-regex: "npm:^3.0.0" + checksum: 10c0/a41692e7d89a553ef21d324a5cceb5f686d1f3c040759c50aab69688634688c5c327f26f3ecf7001ebfd78c01f3c7c0a11a7c8bfd0a8bc9f6240d4f40b224e4e + languageName: node + linkType: hard + +"shebang-regex@npm:^3.0.0": + version: 3.0.0 + resolution: "shebang-regex@npm:3.0.0" + checksum: 10c0/1dbed0726dd0e1152a92696c76c7f06084eb32a90f0528d11acd764043aacf76994b2fb30aa1291a21bd019d6699164d048286309a278855ee7bec06cf6fb690 + languageName: node + linkType: hard + +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 + languageName: node + linkType: hard + +"sirv@npm:^2.0.4": + version: 2.0.4 + resolution: "sirv@npm:2.0.4" + dependencies: + "@polka/url": "npm:^1.0.0-next.24" + mrmime: "npm:^2.0.0" + totalist: "npm:^3.0.0" + checksum: 10c0/68f8ee857f6a9415e9c07a1f31c7c561df8d5f1b1ba79bee3de583fa37da8718def5309f6b1c6e2c3ef77de45d74f5e49efc7959214443aa92d42e9c99180a4e + languageName: node + linkType: hard + +"smart-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "smart-buffer@npm:4.2.0" + checksum: 10c0/a16775323e1404dd43fabafe7460be13a471e021637bc7889468eb45ce6a6b207261f454e4e530a19500cc962c4cc5348583520843b363f4193cee5c00e1e539 + languageName: node + linkType: hard + +"socks-proxy-agent@npm:^8.0.3": + version: 8.0.4 + resolution: "socks-proxy-agent@npm:8.0.4" + dependencies: + agent-base: "npm:^7.1.1" + debug: "npm:^4.3.4" + socks: "npm:^2.8.3" + checksum: 10c0/345593bb21b95b0508e63e703c84da11549f0a2657d6b4e3ee3612c312cb3a907eac10e53b23ede3557c6601d63252103494caa306b66560f43af7b98f53957a + languageName: node + linkType: hard + +"socks@npm:^2.8.3": + version: 2.8.3 + resolution: "socks@npm:2.8.3" + dependencies: + ip-address: "npm:^9.0.5" + smart-buffer: "npm:^4.2.0" + checksum: 10c0/d54a52bf9325165770b674a67241143a3d8b4e4c8884560c4e0e078aace2a728dffc7f70150660f51b85797c4e1a3b82f9b7aa25e0a0ceae1a243365da5c51a7 + languageName: node + linkType: hard + +"source-map-js@npm:^1.2.0": + version: 1.2.0 + resolution: "source-map-js@npm:1.2.0" + checksum: 10c0/7e5f896ac10a3a50fe2898e5009c58ff0dc102dcb056ed27a354623a0ece8954d4b2649e1a1b2b52ef2e161d26f8859c7710350930751640e71e374fe2d321a4 + languageName: node + linkType: hard + +"sprintf-js@npm:^1.1.3": + version: 1.1.3 + resolution: "sprintf-js@npm:1.1.3" + checksum: 10c0/09270dc4f30d479e666aee820eacd9e464215cdff53848b443964202bf4051490538e5dd1b42e1a65cf7296916ca17640aebf63dae9812749c7542ee5f288dec + languageName: node + linkType: hard + +"ssri@npm:^10.0.0": + version: 10.0.6 + resolution: "ssri@npm:10.0.6" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/e5a1e23a4057a86a97971465418f22ea89bd439ac36ade88812dd920e4e61873e8abd6a9b72a03a67ef50faa00a2daf1ab745c5a15b46d03e0544a0296354227 + languageName: node + linkType: hard + +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"std-env@npm:^3.5.0": + version: 3.7.0 + resolution: "std-env@npm:3.7.0" + checksum: 10c0/60edf2d130a4feb7002974af3d5a5f3343558d1ccf8d9b9934d225c638606884db4a20d2fe6440a09605bca282af6b042ae8070a10490c0800d69e82e478f41e + languageName: node + linkType: hard + +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": + version: 4.2.3 + resolution: "string-width@npm:4.2.3" + dependencies: + emoji-regex: "npm:^8.0.0" + is-fullwidth-code-point: "npm:^3.0.0" + strip-ansi: "npm:^6.0.1" + checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b + languageName: node + linkType: hard + +"string-width@npm:^5.0.1, string-width@npm:^5.1.2": + version: 5.1.2 + resolution: "string-width@npm:5.1.2" + dependencies: + eastasianwidth: "npm:^0.2.0" + emoji-regex: "npm:^9.2.2" + strip-ansi: "npm:^7.0.1" + checksum: 10c0/ab9c4264443d35b8b923cbdd513a089a60de339216d3b0ed3be3ba57d6880e1a192b70ae17225f764d7adbf5994e9bb8df253a944736c15a0240eff553c678ca + languageName: node + linkType: hard + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: "npm:^5.0.1" + checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 + languageName: node + linkType: hard + +"strip-ansi@npm:^7.0.1": + version: 7.1.0 + resolution: "strip-ansi@npm:7.1.0" + dependencies: + ansi-regex: "npm:^6.0.1" + checksum: 10c0/a198c3762e8832505328cbf9e8c8381de14a4fa50a4f9b2160138158ea88c0f5549fb50cb13c651c3088f47e63a108b34622ec18c0499b6c8c3a5ddf6b305ac4 + languageName: node + linkType: hard + +"strip-final-newline@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-final-newline@npm:3.0.0" + checksum: 10c0/a771a17901427bac6293fd416db7577e2bc1c34a19d38351e9d5478c3c415f523f391003b42ed475f27e33a78233035df183525395f731d3bfb8cdcbd4da08ce + languageName: node + linkType: hard + +"strip-literal@npm:^2.0.0": + version: 2.1.0 + resolution: "strip-literal@npm:2.1.0" + dependencies: + js-tokens: "npm:^9.0.0" + checksum: 10c0/bc8b8c8346125ae3c20fcdaf12e10a498ff85baf6f69597b4ab2b5fbf2e58cfd2827f1a44f83606b852da99a5f6c8279770046ddea974c510c17c98934c9cc24 + languageName: node + linkType: hard + +"supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124 + languageName: node + linkType: hard + +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 + languageName: node + linkType: hard + +"tar@npm:^6.1.11, tar@npm:^6.1.2": + version: 6.2.1 + resolution: "tar@npm:6.2.1" + dependencies: + chownr: "npm:^2.0.0" + fs-minipass: "npm:^2.0.0" + minipass: "npm:^5.0.0" + minizlib: "npm:^2.1.1" + mkdirp: "npm:^1.0.3" + yallist: "npm:^4.0.0" + checksum: 10c0/a5eca3eb50bc11552d453488344e6507156b9193efd7635e98e867fab275d527af53d8866e2370cd09dfe74378a18111622ace35af6a608e5223a7d27fe99537 + languageName: node + linkType: hard + +"test-exclude@npm:^6.0.0": + version: 6.0.0 + resolution: "test-exclude@npm:6.0.0" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^7.1.4" + minimatch: "npm:^3.0.4" + checksum: 10c0/019d33d81adff3f9f1bfcff18125fb2d3c65564f437d9be539270ee74b994986abb8260c7c2ce90e8f30162178b09dbbce33c6389273afac4f36069c48521f57 + languageName: node + linkType: hard + +"tinybench@npm:^2.5.1": + version: 2.8.0 + resolution: "tinybench@npm:2.8.0" + checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d + languageName: node + linkType: hard + +"tinypool@npm:^0.8.3": + version: 0.8.4 + resolution: "tinypool@npm:0.8.4" + checksum: 10c0/779c790adcb0316a45359652f4b025958c1dff5a82460fe49f553c864309b12ad732c8288be52f852973bc76317f5e7b3598878aee0beb8a33322c0e72c4a66c + languageName: node + linkType: hard + +"tinyspy@npm:^2.2.0": + version: 2.2.1 + resolution: "tinyspy@npm:2.2.1" + checksum: 10c0/0b4cfd07c09871e12c592dfa7b91528124dc49a4766a0b23350638c62e6a483d5a2a667de7e6282246c0d4f09996482ddaacbd01f0c05b7ed7e0f79d32409bdc + languageName: node + linkType: hard + +"to-fast-properties@npm:^2.0.0": + version: 2.0.0 + resolution: "to-fast-properties@npm:2.0.0" + checksum: 10c0/b214d21dbfb4bce3452b6244b336806ffea9c05297148d32ebb428d5c43ce7545bdfc65a1ceb58c9ef4376a65c0cb2854d645f33961658b3e3b4f84910ddcdd7 + languageName: node + linkType: hard + +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: "npm:^7.0.0" + checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892 + languageName: node + linkType: hard + +"totalist@npm:^3.0.0": + version: 3.0.1 + resolution: "totalist@npm:3.0.1" + checksum: 10c0/4bb1fadb69c3edbef91c73ebef9d25b33bbf69afe1e37ce544d5f7d13854cda15e47132f3e0dc4cafe300ddb8578c77c50a65004d8b6e97e77934a69aa924863 + languageName: node + linkType: hard + +"tough-cookie@npm:^4.1.4": + version: 4.1.4 + resolution: "tough-cookie@npm:4.1.4" + dependencies: + psl: "npm:^1.1.33" + punycode: "npm:^2.1.1" + universalify: "npm:^0.2.0" + url-parse: "npm:^1.5.3" + checksum: 10c0/aca7ff96054f367d53d1e813e62ceb7dd2eda25d7752058a74d64b7266fd07be75908f3753a32ccf866a2f997604b414cfb1916d6e7f69bc64d9d9939b0d6c45 + languageName: node + linkType: hard + +"tr46@npm:^5.0.0": + version: 5.0.0 + resolution: "tr46@npm:5.0.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/1521b6e7bbc8adc825c4561480f9fe48eb2276c81335eed9fa610aa4c44a48a3221f78b10e5f18b875769eb3413e30efbf209ed556a17a42aa8d690df44b7bee + languageName: node + linkType: hard + +"type-detect@npm:^4.0.0, type-detect@npm:^4.0.8": + version: 4.0.8 + resolution: "type-detect@npm:4.0.8" + checksum: 10c0/8fb9a51d3f365a7de84ab7f73b653534b61b622aa6800aecdb0f1095a4a646d3f5eb295322127b6573db7982afcd40ab492d038cf825a42093a58b1e1353e0bd + languageName: node + linkType: hard + +"ufo@npm:^1.5.3": + version: 1.5.3 + resolution: "ufo@npm:1.5.3" + checksum: 10c0/1df10702582aa74f4deac4486ecdfd660e74be057355f1afb6adfa14243476cf3d3acff734ccc3d0b74e9bfdefe91d578f3edbbb0a5b2430fe93cd672370e024 + languageName: node + linkType: hard + +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501 + languageName: node + linkType: hard + +"unique-filename@npm:^3.0.0": + version: 3.0.0 + resolution: "unique-filename@npm:3.0.0" + dependencies: + unique-slug: "npm:^4.0.0" + checksum: 10c0/6363e40b2fa758eb5ec5e21b3c7fb83e5da8dcfbd866cc0c199d5534c42f03b9ea9ab069769cc388e1d7ab93b4eeef28ef506ab5f18d910ef29617715101884f + languageName: node + linkType: hard + +"unique-slug@npm:^4.0.0": + version: 4.0.0 + resolution: "unique-slug@npm:4.0.0" + dependencies: + imurmurhash: "npm:^0.1.4" + checksum: 10c0/cb811d9d54eb5821b81b18205750be84cb015c20a4a44280794e915f5a0a70223ce39066781a354e872df3572e8155c228f43ff0cce94c7cbf4da2cc7cbdd635 + languageName: node + linkType: hard + +"universalify@npm:^0.2.0": + version: 0.2.0 + resolution: "universalify@npm:0.2.0" + checksum: 10c0/cedbe4d4ca3967edf24c0800cfc161c5a15e240dac28e3ce575c689abc11f2c81ccc6532c8752af3b40f9120fb5e454abecd359e164f4f6aa44c29cd37e194fe + languageName: node + linkType: hard + +"url-parse@npm:^1.5.3": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: 10c0/bd5aa9389f896974beb851c112f63b466505a04b4807cea2e5a3b7092f6fbb75316f0491ea84e44f66fed55f1b440df5195d7e3a8203f64fcefa19d182f5be87 + languageName: node + linkType: hard + +"vite-node@npm:1.6.0": + version: 1.6.0 + resolution: "vite-node@npm:1.6.0" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.4" + pathe: "npm:^1.1.1" + picocolors: "npm:^1.0.0" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/0807e6501ac7763e0efa2b4bd484ce99fb207e92c98624c9f8999d1f6727ac026e457994260fa7fdb7060d87546d197081e46a705d05b0136a38b6f03715cbc2 + languageName: node + linkType: hard + +"vite@npm:^5.0.0, vite@npm:^5.3.1": + version: 5.3.3 + resolution: "vite@npm:5.3.3" + dependencies: + esbuild: "npm:^0.21.3" + fsevents: "npm:~2.3.3" + postcss: "npm:^8.4.39" + rollup: "npm:^4.13.0" + peerDependencies: + "@types/node": ^18.0.0 || >=20.0.0 + less: "*" + lightningcss: ^1.21.0 + sass: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/a796872e1d11875d994615cd00da185c80eeb7753034d35c096050bf3c269c02004070cf623c5fe2a4a90ea2f12488e6f9d13933ec810f117f1b931e1b5e3385 + languageName: node + linkType: hard + +"vitest@npm:^1.6.0": + version: 1.6.0 + resolution: "vitest@npm:1.6.0" + dependencies: + "@vitest/expect": "npm:1.6.0" + "@vitest/runner": "npm:1.6.0" + "@vitest/snapshot": "npm:1.6.0" + "@vitest/spy": "npm:1.6.0" + "@vitest/utils": "npm:1.6.0" + acorn-walk: "npm:^8.3.2" + chai: "npm:^4.3.10" + debug: "npm:^4.3.4" + execa: "npm:^8.0.1" + local-pkg: "npm:^0.5.0" + magic-string: "npm:^0.30.5" + pathe: "npm:^1.1.1" + picocolors: "npm:^1.0.0" + std-env: "npm:^3.5.0" + strip-literal: "npm:^2.0.0" + tinybench: "npm:^2.5.1" + tinypool: "npm:^0.8.3" + vite: "npm:^5.0.0" + vite-node: "npm:1.6.0" + why-is-node-running: "npm:^2.2.2" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 1.6.0 + "@vitest/ui": 1.6.0 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/065da5b8ead51eb174d93dac0cd50042ca9539856dc25e340ea905d668c41961f7e00df3e388e6c76125b2c22091db2e8465f993d0f6944daf9598d549e562e7 + languageName: node + linkType: hard + +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" + dependencies: + xml-name-validator: "npm:^5.0.0" + checksum: 10c0/8712774c1aeb62dec22928bf1cdfd11426c2c9383a1a63f2bcae18db87ca574165a0fbe96b312b73652149167ac6c7f4cf5409f2eb101d9c805efe0e4bae798b + languageName: node + linkType: hard + +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10c0/228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4 + languageName: node + linkType: hard + +"whatwg-encoding@npm:^3.1.1": + version: 3.1.1 + resolution: "whatwg-encoding@npm:3.1.1" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10c0/273b5f441c2f7fda3368a496c3009edbaa5e43b71b09728f90425e7f487e5cef9eb2b846a31bd760dd8077739c26faf6b5ca43a5f24033172b003b72cf61a93e + languageName: node + linkType: hard + +"whatwg-mimetype@npm:^4.0.0": + version: 4.0.0 + resolution: "whatwg-mimetype@npm:4.0.0" + checksum: 10c0/a773cdc8126b514d790bdae7052e8bf242970cebd84af62fb2f35a33411e78e981f6c0ab9ed1fe6ec5071b09d5340ac9178e05b52d35a9c4bcf558ba1b1551df + languageName: node + linkType: hard + +"whatwg-url@npm:^14.0.0": + version: 14.0.0 + resolution: "whatwg-url@npm:14.0.0" + dependencies: + tr46: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/ac32e9ba9d08744605519bbe9e1371174d36229689ecc099157b6ba102d4251a95e81d81f3d80271eb8da182eccfa65653f07f0ab43ea66a6934e643fd091ba9 + languageName: node + linkType: hard + +"which@npm:^2.0.1": + version: 2.0.2 + resolution: "which@npm:2.0.2" + dependencies: + isexe: "npm:^2.0.0" + bin: + node-which: ./bin/node-which + checksum: 10c0/66522872a768b60c2a65a57e8ad184e5372f5b6a9ca6d5f033d4b0dc98aff63995655a7503b9c0a2598936f532120e81dd8cc155e2e92ed662a2b9377cc4374f + languageName: node + linkType: hard + +"which@npm:^4.0.0": + version: 4.0.0 + resolution: "which@npm:4.0.0" + dependencies: + isexe: "npm:^3.1.1" + bin: + node-which: bin/which.js + checksum: 10c0/449fa5c44ed120ccecfe18c433296a4978a7583bf2391c50abce13f76878d2476defde04d0f79db8165bdf432853c1f8389d0485ca6e8ebce3bbcded513d5e6a + languageName: node + linkType: hard + +"why-is-node-running@npm:^2.2.2": + version: 2.2.2 + resolution: "why-is-node-running@npm:2.2.2" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/805d57eb5d33f0fb4e36bae5dceda7fd8c6932c2aeb705e30003970488f1a2bc70029ee64be1a0e1531e2268b11e65606e88e5b71d667ea745e6dc48fc9014bd + languageName: node + linkType: hard + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10c0/d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da + languageName: node + linkType: hard + +"wrap-ansi@npm:^8.1.0": + version: 8.1.0 + resolution: "wrap-ansi@npm:8.1.0" + dependencies: + ansi-styles: "npm:^6.1.0" + string-width: "npm:^5.0.1" + strip-ansi: "npm:^7.0.1" + checksum: 10c0/138ff58a41d2f877eae87e3282c0630fc2789012fc1af4d6bd626eeb9a2f9a65ca92005e6e69a75c7b85a68479fe7443c7dbe1eb8fbaa681a4491364b7c55c60 + languageName: node + linkType: hard + +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 + languageName: node + linkType: hard + +"ws@npm:^8.18.0": + version: 8.18.0 + resolution: "ws@npm:8.18.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/25eb33aff17edcb90721ed6b0eb250976328533ad3cd1a28a274bd263682e7296a6591ff1436d6cbc50fa67463158b062f9d1122013b361cec99a05f84680e06 + languageName: node + linkType: hard + +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 10c0/3fcf44e7b73fb18be917fdd4ccffff3639373c7cb83f8fc35df6001fecba7942f1dbead29d91ebb8315e2f2ff786b508f0c9dc0215b6353f9983c6b7d62cb1f5 + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + +"yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a + languageName: node + linkType: hard + +"yocto-queue@npm:^1.0.0": + version: 1.1.1 + resolution: "yocto-queue@npm:1.1.1" + checksum: 10c0/cb287fe5e6acfa82690acb43c283de34e945c571a78a939774f6eaba7c285bacdf6c90fbc16ce530060863984c906d2b4c6ceb069c94d1e0a06d5f2b458e2a92 + languageName: node + linkType: hard diff --git a/frontend/yarn.lock b/frontend/yarn.lock index e602e4791..58b2cbe1e 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -948,12 +948,11 @@ __metadata: languageName: node linkType: hard -"@penpot/text-editor@penpot/penpot-text-editor#a100aad8d0efcbb070bed9144dbd2782547e78ba": - version: 0.0.0 - resolution: "@penpot/text-editor@https://github.com/penpot/penpot-text-editor.git#commit=a100aad8d0efcbb070bed9144dbd2782547e78ba" - checksum: 10c0/328c827cd740c5e05df678083cfb1d2b6d006b56523daa0bd2a3c2936a0490a2ae4d0e69a3aec428674609a22a5fafdd5600aae1399cb3f4ed5b80e497c74a5c +"@penpot/text-editor@portal:./text-editor::locator=frontend%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@penpot/text-editor@portal:./text-editor::locator=frontend%40workspace%3A." languageName: node - linkType: hard + linkType: soft "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 @@ -4300,7 +4299,7 @@ __metadata: "@penpot/hljs": "portal:./vendor/hljs" "@penpot/mousetrap": "portal:./vendor/mousetrap" "@penpot/svgo": "penpot/svgo#c6fba7a4dcfbc27b643e7fc0c94fc98cf680b77b" - "@penpot/text-editor": "penpot/penpot-text-editor#a100aad8d0efcbb070bed9144dbd2782547e78ba" + "@penpot/text-editor": "portal:./text-editor" "@playwright/test": "npm:1.48.1" "@storybook/addon-essentials": "npm:^8.3.6" "@storybook/addon-themes": "npm:^8.3.6"