From e55202bc67ff9a02a7c38801ca3ca380aac0ab79 Mon Sep 17 00:00:00 2001 From: b12f Date: Tue, 8 Oct 2024 09:15:26 +0200 Subject: [PATCH] rich-text: init --- .editorconfig | 27 +- flake.lock | 8 +- flake.nix | 19 +- package-lock.json | 1835 ++++++++++++++++- package.json | 10 +- packages/core/__tests__/core.test.ts | 33 + .../{heading.test.js => heading.test.ts} | 0 .../{image.test.js => image.test.ts} | 0 .../{layout.test.js => layout.test.ts} | 0 .../{paragraph.test.js => paragraph.test.ts} | 0 packages/rich-text/.npmrc | 1 + packages/rich-text/CHANGELOG.md | 251 +++ packages/rich-text/README.md | 490 +++++ packages/rich-text/lib/apply-format.ts | 107 + .../component/event-listeners/copy-handler.ts | 41 + .../lib/component/event-listeners/delete.ts | 38 + .../event-listeners/format-boundaries.ts | 103 + .../lib/component/event-listeners/index.ts | 43 + .../event-listeners/input-and-selection.ts | 259 +++ .../event-listeners/select-object.ts | 54 + .../selection-change-compat.ts | 53 + packages/rich-text/lib/component/index.ts | 214 ++ .../rich-text/lib/component/use-anchor.ts | 204 ++ .../lib/component/use-boundary-style.ts | 55 + .../lib/component/use-default-style.ts | 42 + packages/rich-text/lib/concat.ts | 32 + packages/rich-text/lib/create-element.ts | 25 + packages/rich-text/lib/create.ts | 549 +++++ packages/rich-text/lib/get-active-format.ts | 23 + packages/rich-text/lib/get-active-formats.ts | 86 + packages/rich-text/lib/get-active-object.ts | 21 + packages/rich-text/lib/get-text-content.ts | 17 + packages/rich-text/lib/index.ts | 32 + packages/rich-text/lib/insert-object.ts | 29 + packages/rich-text/lib/insert.ts | 53 + packages/rich-text/lib/is-collapsed.ts | 26 + packages/rich-text/lib/is-empty.ts | 13 + packages/rich-text/lib/is-format-equal.ts | 58 + packages/rich-text/lib/is-range-equal.ts | 23 + packages/rich-text/lib/join.ts | 34 + packages/rich-text/lib/length.ts | 5 + packages/rich-text/lib/normalise-formats.ts | 37 + packages/rich-text/lib/remove-format.ts | 83 + packages/rich-text/lib/remove.ts | 21 + packages/rich-text/lib/replace.ts | 66 + packages/rich-text/lib/slice.ts | 26 + packages/rich-text/lib/special-characters.ts | 10 + packages/rich-text/lib/split.ts | 82 + .../test/__snapshots__/to-dom.test.ts.snap | 303 +++ .../rich-text/lib/test/apply-format.test.ts | 265 +++ packages/rich-text/lib/test/concat.test.ts | 32 + packages/rich-text/lib/test/create.test.ts | 122 ++ .../lib/test/get-active-format.test.ts | 95 + .../lib/test/get-active-object.test.ts | 38 + packages/rich-text/lib/test/helpers/index.ts | 724 +++++++ .../rich-text/lib/test/insert-object.test.ts | 33 + packages/rich-text/lib/test/insert.test.ts | 63 + .../rich-text/lib/test/is-collapsed.test.ts | 13 + packages/rich-text/lib/test/is-empty.test.ts | 22 + .../lib/test/is-format-equal.test.ts | 70 + packages/rich-text/lib/test/join.test.ts | 45 + .../lib/test/normalise-formats.test.ts | 32 + .../rich-text/lib/test/remove-format.test.ts | 71 + packages/rich-text/lib/test/replace.test.ts | 86 + packages/rich-text/lib/test/slice.test.ts | 46 + packages/rich-text/lib/test/split.test.ts | 230 +++ packages/rich-text/lib/test/to-dom.test.ts | 98 + .../rich-text/lib/test/to-html-string.test.ts | 114 + .../rich-text/lib/test/toggle-format.test.ts | 82 + .../rich-text/lib/test/update-formats.test.ts | 52 + packages/rich-text/lib/to-dom.ts | 313 +++ packages/rich-text/lib/to-html-string.ts | 124 ++ packages/rich-text/lib/to-tree.ts | 343 +++ packages/rich-text/lib/toggle-format.ts | 29 + packages/rich-text/lib/types.ts | 74 + packages/rich-text/lib/update-formats.ts | 53 + packages/rich-text/lib/use-format-types.ts | 45 + packages/rich-text/package.json | 36 + packages/standalone/__tests__/core.test.js | 7 - .../__tests__/core.test.ts} | 0 test/index.ts | 1 + test/jsdom.ts | 8 + test/with-setup.ts | 17 + tsconfig.json | 3 + vitest-example/HelloWorld.test.ts | 10 + vitest-example/HelloWorld.vue | 11 + vitest.config.ts | 15 + 87 files changed, 8911 insertions(+), 52 deletions(-) create mode 100644 packages/core/__tests__/core.test.ts rename packages/heading/__tests__/{heading.test.js => heading.test.ts} (100%) rename packages/image/__tests__/{image.test.js => image.test.ts} (100%) rename packages/layout/__tests__/{layout.test.js => layout.test.ts} (100%) rename packages/paragraph/__tests__/{paragraph.test.js => paragraph.test.ts} (100%) create mode 100644 packages/rich-text/.npmrc create mode 100644 packages/rich-text/CHANGELOG.md create mode 100644 packages/rich-text/README.md create mode 100644 packages/rich-text/lib/apply-format.ts create mode 100644 packages/rich-text/lib/component/event-listeners/copy-handler.ts create mode 100644 packages/rich-text/lib/component/event-listeners/delete.ts create mode 100644 packages/rich-text/lib/component/event-listeners/format-boundaries.ts create mode 100644 packages/rich-text/lib/component/event-listeners/index.ts create mode 100644 packages/rich-text/lib/component/event-listeners/input-and-selection.ts create mode 100644 packages/rich-text/lib/component/event-listeners/select-object.ts create mode 100644 packages/rich-text/lib/component/event-listeners/selection-change-compat.ts create mode 100644 packages/rich-text/lib/component/index.ts create mode 100644 packages/rich-text/lib/component/use-anchor.ts create mode 100644 packages/rich-text/lib/component/use-boundary-style.ts create mode 100644 packages/rich-text/lib/component/use-default-style.ts create mode 100644 packages/rich-text/lib/concat.ts create mode 100644 packages/rich-text/lib/create-element.ts create mode 100644 packages/rich-text/lib/create.ts create mode 100644 packages/rich-text/lib/get-active-format.ts create mode 100644 packages/rich-text/lib/get-active-formats.ts create mode 100644 packages/rich-text/lib/get-active-object.ts create mode 100644 packages/rich-text/lib/get-text-content.ts create mode 100644 packages/rich-text/lib/index.ts create mode 100644 packages/rich-text/lib/insert-object.ts create mode 100644 packages/rich-text/lib/insert.ts create mode 100644 packages/rich-text/lib/is-collapsed.ts create mode 100644 packages/rich-text/lib/is-empty.ts create mode 100644 packages/rich-text/lib/is-format-equal.ts create mode 100644 packages/rich-text/lib/is-range-equal.ts create mode 100644 packages/rich-text/lib/join.ts create mode 100644 packages/rich-text/lib/length.ts create mode 100644 packages/rich-text/lib/normalise-formats.ts create mode 100644 packages/rich-text/lib/remove-format.ts create mode 100644 packages/rich-text/lib/remove.ts create mode 100644 packages/rich-text/lib/replace.ts create mode 100644 packages/rich-text/lib/slice.ts create mode 100644 packages/rich-text/lib/special-characters.ts create mode 100644 packages/rich-text/lib/split.ts create mode 100644 packages/rich-text/lib/test/__snapshots__/to-dom.test.ts.snap create mode 100644 packages/rich-text/lib/test/apply-format.test.ts create mode 100644 packages/rich-text/lib/test/concat.test.ts create mode 100644 packages/rich-text/lib/test/create.test.ts create mode 100644 packages/rich-text/lib/test/get-active-format.test.ts create mode 100644 packages/rich-text/lib/test/get-active-object.test.ts create mode 100644 packages/rich-text/lib/test/helpers/index.ts create mode 100644 packages/rich-text/lib/test/insert-object.test.ts create mode 100644 packages/rich-text/lib/test/insert.test.ts create mode 100644 packages/rich-text/lib/test/is-collapsed.test.ts create mode 100644 packages/rich-text/lib/test/is-empty.test.ts create mode 100644 packages/rich-text/lib/test/is-format-equal.test.ts create mode 100644 packages/rich-text/lib/test/join.test.ts create mode 100644 packages/rich-text/lib/test/normalise-formats.test.ts create mode 100644 packages/rich-text/lib/test/remove-format.test.ts create mode 100644 packages/rich-text/lib/test/replace.test.ts create mode 100644 packages/rich-text/lib/test/slice.test.ts create mode 100644 packages/rich-text/lib/test/split.test.ts create mode 100644 packages/rich-text/lib/test/to-dom.test.ts create mode 100644 packages/rich-text/lib/test/to-html-string.test.ts create mode 100644 packages/rich-text/lib/test/toggle-format.test.ts create mode 100644 packages/rich-text/lib/test/update-formats.test.ts create mode 100644 packages/rich-text/lib/to-dom.ts create mode 100644 packages/rich-text/lib/to-html-string.ts create mode 100644 packages/rich-text/lib/to-tree.ts create mode 100644 packages/rich-text/lib/toggle-format.ts create mode 100644 packages/rich-text/lib/types.ts create mode 100644 packages/rich-text/lib/update-formats.ts create mode 100644 packages/rich-text/lib/use-format-types.ts create mode 100644 packages/rich-text/package.json delete mode 100644 packages/standalone/__tests__/core.test.js rename packages/{core/__tests__/core.test.js => standalone/__tests__/core.test.ts} (100%) create mode 100644 test/index.ts create mode 100644 test/jsdom.ts create mode 100644 test/with-setup.ts create mode 100644 vitest-example/HelloWorld.test.ts create mode 100644 vitest-example/HelloWorld.vue create mode 100644 vitest.config.ts diff --git a/.editorconfig b/.editorconfig index 7440d11..e3c09ff 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,24 @@ -[*.{js,jsx,ts,tsx,mts,mtsx,vue.nix, md}] +# Editor configuration, see http://editorconfig.org +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 indent_style = space indent_size = 2 -end_of_line = lf -trim_trailing_whitespace = true -insert_final_newline = true -max_line_length = 100 + +# Ignore diffs/patches +[*.{diff,patch}] +end_of_line = unset +insert_final_newline = unset +trim_trailing_whitespace = unset +indent_size = unset +charset = unset +indent_style = unset +indent_size = unset + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/flake.lock b/flake.lock index c5d5bcc..56bf54b 100644 --- a/flake.lock +++ b/flake.lock @@ -59,16 +59,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1727540905, - "narHash": "sha256-40J9tW7Y794J7Uw4GwcAKlMxlX2xISBl6IBigo83ih8=", + "lastModified": 1728018373, + "narHash": "sha256-NOiTvBbRLIOe5F6RbHaAh6++BNjsb149fGZd1T4+KBg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fbca5e745367ae7632731639de5c21f29c8744ed", + "rev": "bc947f541ae55e999ffdb4013441347d83b00feb", "type": "github" }, "original": { "owner": "nixos", - "ref": "nixos-24.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index 24454e7..96bff0a 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "Schlechtenburg"; inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05"; + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; @@ -17,11 +17,18 @@ preOverlays = [ devshell.overlays.default ]; shell = { pkgs }: pkgs.devshell.mkShell { devshell.packages = with pkgs; [ - nodejs - nodePackages.typescript - nodePackages.typescript-language-server - nodePackages.vue-language-server - ]; + nodejs + nodePackages.typescript + nodePackages.typescript-language-server + nodePackages.vue-language-server + playwright + ]; + + env = [ + { name = "PLAYWRIGHT_NODEJS_PATH"; value = "${pkgs.nodejs}/bin/node"; } + { name = "PLAYWRIGHT_BROWSERS_PATH"; value = "${pkgs.playwright-driver.browsers}"; } + { name = "PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS"; value = "true"; } + ]; }; }; } diff --git a/package-lock.json b/package-lock.json index 79700ae..7714468 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,18 +20,19 @@ "lodash-es": "^4.17.21" }, "devDependencies": { - "@schlechtenburg/core": "^0.0.0", - "@schlechtenburg/heading": "^0.0.0", - "@schlechtenburg/image": "^0.0.0", - "@schlechtenburg/layout": "^0.0.0", - "@schlechtenburg/paragraph": "^0.0.0", - "@schlechtenburg/standalone": "^0.0.0", + "@types/jsdom": "^21.1.7", + "@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue-jsx": "^3.1.0", + "@vitest/browser": "^2.1.2", + "@vue/test-utils": "^2.4.6", + "deep-freeze": "^0.0.1", "lerna": "^8.1.8", "sass": "^1.75.0", "typedoc": "^0.26.7", "typescript": "^5.6.2", "vitepress": "^1.3.4", + "vitest": "^2.1.2", + "vitest-browser-vue": "^0.0.1", "vue": "^3.5.10" } }, @@ -566,9 +567,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", - "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.7.tgz", + "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==", "dev": true, "engines": { "node": ">=6.9.0" @@ -746,6 +747,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", + "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", @@ -795,6 +808,67 @@ "node": ">=6.9.0" } }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.0.tgz", + "integrity": "sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==", + "dev": true, + "dependencies": { + "cookie": "^0.5.0" + } + }, + "node_modules/@bundled-es-modules/cookie/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@bundled-es-modules/tough-cookie": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", + "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", + "dev": true, + "dependencies": { + "@types/tough-cookie": "^4.0.5", + "tough-cookie": "^4.1.4" + } + }, + "node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@bundled-es-modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@docsearch/css": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.6.2.tgz", @@ -1248,6 +1322,114 @@ "node": ">=6.9.0" } }, + "node_modules/@inquirer/confirm": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.2.0.tgz", + "integrity": "sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==", + "dev": true, + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz", + "integrity": "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==", + "dev": true, + "dependencies": { + "@inquirer/figures": "^1.0.6", + "@inquirer/type": "^2.0.0", + "@types/mute-stream": "^0.0.4", + "@types/node": "^22.5.5", + "@types/wrap-ansi": "^3.0.0", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^1.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core/node_modules/@inquirer/type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz", + "integrity": "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==", + "dev": true, + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.7.tgz", + "integrity": "sha512-m+Trk77mp54Zma6xLkLuY+mvanPxlE4A7yNKs2HBiyZ4UkVs28Mv5c/pgWrHeInx+USHeX/WEPzjrWrcJiQgjw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", + "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", + "dev": true, + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type/node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/@intlify/core": { "version": "9.14.1", "resolved": "https://registry.npmjs.org/@intlify/core/-/core-9.14.1.tgz", @@ -1646,6 +1828,23 @@ "node": ">=8" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.35.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.35.9.tgz", + "integrity": "sha512-SSnyl/4ni/2ViHKkiZb8eajA/eN1DNFaHjhGiLUdZvDz6PKF4COSf/17xqSz64nOo2Ia29SA6B2KNCsyCbVmaQ==", + "dev": true, + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", @@ -2524,6 +2723,34 @@ "@octokit/openapi-types": "^18.0.0" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2534,6 +2761,12 @@ "node": ">=14" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", + "dev": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.22.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.5.tgz", @@ -3044,6 +3277,140 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/@testing-library/dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -3099,12 +3466,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true + }, "node_modules/@types/braces": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.4.tgz", "integrity": "sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==", "dev": true }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -3120,6 +3499,29 @@ "@types/unist": "*" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/jsdom/node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -3187,15 +3589,22 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, - "node_modules/@types/node": { - "version": "20.12.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", - "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "node_modules/@types/mute-stream": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", + "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "undici-types": "~5.26.4" + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.7.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", + "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" } }, "node_modules/@types/normalize-package-data": { @@ -3204,6 +3613,38 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "node_modules/@types/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@types/react": { + "version": "18.3.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz", + "integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", + "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", + "dev": true + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3222,6 +3663,12 @@ "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", "dev": true }, + "node_modules/@types/wrap-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", + "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", + "dev": true + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -3259,6 +3706,158 @@ "vue": "^3.0.0" } }, + "node_modules/@vitest/browser": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-2.1.2.tgz", + "integrity": "sha512-tqpGfz2sfjFFNuZ2iLZ6EGRVnH8z18O93ZIicbLsxDhiLgRNz84UcjSvX4pbheuddW+BJeNbLGdM3BU8vohbEg==", + "dev": true, + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.5.2", + "@vitest/mocker": "2.1.2", + "@vitest/utils": "2.1.2", + "magic-string": "^0.30.11", + "msw": "^2.3.5", + "sirv": "^2.0.4", + "tinyrainbow": "^1.2.0", + "ws": "^8.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "2.1.2", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.2.tgz", + "integrity": "sha512-FEgtlN8mIUSEAAnlvn7mP8vzaWhEaAEvhSXCqrsijM7K6QqjB11qoRZYEd4AKSCDz8p0/+yH5LzhZ47qt+EyPg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.2.tgz", + "integrity": "sha512-ExElkCGMS13JAJy+812fw1aCv2QO/LBK6CyO4WOPAzLTmve50gydOlWhgdBJPx2ztbADUq3JVI0C5U+bShaeEA==", + "dev": true, + "dependencies": { + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.2", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.2.tgz", + "integrity": "sha512-FIoglbHrSUlOJPDGIrh2bjX1sNars5HbxlcsFKCtKzu4+5lpsRhOCVcuzp0fEhAGHkPZRIXVNzPcpSlkoZ3LuA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.2.tgz", + "integrity": "sha512-UCsPtvluHO3u7jdoONGjOSil+uON5SSvU9buQh3lP7GgUXHp78guN1wRmZDX4wGK6J10f9NUtP6pO+SFquoMlw==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.2", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.2.tgz", + "integrity": "sha512-xtAeNsZ++aRIYIUsek7VHzry/9AcxeULlegBvsdLncLmNCR6tR8SRjn8BbDP4naxtccvzTqZ+L1ltZlRCfBZFA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.2", + "magic-string": "^0.30.11", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.2.tgz", + "integrity": "sha512-GSUi5zoy+abNRJwmFhBDC0yRuVUn8WMlQscvnbbXdKLXX9dE59YbfwXxuJ/mth6eeqIzofU8BB5XDo/Ns/qK2A==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.2.tgz", + "integrity": "sha512-zMO2KdYy6mx56btx9JvAqAZ6EyS3g49krMPPrgOp1yxGZiA93HumGk+bZ5jIZtOg5/VBYl5eBmGRQHqq4FG6uQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.2", + "loupe": "^3.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/babel-helper-vue-transform-on": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.2.2.tgz", @@ -3465,6 +4064,16 @@ "integrity": "sha512-VkkBhU97Ki+XJ0xvl4C9YJsIZ2uIlQ7HqPpZOS3m9VCvmROPaChZU6DexdMJqvz9tbgG+4EtFVrSuailUq5KGQ==", "dev": true }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, "node_modules/@vuedx/analyze": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@vuedx/analyze/-/analyze-0.6.3.tgz", @@ -4207,6 +4816,15 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-differ": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", @@ -4240,6 +4858,15 @@ "node": ">=0.10.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4435,6 +5062,15 @@ "node": ">=12.17" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/cacache": { "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", @@ -4550,6 +5186,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", + "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -4564,6 +5216,14 @@ "node": ">=4" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -4590,6 +5250,15 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5039,6 +5708,16 @@ "typedarray": "^0.0.6" } }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -5339,6 +6018,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", + "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "rrweb-cssom": "^0.7.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -5354,6 +6047,61 @@ "node": ">=8" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -5370,12 +6118,12 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -5420,6 +6168,14 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -5434,6 +6190,21 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-freeze": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz", + "integrity": "sha512-Z+z8HiAvsGwmjqlphnHW5oz6yWlOwu6EQfFTjmeTWlDeda3FS2yv3jhq35TX/ewmsnqB+RX2IdsIOyjJCQN5tg==", + "dev": true + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -5522,6 +6293,12 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -5573,6 +6350,69 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "dev": true, + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/editorconfig/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -6119,6 +6959,15 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/get-pkg-repo": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/get-pkg-repo/-/get-pkg-repo-4.2.1.tgz", @@ -6465,6 +7314,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -6564,6 +7422,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -6597,6 +7461,20 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -7073,6 +7951,12 @@ "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", "dev": true }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7109,6 +7993,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/is-ssh": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.0.tgz", @@ -7408,6 +8300,36 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/js-beautify": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz", + "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==", + "dev": true, + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.3.3", + "js-cookie": "^3.0.5", + "nopt": "^7.2.0" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7432,6 +8354,131 @@ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", "dev": true }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -7966,6 +9013,29 @@ "node": ">=8" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", + "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru_map": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz", @@ -7987,6 +9057,15 @@ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", "dev": true }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -8671,12 +9750,146 @@ "node": ">=0.10.0" } }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/msw": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.4.9.tgz", + "integrity": "sha512-1m8xccT6ipN4PTqLinPwmzhxQREuxaEJYdx4nIbggxP8aM7r1e71vE7RtOUSQoAm1LydjGfZKy7370XD/tsuYg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.0", + "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", + "@inquirer/confirm": "^3.0.0", + "@mswjs/interceptors": "^0.35.8", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "chalk": "^4.1.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.2", + "path-to-regexp": "^6.3.0", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.9.0", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/msw/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/msw/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/msw/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/msw/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/msw/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz", + "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/multimatch": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", @@ -9034,6 +10247,14 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.13.tgz", + "integrity": "sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/nx": { "version": "19.8.2", "resolved": "https://registry.npmjs.org/nx/-/nx-19.8.2.tgz", @@ -9403,6 +10624,12 @@ "node": ">=0.10.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -9717,6 +10944,12 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -9726,6 +10959,21 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -9774,6 +11022,55 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.2.tgz", + "integrity": "sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "playwright-core": "1.47.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.2.tgz", + "integrity": "sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", @@ -9934,6 +11231,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true + }, "node_modules/protocols": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.1.tgz", @@ -9946,6 +11249,21 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -9955,6 +11273,12 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9987,6 +11311,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -10263,6 +11616,12 @@ "node": ">=8" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true + }, "node_modules/regex": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/regex/-/regex-4.3.2.tgz", @@ -10278,6 +11637,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -10458,6 +11823,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -10542,6 +11915,31 @@ "node": ">=14.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/search-insights": { "version": "2.17.2", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.2.tgz", @@ -10611,6 +12009,12 @@ "@types/hast": "^3.0.4" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -10634,6 +12038,20 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -10813,6 +12231,33 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -10979,6 +12424,14 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -11142,6 +12595,67 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", + "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.50", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.50.tgz", + "integrity": "sha512-q9GOap6q3KCsLMdOjXhWU5jVZ8/1dIib898JBRLsN+tBhENpBDcAVQbE0epADOjw11FhQQy9AcbqKGBQPUfTQA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "tldts-core": "^6.1.50" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.50", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.50.tgz", + "integrity": "sha512-na2EcZqmdA2iV9zHV7OHQDxxdciEpxrjbkp+aHmZgnZKHzoElLajP59np5/4+sare9fQBfixgvXKx8ev1d7ytw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -11172,6 +12686,29 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz", + "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -11346,12 +12883,10 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "optional": true, - "peer": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true }, "node_modules/unique-filename": { "version": "3.0.0", @@ -11500,6 +13035,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -11625,6 +13170,27 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.2.tgz", + "integrity": "sha512-HPcGNN5g/7I2OtPjLqgOtCRu/qhVvBxTUD3qzitmL0SrG1cWFzxzhMDWussxSbrRYWqnKf8P2jiNhPMSN+ymsQ==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.6", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitepress": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.3.4.tgz", @@ -11664,6 +13230,90 @@ } } }, + "node_modules/vitest": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz", + "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.2", + "@vitest/mocker": "2.1.2", + "@vitest/pretty-format": "^2.1.2", + "@vitest/runner": "2.1.2", + "@vitest/snapshot": "2.1.2", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", + "chai": "^5.1.1", + "debug": "^4.3.6", + "magic-string": "^0.30.11", + "pathe": "^1.1.2", + "std-env": "^3.7.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", + "tinypool": "^1.0.0", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.2", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.2", + "@vitest/ui": "2.1.2", + "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 + } + } + }, + "node_modules/vitest-browser-vue": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/vitest-browser-vue/-/vitest-browser-vue-0.0.1.tgz", + "integrity": "sha512-r4UoOR2zFg0p5FFmYhcdIp6gFVXcQrVr5+zpm4h+1uAD1V+x7WUWrVzkFAFzeXVU2DfjhHMGxIvEBQT2HOw4Ew==", + "dev": true, + "dependencies": { + "@vue/test-utils": "^2.4.6" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "^2.1.0-beta.4", + "vitest": "^2.1.0-beta.4", + "vue": "^3.0.0" + } + }, "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", @@ -11704,6 +13354,26 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.1.6.tgz", + "integrity": "sha512-ng11B8B/ZADUMMOsRbqv0arc442q7lifSubD0v8oDXIFoMg/mXwAPUunrroIDkY+mcD0dHKccdaznSVp8EoX3w==", + "dev": true + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walk-up-path": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", @@ -11725,6 +13395,45 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -11750,6 +13459,22 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -11976,6 +13701,46 @@ "node": ">=6" } }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -12039,6 +13804,18 @@ "node": ">=12" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index c6660dc..3f8afa9 100644 --- a/package.json +++ b/package.json @@ -7,15 +7,23 @@ "typecheck": "lerna run --stream typecheck", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", - "docs:serve": "vitepress serve docs" + "docs:serve": "vitepress serve docs", + "test:browser": "vitest" }, "devDependencies": { + "@types/jsdom": "^21.1.7", + "@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue-jsx": "^3.1.0", + "@vitest/browser": "^2.1.2", + "@vue/test-utils": "^2.4.6", + "deep-freeze": "^0.0.1", "lerna": "^8.1.8", "sass": "^1.75.0", "typedoc": "^0.26.7", "typescript": "^5.6.2", "vitepress": "^1.3.4", + "vitest": "^2.1.2", + "vitest-browser-vue": "^0.0.1", "vue": "^3.5.10" }, "dependencies": { diff --git a/packages/core/__tests__/core.test.ts b/packages/core/__tests__/core.test.ts new file mode 100644 index 0000000..b50c089 --- /dev/null +++ b/packages/core/__tests__/core.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { withSetup } from '../../../test'; +import { useActivation } from '..'; + +describe('@schlechtenburg/core', () => { + const a = 'a'; + const b = 'b'; + + it('Should activate', () => { + const { + activeBlockId, + isActive, + activate, + deactivate, + } = withSetup(() => useActivation(a)); + + activate(a); + expect(activeBlockId.value).toBe(a); + expect(isActive.value).toBeTruthy(); + + activate(b); + expect(activeBlockId.value).toBe(b); + expect(isActive.value).toBeFalsy(); + + deactivate(); + expect(isActive.value).toBeFalsy(); + expect(activeBlockId.value).toBe(undefined); + + activate(); + expect(activeBlockId.value).toBe(a); + expect(isActive.value).toBeTruthy(); + }); +}); diff --git a/packages/heading/__tests__/heading.test.js b/packages/heading/__tests__/heading.test.ts similarity index 100% rename from packages/heading/__tests__/heading.test.js rename to packages/heading/__tests__/heading.test.ts diff --git a/packages/image/__tests__/image.test.js b/packages/image/__tests__/image.test.ts similarity index 100% rename from packages/image/__tests__/image.test.js rename to packages/image/__tests__/image.test.ts diff --git a/packages/layout/__tests__/layout.test.js b/packages/layout/__tests__/layout.test.ts similarity index 100% rename from packages/layout/__tests__/layout.test.js rename to packages/layout/__tests__/layout.test.ts diff --git a/packages/paragraph/__tests__/paragraph.test.js b/packages/paragraph/__tests__/paragraph.test.ts similarity index 100% rename from packages/paragraph/__tests__/paragraph.test.js rename to packages/paragraph/__tests__/paragraph.test.ts diff --git a/packages/rich-text/.npmrc b/packages/rich-text/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/packages/rich-text/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/rich-text/CHANGELOG.md b/packages/rich-text/CHANGELOG.md new file mode 100644 index 0000000..e456968 --- /dev/null +++ b/packages/rich-text/CHANGELOG.md @@ -0,0 +1,251 @@ + + +## Unreleased + +## 7.8.0 (2024-09-19) + +## 7.7.0 (2024-09-05) + +## 7.6.0 (2024-08-21) + +## 7.5.0 (2024-08-07) + +## 7.4.0 (2024-07-24) + +## 7.3.0 (2024-07-10) + +## 7.2.0 (2024-06-26) + +## 7.1.0 (2024-06-15) + +## 7.0.0 (2024-05-31) + +### Breaking Changes + +- Increase the minimum required Node.js version to v18.12.0 matching long-term support releases ([#31270](https://github.com/WordPress/gutenberg/pull/61930)). Learn more about [Node.js releases](https://nodejs.org/en/about/previous-releases). + +## 6.35.0 (2024-05-16) + +## 6.34.0 (2024-05-02) + +## 6.33.0 (2024-04-19) + +## 6.32.0 (2024-04-03) + +## 6.31.0 (2024-03-21) + +## 6.30.0 (2024-03-06) + +## 6.29.0 (2024-02-21) + +## 6.28.0 (2024-02-09) + +## 6.27.0 (2024-01-24) + +## 6.26.0 (2024-01-10) + +## 6.25.0 (2023-12-13) + +## 6.24.0 (2023-11-29) + +## 6.23.0 (2023-11-16) + +## 6.22.0 (2023-11-02) + +## 6.21.0 (2023-10-18) + +## 6.20.0 (2023-10-05) + +## 6.19.0 (2023-09-20) + +## 6.18.0 (2023-08-31) + +## 6.17.0 (2023-08-16) + +## 6.16.0 (2023-08-10) + +## 6.15.0 (2023-07-20) + +## 6.14.0 (2023-07-05) + +## 6.13.0 (2023-06-23) + +## 6.12.0 (2023-06-07) + +## 6.11.0 (2023-05-24) + +## 6.10.0 (2023-05-10) + +## 6.9.0 (2023-04-26) + +## 6.8.0 (2023-04-12) + +## 6.7.0 (2023-03-29) + +## 6.6.0 (2023-03-15) + +## 6.5.0 (2023-03-01) + +## 6.4.0 (2023-02-15) + +## 6.3.0 (2023-02-01) + +## 6.2.0 (2023-01-11) + +## 6.1.0 (2023-01-02) + +## 6.0.0 (2022-12-14) + +### Breaking Changes + +- Updated dependencies to require React 18 ([45235](https://github.com/WordPress/gutenberg/pull/45235)) + +## 5.20.0 (2022-11-16) + +## 5.19.0 (2022-11-02) + +### Deprecations + +- Update deprecation message for the `useAnchorRef` hook ([#45195](https://github.com/WordPress/gutenberg/pull/45195)). + +## 5.18.0 (2022-10-19) + +## 5.17.0 (2022-10-05) + +## 5.16.0 (2022-09-21) + +### Deprecations + +- Introduced new `useAnchor` hook, which works better with the new `Popover` component APIs. The previous `useAnchorRef` hook is now marked as deprecated, and is scheduled to be removed in WordPress 6.3 ([#43691](https://github.com/WordPress/gutenberg/pull/43691)). + +## 5.15.0 (2022-09-13) + +## 5.14.0 (2022-08-24) + +## 5.13.0 (2022-08-10) + +## 5.12.0 (2022-07-27) + +## 5.11.0 (2022-07-13) + +## 5.10.0 (2022-06-29) + +## 5.9.0 (2022-06-15) + +## 5.8.0 (2022-06-01) + +## 5.7.0 (2022-05-18) + +## 5.6.0 (2022-05-04) + +## 5.5.0 (2022-04-21) + +## 5.4.0 (2022-04-08) + +## 5.3.0 (2022-03-23) + +## 5.2.0 (2022-03-11) + +## 5.1.1 (2022-02-10) + +### Bug Fixes + +- Removed unused `@wordpress/dom`, `@wordpress/is-shallow-equal` and `classnames` dependencies ([#38388](https://github.com/WordPress/gutenberg/pull/38388)). + +## 5.1.0 (2022-01-27) + +## 5.0.0 (2021-07-29) + +### Breaking Changes + +- Upgraded React components to work with v17.0 ([#29118](https://github.com/WordPress/gutenberg/pull/29118)). There are no new features in React v17.0 as explained in the [blog post](https://reactjs.org/blog/2020/10/20/react-v17.html). + +## 4.2.0 (2021-07-21) + +## 4.1.0 (2021-05-20) + +## 4.0.0 (2021-05-14) + +### Breaking Changes + +- Drop support for Internet Explorer 11 ([#31110](https://github.com/WordPress/gutenberg/pull/31110)). Learn more at https://make.wordpress.org/core/2021/04/22/ie-11-support-phase-out-plan/. +- Increase the minimum Node.js version to v12 matching Long Term Support releases ([#31270](https://github.com/WordPress/gutenberg/pull/31270)). Learn more at https://nodejs.org/en/about/releases/. + +## 3.25.0 (2021-03-17) + +## 3.24.0 (2020-12-17) + +### New Features + +- Added a store definition `store` for the rich-text namespace to use with `@wordpress/data` API ([#26655](https://github.com/WordPress/gutenberg/pull/26655)). + +## 3.3.0 (2019-05-21) + +### Internal + +- Removed and renamed undocumented functions and constants: + - Removed `charAt` + - Removed `getSelectionStart` + - Removed `getSelectionEnd` + - Removed `insertLineBreak` + - Renamed `isEmptyLine` to `__unstableIsEmptyLine` + - Renamed `insertLineSeparator` to `__unstableInsertLineSeparator` + - Renamed `apply` to `__unstableApply` + - Renamed `unstableToDom` to `__unstableToDom` + - Renamed `LINE_SEPARATOR` to `__UNSTABLE_LINE_SEPARATOR` + - Renamed `indentListItems` to `__unstableIndentListItems` + - Renamed `outdentListItems` to `__unstableOutdentListItems` + - Renamed `changeListType` to `__unstableChangeListType` + +## 3.1.0 (2019-03-06) + +### Enhancements + +- Added format boundaries. +- Removed parameters from `create` to filter out content. +- Removed the `createLinePadding` from `apply`, which is now built in. +- Improved format placeholder. +- Improved dom diffing. + +## 3.0.4 (2019-01-03) + +## 3.0.3 (2018-12-12) + +### Internal + +- Internal performance optimizations to avoid excessive expensive creation of DOM documents. + +## 3.0.2 (2018-11-21) + +## 3.0.1 (2018-11-20) + +## 3.0.0 (2018-11-15) + +### Breaking Changes + +- `toHTMLString` always expects an object instead of multiple arguments. + +## 2.0.4 (2018-11-09) + +## 2.0.3 (2018-11-09) + +### Bug Fixes + +- Fix Format Type Assignment During Parsing. +- Fix applying formats on multiline values without wrapper tags. + +## 2.0.2 (2018-11-03) + +## 2.0.1 (2018-10-30) + +## 2.0.0 (2018-10-30) + +- Remove `@wordpress/blocks` as a dependency. + +## 1.0.2 (2018-10-29) + +## 1.0.1 (2018-10-19) + +## 1.0.0 (2018-10-18) + +- Initial release. diff --git a/packages/rich-text/README.md b/packages/rich-text/README.md new file mode 100644 index 0000000..033a4f2 --- /dev/null +++ b/packages/rich-text/README.md @@ -0,0 +1,490 @@ +# Rich Text + +This module contains helper functions to convert HTML or a DOM tree into a rich text value and back, and to modify the value with functions that are similar to `String` methods, plus some additional ones for formatting. + +## Installation + +Install the module + +```bash +npm install @wordpress/rich-text +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ + +## Usage + +The Rich Text package is designed to aid in the manipulation of plain text strings in order that they can represent complex formatting. + +By using a `RichTextValue` value object (referred to from here on as `value`) it is possible to separate text from formatting, thereby affording the ability to easily search and manipulate rich formats. + +Examples of rich formats include: + +- bold, italic, superscript (etc) +- links +- unordered/ordered lists + +### The RichTextValue object + +The value object is comprised of the following: + +- `text` - the string of text to which rich formats are to be applied. +- `formats` - a sparse array of the same length as `text` that is filled with [formats](https://developer.wordpress.org/block-editor/how-to-guides/format-api/) (e.g. `core/link`, `core/bold` etc.) at the positions where the text is formatted. +- `start` - an index in the `text` representing the _start_ of the currently active selection. +- `end` - an index in the `text` representing the _end_ of the currently active selection. + +You should not attempt to create your own `value` objects. Rather you should rely on the built in methods of the `@wordpress/rich-text` package to build these for you. + +It is important to understand how a value represents richly formatted text. Here is an example to illustrate. + +If `text` is formatted from position 2-5 in bold (`core/bold`) and from position 2-8 with a link (`core/link`), then you'll find: + +- arrays within the sparse array at positions 2-5 that include the `core/bold` format +- arrays within the sparse array at positions 2-8 that include the `core/link` format + +Here's how that would look: + +```js +{ + text: 'Hello world', // length 11 + formats: [ + [], // 0 + [], + [ // 2 + { + type: 'core/bold', + }, + { + type: 'core/link', + } + ], + [ + { + type: 'core/bold', + }, + { + type: 'core/link', + } + ], + [ + { + type: 'core/bold', + }, + { + type: 'core/link', + } + ], + [ + { + type: 'core/bold', + }, + { + type: 'core/link', + } + ], + [ // 6 + { + type: 'core/link', + } + ] + [ + { + type: 'core/link', + } + ], + [ + { + type: 'core/link', + } + ], + [], // 9 + [], // 10 + [], // 11 + ] +} +``` + +### Selections + +Let's continue to consider the above example with the text `Hello world`. + +If, as a user, I make a selection of the word `Hello` this would result in a value object with `start` and `end` as `0` and `5` respectively. + +In general, this is useful for knowing which portion of the text is selected. However, we need to consider that selections may also be "collapsed". + +#### Collapsed selections + +A collapsed selection is one where `start` and `end` values are _identical_ (e.g. `start: 4, end: 4`). This happens when no characters are selected, but there is a caret present. This most often occurs when a user places the cursor/caret within a string of text but does not make a selection. + +Given that the selection has no "range" (i.e. there is no difference between `start` and `end` indices), finding the currently selected portion of text from collapsed values can be challenging. + +## API + + + +### applyFormat + +Apply a format object to a Rich Text value from the given `startIndex` to the given `endIndex`. Indices are retrieved from the selection if none are provided. + +_Parameters_ + +- _value_ `RichTextValue`: Value to modify. +- _format_ `RichTextFormat`: Format to apply. +- _startIndex_ `[number]`: Start index. +- _endIndex_ `[number]`: End index. + +_Returns_ + +- `RichTextValue`: A new value with the format applied. + +### concat + +Combine all Rich Text values into one. This is similar to `String.prototype.concat`. + +_Parameters_ + +- _values_ `...RichTextValue`: Objects to combine. + +_Returns_ + +- `RichTextValue`: A new value combining all given records. + +### create + +Create a RichText value from an `Element` tree (DOM), an HTML string or a plain text string, with optionally a `Range` object to set the selection. If called without any input, an empty value will be created. The optional functions can be used to filter out content. + +A value will have the following shape, which you are strongly encouraged not to modify without the use of helper functions: + +```js +{ + text: string, + formats: Array, + replacements: Array, + ?start: number, + ?end: number, +} +``` + +As you can see, text and formatting are separated. `text` holds the text, including any replacement characters for objects and lines. `formats`, `objects` and `lines` are all sparse arrays of the same length as `text`. It holds information about the formatting at the relevant text indices. Finally `start` and `end` state which text indices are selected. They are only provided if a `Range` was given. + +_Parameters_ + +- _$1_ `[Object]`: Optional named arguments. +- _$1.element_ `[Element]`: Element to create value from. +- _$1.text_ `[string]`: Text to create value from. +- _$1.html_ `[string]`: HTML to create value from. +- _$1.range_ `[Range]`: Range to create value from. +- _$1.\_\_unstableIsEditableTree_ `[boolean]`: + +_Returns_ + +- `RichTextValue`: A rich text value. + +### getActiveFormat + +Gets the format object by type at the start of the selection. This can be used to get e.g. the URL of a link format at the current selection, but also to check if a format is active at the selection. Returns undefined if there is no format at the selection. + +_Parameters_ + +- _value_ `RichTextValue`: Value to inspect. +- _formatType_ `string`: Format type to look for. + +_Returns_ + +- `RichTextFormat|undefined`: Active format object of the specified type, or undefined. + +### getActiveFormats + +Gets the all format objects at the start of the selection. + +_Parameters_ + +- _value_ `RichTextValue`: Value to inspect. +- _EMPTY_ACTIVE_FORMATS_ `Array`: Array to return if there are no active formats. + +_Returns_ + +- `RichTextFormatList`: Active format objects. + +### getActiveObject + +Gets the active object, if there is any. + +_Parameters_ + +- _value_ `RichTextValue`: Value to inspect. + +_Returns_ + +- `RichTextFormat|void`: Active object, or undefined. + +### getTextContent + +Get the textual content of a Rich Text value. This is similar to `Element.textContent`. + +_Parameters_ + +- _value_ `RichTextValue`: Value to use. + +_Returns_ + +- `string`: The text content. + +### insert + +Insert a Rich Text value, an HTML string, or a plain text string, into a Rich Text value at the given `startIndex`. Any content between `startIndex` and `endIndex` will be removed. Indices are retrieved from the selection if none are provided. + +_Parameters_ + +- _value_ `RichTextValue`: Value to modify. +- _valueToInsert_ `RichTextValue|string`: Value to insert. +- _startIndex_ `[number]`: Start index. +- _endIndex_ `[number]`: End index. + +_Returns_ + +- `RichTextValue`: A new value with the value inserted. + +### insertObject + +Insert a format as an object into a Rich Text value at the given `startIndex`. Any content between `startIndex` and `endIndex` will be removed. Indices are retrieved from the selection if none are provided. + +_Parameters_ + +- _value_ `RichTextValue`: Value to modify. +- _formatToInsert_ `RichTextFormat`: Format to insert as object. +- _startIndex_ `[number]`: Start index. +- _endIndex_ `[number]`: End index. + +_Returns_ + +- `RichTextValue`: A new value with the object inserted. + +### isCollapsed + +Check if the selection of a Rich Text value is collapsed or not. Collapsed means that no characters are selected, but there is a caret present. If there is no selection, `undefined` will be returned. This is similar to `window.getSelection().isCollapsed()`. + +_Parameters_ + +- _props_ `RichTextValue`: The rich text value to check. +- _props.start_ `RichTextValue[ 'start' ]`: +- _props.end_ `RichTextValue[ 'end' ]`: + +_Returns_ + +- `boolean | undefined`: True if the selection is collapsed, false if not, undefined if there is no selection. + +### isEmpty + +Check if a Rich Text value is Empty, meaning it contains no text or any objects (such as images). + +_Parameters_ + +- _value_ `RichTextValue`: Value to use. + +_Returns_ + +- `boolean`: True if the value is empty, false if not. + +### join + +Combine an array of Rich Text values into one, optionally separated by `separator`, which can be a Rich Text value, HTML string, or plain text string. This is similar to `Array.prototype.join`. + +_Parameters_ + +- _values_ `Array`: An array of values to join. +- _separator_ `[string|RichTextValue]`: Separator string or value. + +_Returns_ + +- `RichTextValue`: A new combined value. + +### registerFormatType + +Registers a new format provided a unique name and an object defining its behavior. + +_Parameters_ + +- _name_ `string`: Format name. +- _settings_ `WPFormat`: Format settings. + +_Returns_ + +- `WPFormat|undefined`: The format, if it has been successfully registered; otherwise `undefined`. + +### remove + +Remove content from a Rich Text value between the given `startIndex` and `endIndex`. Indices are retrieved from the selection if none are provided. + +_Parameters_ + +- _value_ `RichTextValue`: Value to modify. +- _startIndex_ `[number]`: Start index. +- _endIndex_ `[number]`: End index. + +_Returns_ + +- `RichTextValue`: A new value with the content removed. + +### removeFormat + +Remove any format object from a Rich Text value by type from the given `startIndex` to the given `endIndex`. Indices are retrieved from the selection if none are provided. + +_Parameters_ + +- _value_ `RichTextValue`: Value to modify. +- _formatType_ `string`: Format type to remove. +- _startIndex_ `[number]`: Start index. +- _endIndex_ `[number]`: End index. + +_Returns_ + +- `RichTextValue`: A new value with the format applied. + +### replace + +Search a Rich Text value and replace the match(es) with `replacement`. This is similar to `String.prototype.replace`. + +_Parameters_ + +- _value_ `RichTextValue`: The value to modify. +- _pattern_ `RegExp|string`: A RegExp object or literal. Can also be a string. It is treated as a verbatim string and is not interpreted as a regular expression. Only the first occurrence will be replaced. +- _replacement_ `Function|string`: The match or matches are replaced with the specified or the value returned by the specified function. + +_Returns_ + +- `RichTextValue`: A new value with replacements applied. + +### RichTextData + +The RichTextData class is used to instantiate a wrapper around rich text values, with methods that can be used to transform or manipulate the data. + +- Create an empty instance: `new RichTextData()`. +- Create one from an HTML string: `RichTextData.fromHTMLString( +'hello' )`. +- Create one from a wrapper HTMLElement: `RichTextData.fromHTMLElement( +document.querySelector( 'p' ) )`. +- Create one from plain text: `RichTextData.fromPlainText( '1\n2' )`. +- Create one from a rich text value: `new RichTextData( { text: '...', +formats: [ ... ] } )`. + +### RichTextValue + +An object which represents a formatted string. See main `@wordpress/rich-text` documentation for more information. + +### slice + +Slice a Rich Text value from `startIndex` to `endIndex`. Indices are retrieved from the selection if none are provided. This is similar to `String.prototype.slice`. + +_Parameters_ + +- _value_ `RichTextValue`: Value to modify. +- _startIndex_ `[number]`: Start index. +- _endIndex_ `[number]`: End index. + +_Returns_ + +- `RichTextValue`: A new extracted value. + +### split + +Split a Rich Text value in two at the given `startIndex` and `endIndex`, or split at the given separator. This is similar to `String.prototype.split`. Indices are retrieved from the selection if none are provided. + +_Parameters_ + +- _value_ `RichTextValue`: +- _string_ `[number|string]`: Start index, or string at which to split. + +_Returns_ + +- `Array|undefined`: An array of new values. + +### store + +Store definition for the rich-text namespace. + +_Related_ + +- + +_Type_ + +- `Object` + +### toggleFormat + +Toggles a format object to a Rich Text value at the current selection. + +_Parameters_ + +- _value_ `RichTextValue`: Value to modify. +- _format_ `RichTextFormat`: Format to apply or remove. + +_Returns_ + +- `RichTextValue`: A new value with the format applied or removed. + +### toHTMLString + +Create an HTML string from a Rich Text value. + +_Parameters_ + +- _$1_ `Object`: Named arguments. +- _$1.value_ `RichTextValue`: Rich text value. +- _$1.preserveWhiteSpace_ `[boolean]`: Preserves newlines if true. + +_Returns_ + +- `string`: HTML string. + +### unregisterFormatType + +Unregisters a format. + +_Parameters_ + +- _name_ `string`: Format name. + +_Returns_ + +- `WPFormat|undefined`: The previous format value, if it has been successfully unregistered; otherwise `undefined`. + +### useAnchor + +This hook, to be used in a format type's Edit component, returns the active element that is formatted, or a virtual element for the selection range if no format is active. The returned value is meant to be used for positioning UI, e.g. by passing it to the `Popover` component via the `anchor` prop. + +_Parameters_ + +- _$1_ `Object`: Named parameters. +- _$1.editableContentElement_ `HTMLElement|null`: The element containing the editable content. +- _$1.settings_ `WPFormat=`: The format type's settings. + +_Returns_ + +- `Element|VirtualAnchorElement|undefined|null`: The active element or selection range. + +### useAnchorRef + +This hook, to be used in a format type's Edit component, returns the active element that is formatted, or the selection range if no format is active. The returned value is meant to be used for positioning UI, e.g. by passing it to the `Popover` component. + +_Parameters_ + +- _$1_ `Object`: Named parameters. +- _$1.ref_ `RefObject`: React ref of the element containing the editable content. +- _$1.value_ `RichTextValue`: Value to check for selection. +- _$1.settings_ `WPFormat`: The format type's settings. + +_Returns_ + +- `Element|Range`: The active element or selection range. + + + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/rich-text/lib/apply-format.ts b/packages/rich-text/lib/apply-format.ts new file mode 100644 index 0000000..d4a8e2b --- /dev/null +++ b/packages/rich-text/lib/apply-format.ts @@ -0,0 +1,107 @@ +import { normaliseFormats } from './normalise-formats'; +import { RichTextValue, RichTextFormat } from './types'; + +function replace( array: T[], index: number, value: T ) { + array = array.slice(); + array[ index ] = value; + return array; +} + +/** + * Apply a format object to a Rich Text value from the given `startIndex` to the + * given `endIndex`. Indices are retrieved from the selection if none are + * provided. + * + * @param {RichTextValue} value Value to modify. + * @param {RichTextFormat} format Format to apply. + * @param {number} [startIndex] Start index. + * @param {number} [endIndex] End index. + * + * @return {RichTextValue} A new value with the format applied. + */ +export function applyFormat( + value: RichTextValue, + format: RichTextFormat, + startIndex: number = value.start || 0, + endIndex: number = value.end || 0, +): RichTextValue { + const { formats, activeFormats } = value; + const newFormats = formats.slice(); + + // The selection is collapsed. + if ( startIndex === endIndex ) { + const startFormat = newFormats[ startIndex ]?.find( + ( { type } ) => type === format.type + ); + + // If the caret is at a format of the same type, expand start and end to + // the edges of the format. This is useful to apply new attributes. + if ( startFormat ) { + const index = newFormats[ startIndex ].indexOf( startFormat ); + + while ( + newFormats[ startIndex ] && + newFormats[ startIndex ][ index ] === startFormat + ) { + newFormats[ startIndex ] = replace( + newFormats[ startIndex ], + index, + format + ); + startIndex--; + } + + endIndex++; + + while ( + newFormats[ endIndex ] && + newFormats[ endIndex ][ index ] === startFormat + ) { + newFormats[ endIndex ] = replace( + newFormats[ endIndex ], + index, + format + ); + endIndex++; + } + } + } else { + // Determine the highest position the new format can be inserted at. + let position = +Infinity; + + for ( let index = startIndex; index < endIndex; index++ ) { + if ( newFormats[ index ] ) { + newFormats[ index ] = newFormats[ index ].filter( + ( { type } ) => type !== format.type + ); + + const length = newFormats[ index ].length; + + if ( length < position ) { + position = length; + } + } else { + newFormats[ index ] = []; + position = 0; + } + } + + for ( let index = startIndex; index < endIndex; index++ ) { + newFormats[ index ].splice( position, 0, format ); + } + } + + return normaliseFormats( { + ...value, + formats: newFormats, + // Always revise active formats. This serves as a placeholder for new + // inputs with the format so new input appears with the format applied, + // and ensures a format of the same type uses the latest values. + activeFormats: [ + ...( activeFormats?.filter( + ( { type } ) => type !== format.type + ) || [] ), + format, + ], + } ); +} diff --git a/packages/rich-text/lib/component/event-listeners/copy-handler.ts b/packages/rich-text/lib/component/event-listeners/copy-handler.ts new file mode 100644 index 0000000..0cc1594 --- /dev/null +++ b/packages/rich-text/lib/component/event-listeners/copy-handler.ts @@ -0,0 +1,41 @@ +/** + * Internal dependencies + */ +import { toHTMLString } from '../../to-html-string'; +import { isCollapsed } from '../../is-collapsed'; +import { slice } from '../../slice'; +import { getTextContent } from '../../get-text-content'; + +export default ( props ) => ( element ) => { + function onCopy( event ) { + const { record } = props.current; + const { ownerDocument } = element; + if ( + isCollapsed( record.current ) || + ! element.contains( ownerDocument.activeElement ) + ) { + return; + } + + const selectedRecord = slice( record.current ); + const plainText = getTextContent( selectedRecord ); + const html = toHTMLString( { value: selectedRecord } ); + event.clipboardData.setData( 'text/plain', plainText ); + event.clipboardData.setData( 'text/html', html ); + event.clipboardData.setData( 'rich-text', 'true' ); + event.preventDefault(); + + if ( event.type === 'cut' ) { + ownerDocument.execCommand( 'delete' ); + } + } + + const { defaultView } = element.ownerDocument; + + defaultView.addEventListener( 'copy', onCopy ); + defaultView.addEventListener( 'cut', onCopy ); + return () => { + defaultView.removeEventListener( 'copy', onCopy ); + defaultView.removeEventListener( 'cut', onCopy ); + }; +}; diff --git a/packages/rich-text/lib/component/event-listeners/delete.ts b/packages/rich-text/lib/component/event-listeners/delete.ts new file mode 100644 index 0000000..62dd06a --- /dev/null +++ b/packages/rich-text/lib/component/event-listeners/delete.ts @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { BACKSPACE, DELETE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { remove } from '../../remove'; + +export default ( props ) => ( element ) => { + function onKeyDown( event ) { + const { keyCode } = event; + const { createRecord, handleChange } = props.current; + + if ( event.defaultPrevented ) { + return; + } + + if ( keyCode !== DELETE && keyCode !== BACKSPACE ) { + return; + } + + const currentValue = createRecord(); + const { start, end, text } = currentValue; + + // Always handle full content deletion ourselves. + if ( start === 0 && end !== 0 && end === text.length ) { + handleChange( remove( currentValue ) ); + event.preventDefault(); + } + } + + element.addEventListener( 'keydown', onKeyDown ); + return () => { + element.removeEventListener( 'keydown', onKeyDown ); + }; +}; diff --git a/packages/rich-text/lib/component/event-listeners/format-boundaries.ts b/packages/rich-text/lib/component/event-listeners/format-boundaries.ts new file mode 100644 index 0000000..e9fdfd1 --- /dev/null +++ b/packages/rich-text/lib/component/event-listeners/format-boundaries.ts @@ -0,0 +1,103 @@ +/** + * WordPress dependencies + */ +import { LEFT, RIGHT } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { isCollapsed } from '../../is-collapsed'; + +const EMPTY_ACTIVE_FORMATS = []; + +export default ( props ) => ( element ) => { + function onKeyDown( event ) { + const { keyCode, shiftKey, altKey, metaKey, ctrlKey } = event; + + if ( + // Only override left and right keys without modifiers pressed. + shiftKey || + altKey || + metaKey || + ctrlKey || + ( keyCode !== LEFT && keyCode !== RIGHT ) + ) { + return; + } + + const { record, applyRecord, forceRender } = props.current; + const { + text, + formats, + start, + end, + activeFormats: currentActiveFormats = [], + } = record.current; + const collapsed = isCollapsed( record.current ); + const { ownerDocument } = element; + const { defaultView } = ownerDocument; + // To do: ideally, we should look at visual position instead. + const { direction } = defaultView.getComputedStyle( element ); + const reverseKey = direction === 'rtl' ? RIGHT : LEFT; + const isReverse = event.keyCode === reverseKey; + + // If the selection is collapsed and at the very start, do nothing if + // navigating backward. + // If the selection is collapsed and at the very end, do nothing if + // navigating forward. + if ( collapsed && currentActiveFormats.length === 0 ) { + if ( start === 0 && isReverse ) { + return; + } + + if ( end === text.length && ! isReverse ) { + return; + } + } + + // If the selection is not collapsed, let the browser handle collapsing + // the selection for now. Later we could expand this logic to set + // boundary positions if needed. + if ( ! collapsed ) { + return; + } + + const formatsBefore = formats[ start - 1 ] || EMPTY_ACTIVE_FORMATS; + const formatsAfter = formats[ start ] || EMPTY_ACTIVE_FORMATS; + const destination = isReverse ? formatsBefore : formatsAfter; + const isIncreasing = currentActiveFormats.every( + ( format, index ) => format === destination[ index ] + ); + + let newActiveFormatsLength = currentActiveFormats.length; + + if ( ! isIncreasing ) { + newActiveFormatsLength--; + } else if ( newActiveFormatsLength < destination.length ) { + newActiveFormatsLength++; + } + + if ( newActiveFormatsLength === currentActiveFormats.length ) { + record.current._newActiveFormats = destination; + return; + } + + event.preventDefault(); + + const origin = isReverse ? formatsAfter : formatsBefore; + const source = isIncreasing ? destination : origin; + const newActiveFormats = source.slice( 0, newActiveFormatsLength ); + const newValue = { + ...record.current, + activeFormats: newActiveFormats, + }; + record.current = newValue; + applyRecord( newValue ); + forceRender(); + } + + element.addEventListener( 'keydown', onKeyDown ); + return () => { + element.removeEventListener( 'keydown', onKeyDown ); + }; +}; diff --git a/packages/rich-text/lib/component/event-listeners/index.ts b/packages/rich-text/lib/component/event-listeners/index.ts new file mode 100644 index 0000000..a6327fe --- /dev/null +++ b/packages/rich-text/lib/component/event-listeners/index.ts @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { useMemo, useRef } from '@wordpress/element'; +import { useRefEffect } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import copyHandler from './copy-handler'; +import selectObject from './select-object'; +import formatBoundaries from './format-boundaries'; +import deleteHandler from './delete'; +import inputAndSelection from './input-and-selection'; +import selectionChangeCompat from './selection-change-compat'; + +const allEventListeners = [ + copyHandler, + selectObject, + formatBoundaries, + deleteHandler, + inputAndSelection, + selectionChangeCompat, +]; + +export function useEventListeners( props ) { + const propsRef = useRef( props ); + propsRef.current = props; + const refEffects = useMemo( + () => allEventListeners.map( ( refEffect ) => refEffect( propsRef ) ), + [ propsRef ] + ); + + return useRefEffect( + ( element ) => { + const cleanups = refEffects.map( ( effect ) => effect( element ) ); + return () => { + cleanups.forEach( ( cleanup ) => cleanup() ); + }; + }, + [ refEffects ] + ); +} diff --git a/packages/rich-text/lib/component/event-listeners/input-and-selection.ts b/packages/rich-text/lib/component/event-listeners/input-and-selection.ts new file mode 100644 index 0000000..621f1c5 --- /dev/null +++ b/packages/rich-text/lib/component/event-listeners/input-and-selection.ts @@ -0,0 +1,259 @@ +/** + * Internal dependencies + */ +import { getActiveFormats } from '../../get-active-formats'; +import { updateFormats } from '../../update-formats'; + +/** + * All inserting input types that would insert HTML into the DOM. + * + * @see https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes + * + * @type {Set} + */ +const INSERTION_INPUT_TYPES_TO_IGNORE = new Set( [ + 'insertParagraph', + 'insertOrderedList', + 'insertUnorderedList', + 'insertHorizontalRule', + 'insertLink', +] ); + +const EMPTY_ACTIVE_FORMATS = []; + +const PLACEHOLDER_ATTR_NAME = 'data-rich-text-placeholder'; + +/** + * If the selection is set on the placeholder element, collapse the selection to + * the start (before the placeholder). + * + * @param {Window} defaultView + */ +function fixPlaceholderSelection( defaultView ) { + const selection = defaultView.getSelection(); + const { anchorNode, anchorOffset } = selection; + + if ( anchorNode.nodeType !== anchorNode.ELEMENT_NODE ) { + return; + } + + const targetNode = anchorNode.childNodes[ anchorOffset ]; + + if ( + ! targetNode || + targetNode.nodeType !== targetNode.ELEMENT_NODE || + ! targetNode.hasAttribute( PLACEHOLDER_ATTR_NAME ) + ) { + return; + } + + selection.collapseToStart(); +} + +export default ( props ) => ( element ) => { + const { ownerDocument } = element; + const { defaultView } = ownerDocument; + + let isComposing = false; + + function onInput( event ) { + // Do not trigger a change if characters are being composed. Browsers + // will usually emit a final `input` event when the characters are + // composed. As of December 2019, Safari doesn't support + // nativeEvent.isComposing. + if ( isComposing ) { + return; + } + + let inputType; + + if ( event ) { + inputType = event.inputType; + } + + const { record, applyRecord, createRecord, handleChange } = + props.current; + + // The browser formatted something or tried to insert HTML. Overwrite + // it. It will be handled later by the format library if needed. + if ( + inputType && + ( inputType.indexOf( 'format' ) === 0 || + INSERTION_INPUT_TYPES_TO_IGNORE.has( inputType ) ) + ) { + applyRecord( record.current ); + return; + } + + const currentValue = createRecord(); + const { start, activeFormats: oldActiveFormats = [] } = record.current; + + // Update the formats between the last and new caret position. + const change = updateFormats( { + value: currentValue, + start, + end: currentValue.start, + formats: oldActiveFormats, + } ); + + handleChange( change ); + } + + /** + * Syncs the selection to local state. A callback for the `selectionchange` + * event. + */ + function handleSelectionChange() { + const { record, applyRecord, createRecord, onSelectionChange } = + props.current; + + // Check if the implementor disabled editing. `contentEditable` does + // disable input, but not text selection, so we must ignore selection + // changes. + if ( element.contentEditable !== 'true' ) { + return; + } + + // Ensure the active element is the rich text element. + if ( ownerDocument.activeElement !== element ) { + // If it is not, we can stop listening for selection changes. We + // resume listening when the element is focused. + ownerDocument.removeEventListener( + 'selectionchange', + handleSelectionChange + ); + return; + } + + // In case of a keyboard event, ignore selection changes during + // composition. + if ( isComposing ) { + return; + } + + const { start, end, text } = createRecord(); + const oldRecord = record.current; + + // Fallback mechanism for IE11, which doesn't support the input event. + // Any input results in a selection change. + if ( text !== oldRecord.text ) { + onInput(); + return; + } + + if ( start === oldRecord.start && end === oldRecord.end ) { + // Sometimes the browser may set the selection on the placeholder + // element, in which case the caret is not visible. We need to set + // the caret before the placeholder if that's the case. + if ( oldRecord.text.length === 0 && start === 0 ) { + fixPlaceholderSelection( defaultView ); + } + + return; + } + + const newValue = { + ...oldRecord, + start, + end, + // _newActiveFormats may be set on arrow key navigation to control + // the right boundary position. If undefined, getActiveFormats will + // give the active formats according to the browser. + activeFormats: oldRecord._newActiveFormats, + _newActiveFormats: undefined, + }; + + const newActiveFormats = getActiveFormats( + newValue, + EMPTY_ACTIVE_FORMATS + ); + + // Update the value with the new active formats. + newValue.activeFormats = newActiveFormats; + + // It is important that the internal value is updated first, + // otherwise the value will be wrong on render! + record.current = newValue; + applyRecord( newValue, { domOnly: true } ); + onSelectionChange( start, end ); + } + + function onCompositionStart() { + isComposing = true; + // Do not update the selection when characters are being composed as + // this rerenders the component and might destroy internal browser + // editing state. + ownerDocument.removeEventListener( + 'selectionchange', + handleSelectionChange + ); + // Remove the placeholder. Since the rich text value doesn't update + // during composition, the placeholder doesn't get removed. There's no + // need to re-add it, when the value is updated on compositionend it + // will be re-added when the value is empty. + element.querySelector( `[${ PLACEHOLDER_ATTR_NAME }]` )?.remove(); + } + + function onCompositionEnd() { + isComposing = false; + // Ensure the value is up-to-date for browsers that don't emit a final + // input event after composition. + onInput( { inputType: 'insertText' } ); + // Tracking selection changes can be resumed. + ownerDocument.addEventListener( + 'selectionchange', + handleSelectionChange + ); + } + + function onFocus() { + const { record, isSelected, onSelectionChange, applyRecord } = + props.current; + + // When the whole editor is editable, let writing flow handle + // selection. + if ( element.parentElement.closest( '[contenteditable="true"]' ) ) { + return; + } + + if ( ! isSelected ) { + // We know for certain that on focus, the old selection is invalid. + // It will be recalculated on the next mouseup, keyup, or touchend + // event. + const index = undefined; + + record.current = { + ...record.current, + start: index, + end: index, + activeFormats: EMPTY_ACTIVE_FORMATS, + }; + } else { + applyRecord( record.current, { domOnly: true } ); + } + + onSelectionChange( record.current.start, record.current.end ); + + // There is no selection change event when the element is focused, so + // we need to manually trigger it. The selection is also not available + // yet in this call stack. + window.queueMicrotask( handleSelectionChange ); + + ownerDocument.addEventListener( + 'selectionchange', + handleSelectionChange + ); + } + + element.addEventListener( 'input', onInput ); + element.addEventListener( 'compositionstart', onCompositionStart ); + element.addEventListener( 'compositionend', onCompositionEnd ); + element.addEventListener( 'focus', onFocus ); + + return () => { + element.removeEventListener( 'input', onInput ); + element.removeEventListener( 'compositionstart', onCompositionStart ); + element.removeEventListener( 'compositionend', onCompositionEnd ); + element.removeEventListener( 'focus', onFocus ); + }; +}; diff --git a/packages/rich-text/lib/component/event-listeners/select-object.ts b/packages/rich-text/lib/component/event-listeners/select-object.ts new file mode 100644 index 0000000..2598b14 --- /dev/null +++ b/packages/rich-text/lib/component/event-listeners/select-object.ts @@ -0,0 +1,54 @@ +export default () => ( element ) => { + function onClick( event ) { + const { target } = event; + + // If the child element has no text content, it must be an object. + if ( + target === element || + ( target.textContent && target.isContentEditable ) + ) { + return; + } + + const { ownerDocument } = target; + const { defaultView } = ownerDocument; + const selection = defaultView.getSelection(); + + // If it's already selected, do nothing and let default behavior happen. + // This means it's "click-through". + if ( selection.containsNode( target ) ) { + return; + } + + const range = ownerDocument.createRange(); + // If the target is within a non editable element, select the non + // editable element. + const nodeToSelect = target.isContentEditable + ? target + : target.closest( '[contenteditable]' ); + + range.selectNode( nodeToSelect ); + selection.removeAllRanges(); + selection.addRange( range ); + + event.preventDefault(); + } + + function onFocusIn( event ) { + // When there is incoming focus from a link, select the object. + if ( + event.relatedTarget && + ! element.contains( event.relatedTarget ) && + event.relatedTarget.tagName === 'A' + ) { + onClick( event ); + } + } + + element.addEventListener( 'click', onClick ); + element.addEventListener( 'focusin', onFocusIn ); + return () => { + element.removeEventListener( 'click', onClick ); + element.removeEventListener( 'focusin', onFocusIn ); + }; +}; diff --git a/packages/rich-text/lib/component/event-listeners/selection-change-compat.ts b/packages/rich-text/lib/component/event-listeners/selection-change-compat.ts new file mode 100644 index 0000000..f64f226 --- /dev/null +++ b/packages/rich-text/lib/component/event-listeners/selection-change-compat.ts @@ -0,0 +1,53 @@ +/** + * Internal dependencies + */ +import { isRangeEqual } from '../../is-range-equal'; + +/** + * Sometimes some browsers are not firing a `selectionchange` event when + * changing the selection by mouse or keyboard. This hook makes sure that, if we + * detect no `selectionchange` or `input` event between the up and down events, + * we fire a `selectionchange` event. + */ +export default () => ( element ) => { + const { ownerDocument } = element; + const { defaultView } = ownerDocument; + const selection = defaultView?.getSelection(); + + let range; + + function getRange() { + return selection.rangeCount ? selection.getRangeAt( 0 ) : null; + } + + function onDown( event ) { + const type = event.type === 'keydown' ? 'keyup' : 'pointerup'; + + function onCancel() { + ownerDocument.removeEventListener( type, onUp ); + ownerDocument.removeEventListener( 'selectionchange', onCancel ); + ownerDocument.removeEventListener( 'input', onCancel ); + } + + function onUp() { + onCancel(); + if ( isRangeEqual( range, getRange() ) ) { + return; + } + ownerDocument.dispatchEvent( new Event( 'selectionchange' ) ); + } + + ownerDocument.addEventListener( type, onUp ); + ownerDocument.addEventListener( 'selectionchange', onCancel ); + ownerDocument.addEventListener( 'input', onCancel ); + + range = getRange(); + } + + element.addEventListener( 'pointerdown', onDown ); + element.addEventListener( 'keydown', onDown ); + return () => { + element.removeEventListener( 'pointerdown', onDown ); + element.removeEventListener( 'keydown', onDown ); + }; +}; diff --git a/packages/rich-text/lib/component/index.ts b/packages/rich-text/lib/component/index.ts new file mode 100644 index 0000000..600fc0f --- /dev/null +++ b/packages/rich-text/lib/component/index.ts @@ -0,0 +1,214 @@ +/** + * WordPress dependencies + */ +import { useRef, useLayoutEffect, useReducer } from '@wordpress/element'; +import { useMergeRefs, useRefEffect } from '@wordpress/compose'; +import { useRegistry } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { create, RichTextData } from '../create'; +import { apply } from '../to-dom'; +import { toHTMLString } from '../to-html-string'; +import { useDefaultStyle } from './use-default-style'; +import { useBoundaryStyle } from './use-boundary-style'; +import { useEventListeners } from './event-listeners'; + +export function useRichText( { + value = '', + selectionStart, + selectionEnd, + placeholder, + onSelectionChange, + preserveWhiteSpace, + onChange, + __unstableDisableFormats: disableFormats, + __unstableIsSelected: isSelected, + __unstableDependencies = [], + __unstableAfterParse, + __unstableBeforeSerialize, + __unstableAddInvisibleFormats, +} ) { + const registry = useRegistry(); + const [ , forceRender ] = useReducer( () => ( {} ) ); + const ref = useRef(); + + function createRecord() { + const { + ownerDocument: { defaultView }, + } = ref.current; + const selection = defaultView.getSelection(); + const range = + selection.rangeCount > 0 ? selection.getRangeAt( 0 ) : null; + + return create( { + element: ref.current, + range, + __unstableIsEditableTree: true, + } ); + } + + function applyRecord( newRecord, { domOnly } = {} ) { + apply( { + value: newRecord, + current: ref.current, + prepareEditableTree: __unstableAddInvisibleFormats, + __unstableDomOnly: domOnly, + placeholder, + } ); + } + + // Internal values are updated synchronously, unlike props and state. + const _valueRef = useRef( value ); + const recordRef = useRef(); + + function setRecordFromProps() { + _valueRef.current = value; + recordRef.current = value; + if ( ! ( value instanceof RichTextData ) ) { + recordRef.current = value + ? RichTextData.fromHTMLString( value, { preserveWhiteSpace } ) + : RichTextData.empty(); + } + // To do: make rich text internally work with RichTextData. + recordRef.current = { + text: recordRef.current.text, + formats: recordRef.current.formats, + replacements: recordRef.current.replacements, + }; + if ( disableFormats ) { + recordRef.current.formats = Array( value.length ); + recordRef.current.replacements = Array( value.length ); + } + if ( __unstableAfterParse ) { + recordRef.current.formats = __unstableAfterParse( + recordRef.current + ); + } + recordRef.current.start = selectionStart; + recordRef.current.end = selectionEnd; + } + + const hadSelectionUpdateRef = useRef( false ); + + if ( ! recordRef.current ) { + hadSelectionUpdateRef.current = isSelected; + setRecordFromProps(); + } else if ( + selectionStart !== recordRef.current.start || + selectionEnd !== recordRef.current.end + ) { + hadSelectionUpdateRef.current = isSelected; + recordRef.current = { + ...recordRef.current, + start: selectionStart, + end: selectionEnd, + activeFormats: undefined, + }; + } + + /** + * Sync the value to global state. The node tree and selection will also be + * updated if differences are found. + * + * @param {Object} newRecord The record to sync and apply. + */ + function handleChange( newRecord ) { + recordRef.current = newRecord; + applyRecord( newRecord ); + + if ( disableFormats ) { + _valueRef.current = newRecord.text; + } else { + const newFormats = __unstableBeforeSerialize + ? __unstableBeforeSerialize( newRecord ) + : newRecord.formats; + newRecord = { ...newRecord, formats: newFormats }; + if ( typeof value === 'string' ) { + _valueRef.current = toHTMLString( { + value: newRecord, + preserveWhiteSpace, + } ); + } else { + _valueRef.current = new RichTextData( newRecord ); + } + } + + const { start, end, formats, text } = recordRef.current; + + // Selection must be updated first, so it is recorded in history when + // the content change happens. + // We batch both calls to only attempt to rerender once. + registry.batch( () => { + onSelectionChange( start, end ); + onChange( _valueRef.current, { + __unstableFormats: formats, + __unstableText: text, + } ); + } ); + forceRender(); + } + + function applyFromProps() { + setRecordFromProps(); + applyRecord( recordRef.current ); + } + + const didMountRef = useRef( false ); + + // Value updates must happen synchonously to avoid overwriting newer values. + useLayoutEffect( () => { + if ( didMountRef.current && value !== _valueRef.current ) { + applyFromProps(); + forceRender(); + } + }, [ value ] ); + + // Value updates must happen synchonously to avoid overwriting newer values. + useLayoutEffect( () => { + if ( ! hadSelectionUpdateRef.current ) { + return; + } + + if ( ref.current.ownerDocument.activeElement !== ref.current ) { + ref.current.focus(); + } + + applyRecord( recordRef.current ); + hadSelectionUpdateRef.current = false; + }, [ hadSelectionUpdateRef.current ] ); + + const mergedRefs = useMergeRefs( [ + ref, + useDefaultStyle(), + useBoundaryStyle( { record: recordRef } ), + useEventListeners( { + record: recordRef, + handleChange, + applyRecord, + createRecord, + isSelected, + onSelectionChange, + forceRender, + } ), + useRefEffect( () => { + applyFromProps(); + didMountRef.current = true; + }, [ placeholder, ...__unstableDependencies ] ), + ] ); + + return { + value: recordRef.current, + // A function to get the most recent value so event handlers in + // useRichText implementations have access to it. For example when + // listening to input events, we internally update the state, but this + // state is not yet available to the input event handler because React + // may re-render asynchronously. + getValue: () => recordRef.current, + onChange: handleChange, + ref: mergedRefs, + }; +} + +export default function __experimentalRichText() {} diff --git a/packages/rich-text/lib/component/use-anchor.ts b/packages/rich-text/lib/component/use-anchor.ts new file mode 100644 index 0000000..fbe9ec2 --- /dev/null +++ b/packages/rich-text/lib/component/use-anchor.ts @@ -0,0 +1,204 @@ +import { SimpleRange } from '../types'; + +/** + * Given a range and a format tag name and class name, returns the closest + * format element. + * + * @param {Range} range The Range to check. + * @param {HTMLElement} editableContentElement The editable wrapper. + * @param {string} tagName The tag name of the format element. + * @param {string} className The class name of the format element. + * + * @return {HTMLElement|undefined} The format element, if found. + */ +function getFormatElement( range: SimpleRange, editableContentElement: HTMLElement, tagName: string, className: string ) { + let element = range.startContainer; + + // Even if the active format is defined, the actualy DOM range's start + // container may be outside of the format's DOM element: + // `aโ€ธb` (DOM) while visually it's `aโ€ธb`. + // So at a given selection index, start with the deepest format DOM element. + if ( + element.nodeType === element.TEXT_NODE && + range.startOffset === element.length && + element.nextSibling + ) { + element = element.nextSibling; + + while ( element.firstChild ) { + element = element.firstChild; + } + } + + if ( element.nodeType !== element.ELEMENT_NODE ) { + element = element.parentElement; + } + + if ( ! element ) { + return; + } + if ( element === editableContentElement ) { + return; + } + if ( ! editableContentElement.contains( element ) ) { + return; + } + + const selector = tagName + ( className ? '.' + className : '' ); + + // .closest( selector ), but with a boundary. Check if the element matches + // the selector. If it doesn't match, try the parent element if it's not the + // editable wrapper. We don't want to try to match ancestors of the editable + // wrapper, which is what .closest( selector ) would do. When the element is + // the editable wrapper (which is most likely the case because most text is + // unformatted), this never runs. + while ( element !== editableContentElement ) { + if ( element.matches( selector ) ) { + return element; + } + + element = element.parentElement; + } +} + +/** + * @typedef {Object} VirtualAnchorElement + * @property {() => DOMRect} getBoundingClientRect A function returning a DOMRect + * @property {HTMLElement} contextElement The actual DOM element + */ + +/** + * Creates a virtual anchor element for a range. + * + * @param {Range} range The range to create a virtual anchor element for. + * @param {HTMLElement} editableContentElement The editable wrapper. + * + * @return {VirtualAnchorElement} The virtual anchor element. + */ +function createVirtualAnchorElement( range, editableContentElement ) { + return { + contextElement: editableContentElement, + getBoundingClientRect() { + return editableContentElement.contains( range.startContainer ) + ? range.getBoundingClientRect() + : editableContentElement.getBoundingClientRect(); + }, + }; +} + +/** + * Get the anchor: a format element if there is a matching one based on the + * tagName and className or a range otherwise. + * + * @param {HTMLElement} editableContentElement The editable wrapper. + * @param {string} tagName The tag name of the format + * element. + * @param {string} className The class name of the format + * element. + * + * @return {HTMLElement|VirtualAnchorElement|undefined} The anchor. + */ +function getAnchor( editableContentElement, tagName, className ) { + if ( ! editableContentElement ) { + return; + } + + const { ownerDocument } = editableContentElement; + const { defaultView } = ownerDocument; + const selection = defaultView.getSelection(); + + if ( ! selection ) { + return; + } + if ( ! selection.rangeCount ) { + return; + } + + const range = selection.getRangeAt( 0 ); + + if ( ! range || ! range.startContainer ) { + return; + } + + const formatElement = getFormatElement( + range, + editableContentElement, + tagName, + className + ); + + if ( formatElement ) { + return formatElement; + } + + return createVirtualAnchorElement( range, editableContentElement ); +} + +/** + * This hook, to be used in a format type's Edit component, returns the active + * element that is formatted, or a virtual element for the selection range if + * no format is active. The returned value is meant to be used for positioning + * UI, e.g. by passing it to the `Popover` component via the `anchor` prop. + * + * @param {Object} $1 Named parameters. + * @param {HTMLElement|null} $1.editableContentElement The element containing + * the editable content. + * @param {WPFormat=} $1.settings The format type's settings. + * @return {Element|VirtualAnchorElement|undefined|null} The active element or selection range. + */ +export function useAnchor( { editableContentElement, settings = {} } ) { + const { tagName, className, isActive } = settings; + const [ anchor, setAnchor ] = useState( () => + getAnchor( editableContentElement, tagName, className ) + ); + const wasActive = usePrevious( isActive ); + + useLayoutEffect( () => { + if ( ! editableContentElement ) { + return; + } + + function callback() { + setAnchor( + getAnchor( editableContentElement, tagName, className ) + ); + } + + function attach() { + ownerDocument.addEventListener( 'selectionchange', callback ); + } + + function detach() { + ownerDocument.removeEventListener( 'selectionchange', callback ); + } + + const { ownerDocument } = editableContentElement; + + if ( + editableContentElement === ownerDocument.activeElement || + // When a link is created, we need to attach the popover to the newly created anchor. + ( ! wasActive && isActive ) || + // Sometimes we're _removing_ an active anchor, such as the inline color popover. + // When we add the color, it switches from a virtual anchor to a `` element. + // When we _remove_ the color, it switches from a `` element to a virtual anchor. + ( wasActive && ! isActive ) + ) { + setAnchor( + getAnchor( editableContentElement, tagName, className ) + ); + attach(); + } + + editableContentElement.addEventListener( 'focusin', attach ); + editableContentElement.addEventListener( 'focusout', detach ); + + return () => { + detach(); + + editableContentElement.removeEventListener( 'focusin', attach ); + editableContentElement.removeEventListener( 'focusout', detach ); + }; + }, [ editableContentElement, tagName, className, isActive, wasActive ] ); + + return anchor; +} diff --git a/packages/rich-text/lib/component/use-boundary-style.ts b/packages/rich-text/lib/component/use-boundary-style.ts new file mode 100644 index 0000000..9f29b24 --- /dev/null +++ b/packages/rich-text/lib/component/use-boundary-style.ts @@ -0,0 +1,55 @@ +/** + * WordPress dependencies + */ +import { useEffect, useRef } from '@wordpress/element'; + +/* + * Calculates and renders the format boundary style when the active formats + * change. + */ +export function useBoundaryStyle( { record } ) { + const ref = useRef(); + const { activeFormats = [], replacements, start } = record.current; + const activeReplacement = replacements[ start ]; + useEffect( () => { + // There's no need to recalculate the boundary styles if no formats are + // active, because no boundary styles will be visible. + if ( + ( ! activeFormats || ! activeFormats.length ) && + ! activeReplacement + ) { + return; + } + + const boundarySelector = '*[data-rich-text-format-boundary]'; + const element = ref.current.querySelector( boundarySelector ); + + if ( ! element ) { + return; + } + + const { ownerDocument } = element; + const { defaultView } = ownerDocument; + const computedStyle = defaultView.getComputedStyle( element ); + const newColor = computedStyle.color + .replace( ')', ', 0.2)' ) + .replace( 'rgb', 'rgba' ); + const selector = `.rich-text:focus ${ boundarySelector }`; + const rule = `background-color: ${ newColor }`; + const style = `${ selector } {${ rule }}`; + const globalStyleId = 'rich-text-boundary-style'; + + let globalStyle = ownerDocument.getElementById( globalStyleId ); + + if ( ! globalStyle ) { + globalStyle = ownerDocument.createElement( 'style' ); + globalStyle.id = globalStyleId; + ownerDocument.head.appendChild( globalStyle ); + } + + if ( globalStyle.innerHTML !== style ) { + globalStyle.innerHTML = style; + } + }, [ activeFormats, activeReplacement ] ); + return ref; +} diff --git a/packages/rich-text/lib/component/use-default-style.ts b/packages/rich-text/lib/component/use-default-style.ts new file mode 100644 index 0000000..a4c8449 --- /dev/null +++ b/packages/rich-text/lib/component/use-default-style.ts @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { useCallback } from '@wordpress/element'; + +/** + * In HTML, leading and trailing spaces are not visible, and multiple spaces + * elsewhere are visually reduced to one space. This rule prevents spaces from + * collapsing so all space is visible in the editor and can be removed. It also + * prevents some browsers from inserting non-breaking spaces at the end of a + * line to prevent the space from visually disappearing. Sometimes these non + * breaking spaces can linger in the editor causing unwanted non breaking spaces + * in between words. If also prevent Firefox from inserting a trailing `br` node + * to visualise any trailing space, causing the element to be saved. + * + * > Authors are encouraged to set the 'white-space' property on editing hosts + * > and on markup that was originally created through these editing mechanisms + * > to the value 'pre-wrap'. Default HTML whitespace handling is not well + * > suited to WYSIWYG editing, and line wrapping will not work correctly in + * > some corner cases if 'white-space' is left at its default value. + * + * https://html.spec.whatwg.org/multipage/interaction.html#best-practices-for-in-page-editors + * + * @type {string} + */ +const whiteSpace = 'pre-wrap'; + +/** + * A minimum width of 1px will prevent the rich text container from collapsing + * to 0 width and hiding the caret. This is useful for inline containers. + */ +const minWidth = '1px'; + +export function useDefaultStyle() { + return useCallback( ( element ) => { + if ( ! element ) { + return; + } + element.style.whiteSpace = whiteSpace; + element.style.minWidth = minWidth; + }, [] ); +} diff --git a/packages/rich-text/lib/concat.ts b/packages/rich-text/lib/concat.ts new file mode 100644 index 0000000..3ab6b9c --- /dev/null +++ b/packages/rich-text/lib/concat.ts @@ -0,0 +1,32 @@ +import { normaliseFormats } from './normalise-formats'; +import { create } from './create'; +import { RichTextValue } from './types'; + +/** + * Concats a pair of rich text values. Not that this mutates `a` and does NOT + * normalise formats! + * + * @param {Object} a Value to mutate. + * @param {Object} b Value to add read from. + * + * @return {Object} `a`, mutated. + */ +export function mergePair( a: RichTextValue, b: RichTextValue ): RichTextValue { + a.formats = a.formats.concat( b.formats ); + a.replacements = a.replacements.concat( b.replacements ); + a.text += b.text; + + return a; +} + +/** + * Combine all Rich Text values into one. This is similar to + * `String.prototype.concat`. + * + * @param {...RichTextValue} values Objects to combine. + * + * @return {RichTextValue} A new value combining all given records. + */ +export function concat( ...values: RichTextValue[] ): RichTextValue { + return normaliseFormats( values.reduce( mergePair, create() ) ); +} diff --git a/packages/rich-text/lib/create-element.ts b/packages/rich-text/lib/create-element.ts new file mode 100644 index 0000000..eebbe45 --- /dev/null +++ b/packages/rich-text/lib/create-element.ts @@ -0,0 +1,25 @@ +/** + * Parse the given HTML into a body element. + * + * Note: The current implementation will return a shared reference, reset on + * each call to `createElement`. Therefore, you should not hold a reference to + * the value to operate upon asynchronously, as it may have unexpected results. + * + * @param {HTMLDocument} document The HTML document to use to parse. + * @param {string} html The HTML to parse. + * + * @return {HTMLBodyElement} Body element with parsed HTML. + */ +export function createElement( { implementation }, html ) { + // Because `createHTMLDocument` is an expensive operation, and with this + // function being internal to `rich-text` (full control in avoiding a risk + // of asynchronous operations on the shared reference), a single document + // is reused and reset for each call to the function. + if ( ! createElement.body ) { + createElement.body = implementation.createHTMLDocument( '' ).body; + } + + createElement.body.innerHTML = html; + + return createElement.body; +} diff --git a/packages/rich-text/lib/create.ts b/packages/rich-text/lib/create.ts new file mode 100644 index 0000000..7dca870 --- /dev/null +++ b/packages/rich-text/lib/create.ts @@ -0,0 +1,549 @@ +/** + * Internal dependencies + */ +import { useFormatTypes } from './use-format-types'; +import { createElement } from './create-element'; +import { mergePair } from './concat'; +import { OBJECT_REPLACEMENT_CHARACTER, ZWNBSP } from './special-characters'; +import { RichTextFormat, RichTextValue, RichTextFormatType, SimpleRange } from './types'; + +function createEmptyValue(): RichTextValue { + return { + formats: [], + replacements: [], + text: '', + }; +} + +function toFormat( { tagName, attributes }: { tagName: string, attributes: Record } ): RichTextFormat { + const { getFormatTypeForClassName, getFormatTypeForBareElement } = useFormatTypes(); + let formatType: RichTextFormatType|undefined; + + if ( attributes && attributes.class ) { + formatType = getFormatTypeForClassName(attributes.class); + + if ( formatType ) { + // Preserve any additional classes. + attributes.class = ` ${ attributes.class } ` + .replace( ` ${ formatType.className } `, ' ' ) + .trim(); + + if ( ! attributes.class ) { + delete attributes.class; + } + } + } + + if ( ! formatType ) { + formatType = getFormatTypeForBareElement(tagName); + } + + if ( ! formatType ) { + return attributes ? { type: tagName, attributes } : { type: tagName }; + } + + if ( ! attributes ) { + return { formatType, type: formatType.name, tagName }; + } + + const registeredAttributes: Record = {}; + const unregisteredAttributes: Record = {}; + const _attributes = { ...attributes }; + + for ( const key in formatType.attributes ) { + const name = formatType.attributes[ key ]; + + registeredAttributes[ key ] = _attributes[ name ]; + + // delete the attribute and what's left is considered + // to be unregistered. + delete _attributes[ name ]; + + if ( typeof registeredAttributes[ key ] === 'undefined' ) { + delete registeredAttributes[ key ]; + } + } + + for ( const name in _attributes ) { + unregisteredAttributes[ name ] = attributes[ name ]; + } + + if ( formatType.contentEditable === false ) { + delete unregisteredAttributes.contenteditable; + } + + return { + formatType, + type: formatType.name, + tagName, + attributes: registeredAttributes, + unregisteredAttributes, + }; +} + +export const fromPlainText = (text: string) => create({ text }); +export const fromHTMLString = (html: string) => create({ html }); +export const fromHTMLElement = (htmlElement: Element, options: { preserveWhiteSpace?: boolean } = {}) => { + const { preserveWhiteSpace = false } = options; + const element = preserveWhiteSpace + ? htmlElement + : collapseWhiteSpace( htmlElement ); + const richTextValue = create({ element }); + Object.defineProperty( richTextValue, 'originalHTML', { + value: htmlElement.innerHTML, + } ); + return richTextValue; +}; + +/** + * Create a RichText value from an `Element` tree (DOM), an HTML string or a + * plain text string, with optionally a `Range` object to set the selection. If + * called without any input, an empty value will be created. The optional + * functions can be used to filter out content. + * + * A value will have the following shape, which you are strongly encouraged not + * to modify without the use of helper functions: + * + * ```js + * { + * text: string, + * formats: Array, + * replacements: Array, + * ?start: number, + * ?end: number, + * } + * ``` + * + * As you can see, text and formatting are separated. `text` holds the text, + * including any replacement characters for objects and lines. `formats`, + * `objects` and `lines` are all sparse arrays of the same length as `text`. It + * holds information about the formatting at the relevant text indices. Finally + * `start` and `end` state which text indices are selected. They are only + * provided if a `Range` was given. + * + * @param {Object} [$1] Optional named arguments. + * @param {Element} [$1.element] Element to create value from. + * @param {string} [$1.text] Text to create value from. + * @param {string} [$1.html] HTML to create value from. + * @param {Range} [$1.range] Range to create value from. + * @return {RichTextValue} A rich text value. + */ +export function create({ + element, + text, + html, + range, +}: { + element?: Element, + text?: string, + html?: string, + range?: SimpleRange, +} = {} ): RichTextValue { + if ( typeof text === 'string' && text.length > 0 ) { + return { + formats: Array( text.length ), + replacements: Array( text.length ), + text, + }; + } + + if ( typeof html === 'string' && html.length > 0 ) { + // It does not matter which document this is, we're just using it to + // parse. + element = createElement( document, html ); + } + + if ( typeof element !== 'object' ) { + return createEmptyValue(); + } + + return createFromElement( { + element, + range, + isEditableTree, + } ); +} + +/** + * Helper to accumulate the value's selection start and end from the current + * node and range. + * + * @param {Object} accumulator Object to accumulate into. + * @param {Node} node Node to create value with. + * @param {Range} range Range to create value with. + * @param {Object} value Value that is being accumulated. + */ +function accumulateSelection( + accumulator: RichTextValue, + node: Node, + range?: SimpleRange, + value?: RichTextValue, +) { + if ( !range || !value ) { + return; + } + + const { parentNode } = node; + const { startContainer, startOffset, endContainer, endOffset } = range; + const currentLength = accumulator.text.length; + + // Selection can be extracted from value. + if ( value.start !== undefined ) { + accumulator.start = currentLength + value.start; + // Range indicates that the current node has selection. + } else if ( node === startContainer && node.nodeType === node.TEXT_NODE ) { + accumulator.start = currentLength + startOffset; + // Range indicates that the current node is selected. + } else if ( + parentNode === startContainer && + node === startContainer.childNodes[ startOffset ] + ) { + accumulator.start = currentLength; + // Range indicates that the selection is after the current node. + } else if ( + parentNode === startContainer && + node === startContainer.childNodes[ startOffset - 1 ] + ) { + accumulator.start = currentLength + value.text.length; + // Fallback if no child inside handled the selection. + } else if ( node === startContainer ) { + accumulator.start = currentLength; + } + + // Selection can be extracted from value. + if ( value.end !== undefined ) { + accumulator.end = currentLength + value.end; + // Range indicates that the current node has selection. + } else if ( node === endContainer && node.nodeType === node.TEXT_NODE ) { + accumulator.end = currentLength + endOffset; + // Range indicates that the current node is selected. + } else if ( + parentNode === endContainer && + node === endContainer.childNodes[ endOffset - 1 ] + ) { + accumulator.end = currentLength + value.text.length; + // Range indicates that the selection is before the current node. + } else if ( + parentNode === endContainer && + node === endContainer.childNodes[ endOffset ] + ) { + accumulator.end = currentLength; + // Fallback if no child inside handled the selection. + } else if ( node === endContainer ) { + accumulator.end = currentLength + endOffset; + } +} + +/** + * Adjusts the start and end offsets from a range based on a text filter. + * + * @param {Node} node Node of which the text should be filtered. + * @param {Range} range The range to filter. + * @param {Function} filter Function to use to filter the text. + * + * @return {Object|undefined} Object containing range properties. + */ +function filterRange(node: Node, range?: SimpleRange, filter?: Function): SimpleRange|undefined { + if ( ! range ) { + return; + } + + if ( ! filter ) { + return; + } + + const { startContainer, endContainer } = range; + let { startOffset, endOffset } = range; + let value = node.nodeValue || ''; + + if ( node === startContainer ) { + startOffset = filter( value.slice( 0, startOffset ) ).length; + } + + if ( node === endContainer ) { + endOffset = filter( value.slice( 0, endOffset ) ).length; + } + + return { startContainer, startOffset, endContainer, endOffset }; +} + +/** + * Collapse any whitespace used for HTML formatting to one space character, + * because it will also be displayed as such by the browser. + * + * We need to strip it from the content because we use white-space: pre-wrap for + * displaying editable rich text. Without using white-space: pre-wrap, the + * browser will litter the content with non breaking spaces, among other issues. + * See packages/rich-text/src/component/use-default-style.js. + * + * @see + * https://developer.mozilla.org/en-US/docs/Web/CSS/white-space-collapse#collapsing_of_white_space + * + * @param {HTMLElement} element + * @param {boolean} isRoot + * + * @return {HTMLElement} New element with collapsed whitespace. + */ +function collapseWhiteSpace(element: HTMLElement, isRoot: boolean = true): HTMLElement { + const clone = element.cloneNode( true ) as HTMLElement; + clone.normalize(); + Array.from( clone.childNodes ).forEach( ( node, i, nodes ) => { + if ( node.nodeType === node.TEXT_NODE ) { + let newNodeValue = node.nodeValue || ''; + + if ( /[\n\t\r\f]/.test( newNodeValue ) ) { + newNodeValue = newNodeValue.replace( /[\n\t\r\f]+/g, ' ' ); + } + + if ( newNodeValue.indexOf( ' ' ) !== -1 ) { + newNodeValue = newNodeValue.replace( / {2,}/g, ' ' ); + } + + if ( i === 0 && newNodeValue.startsWith( ' ' ) ) { + newNodeValue = newNodeValue.slice( 1 ); + } else if ( + isRoot && + i === nodes.length - 1 && + newNodeValue.endsWith( ' ' ) + ) { + newNodeValue = newNodeValue.slice( 0, -1 ); + } + + node.nodeValue = newNodeValue; + } else if ( node.nodeType === node.ELEMENT_NODE ) { + collapseWhiteSpace( node as HTMLElement, false ); + } + } ); + return clone; +} + +/** + * We need to normalise line breaks to `\n` so they are consistent across + * platforms and serialised properly. Not removing \r would cause it to + * linger and result in double line breaks when whitespace is preserved. + */ +const CARRIAGE_RETURN = '\r'; + +/** + * Removes reserved characters used by rich-text (zero width non breaking spaces + * added by `toTree` and object replacement characters). + * + * @param {string} string + */ +export function removeReservedCharacters( string: string ): string { + // with the global flag, note that we should create a new regex each time OR + // reset lastIndex state. + return string.replace( + new RegExp( + `[${ ZWNBSP }${ OBJECT_REPLACEMENT_CHARACTER }${ CARRIAGE_RETURN }]`, + 'gu' + ), + '' + ); +} + +/** + * Creates a Rich Text value from a DOM element and range. + * + * @param {Object} $1 Named arguments. + * @param {Element} [$1.element] Element to create value from. + * @param {Range} [$1.range] Range to create value from. + * @param {boolean} [$1.isEditableTree] + * + * @return {RichTextValue} A rich text value. + */ +function createFromElement( + { element, range, isEditableTree }: + { element?:Element, range?:SimpleRange, isEditableTree?: boolean } +): RichTextValue { + const accumulator = createEmptyValue(); + + if ( ! element ) { + return accumulator; + } + + if ( ! element.hasChildNodes() ) { + accumulateSelection( accumulator, element, range, createEmptyValue() ); + return accumulator; + } + + const length = element.childNodes.length; + let newRange = range; + + // Optimise for speed. + for ( let index = 0; index < length; index++ ) { + const node = element.childNodes[ index ]; + const tagName = node.nodeName.toLowerCase(); + + if ( node.nodeType === node.TEXT_NODE ) { + const text = removeReservedCharacters( node.nodeValue || '' ); + newRange = filterRange( node, newRange, removeReservedCharacters ); + accumulateSelection( accumulator, node, newRange, { text, formats: [], replacements: [] } ); + // Create a sparse array of the same length as `text`, in which + // formats can be added. + accumulator.formats.length += text.length; + accumulator.replacements.length += text.length; + accumulator.text += text; + continue; + } + + if ( node.nodeType !== node.ELEMENT_NODE ) { + continue; + } + + if ( + isEditableTree && + // Ignore any line breaks that are not inserted by us. + tagName === 'br' && + ! (node as HTMLElement).getAttribute( 'data-rich-text-line-break' ) + ) { + accumulateSelection( accumulator, node, newRange, createEmptyValue() ); + continue; + } + + if ( tagName === 'script' ) { + const value = { + formats: [ [{ type: '' }] , [{ type: '' }] ], + replacements: [ + { + type: tagName, + attributes: { + 'data-rich-text-script': + (node as HTMLElement).getAttribute( 'data-rich-text-script' ) || + encodeURIComponent( (node as HTMLElement).innerHTML ), + }, + }, + ], + text: OBJECT_REPLACEMENT_CHARACTER, + start: 0, + end: 0, + }; + accumulateSelection( accumulator, node, newRange, value ); + mergePair( accumulator, value ); + continue; + } + + if ( tagName === 'br' ) { + accumulateSelection( accumulator, node, newRange, createEmptyValue() ); + mergePair( accumulator, create( { text: '\n' } ) ); + continue; + } + + const format = toFormat( { + tagName, + attributes: getAttributes( { element: node as HTMLElement } ), + } ); + + // When a format type is declared as not editable, replace it with an + // object replacement character and preserve the inner HTML. + // if ( format?.formatType?.contentEditable === false ) { + // delete format.formatType; + // accumulateSelection( accumulator, node, newRange, createEmptyValue() ); + // mergePair( accumulator, { + // formats: [ [{ type: '' }] , [{ type: '' }] ], + // replacements: [ + // { + // ...format, + // innerHTML: (node as HTMLElement).innerHTML, + // }, + // ], + // text: OBJECT_REPLACEMENT_CHARACTER, + // start: 0, + // end: 0, + // } ); + // continue; + // } + + if ( format ) { + delete format.formatType; + } + + const value = createFromElement( { + element: node as HTMLElement, + range: newRange, + isEditableTree, + } ); + + accumulateSelection( accumulator, node, newRange, value ); + + // Ignore any placeholders, but keep their content since the browser + // might insert text inside them when the editable element is flex. + if ( ! format || (node as HTMLElement).getAttribute( 'data-rich-text-placeholder' ) ) { + mergePair( accumulator, value ); + } else if ( value.text.length === 0 ) { + if ( format.attributes ) { + mergePair( accumulator, { + formats: [ [{ type: '' }] , [{ type: '' }] ], + replacements: [ format ], + text: OBJECT_REPLACEMENT_CHARACTER, + start: 0, + end: 0, + } ); + } + } else { + // Indices should share a reference to the same formats array. + // Only create a new reference if `formats` changes. + function mergeFormats( formats ) { + if ( mergeFormats.formats === formats ) { + return mergeFormats.newFormats; + } + + const newFormats = formats + ? [ format, ...formats ] + : [ format ]; + + mergeFormats.formats = formats; + mergeFormats.newFormats = newFormats; + + return newFormats; + } + + // Since the formats parameter can be `undefined`, preset + // `mergeFormats` with a new reference. + mergeFormats.newFormats = [ format ]; + + mergePair( accumulator, { + ...value, + formats: Array.from( value.formats, mergeFormats ), + } ); + } + } + + return accumulator; +} + +/** + * Gets the attributes of an element in object shape. + * + * @param {Object} $1 Named arguments. + * @param {Element} $1.element Element to get attributes from. + * + * @return {Object} Attribute object or `undefined` if the element has no + * attributes. + */ +function getAttributes({ element }: { element: Element }): Record{ + let accumulator: Record = {}; + + if ( ! element.hasAttributes() ) { + return accumulator; + } + + const length = element.attributes.length; + // Optimise for speed. + for ( let i = 0; i < length; i++ ) { + const { name, value } = element.attributes[ i ]; + + if ( name.indexOf( 'data-rich-text-' ) === 0 ) { + continue; + } + + const safeName = /^on/i.test( name ) + ? 'data-disable-rich-text-' + name + : name; + + accumulator = accumulator || {}; + accumulator[ safeName ] = value; + } + + return accumulator; +} diff --git a/packages/rich-text/lib/get-active-format.ts b/packages/rich-text/lib/get-active-format.ts new file mode 100644 index 0000000..3f3e764 --- /dev/null +++ b/packages/rich-text/lib/get-active-format.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { getActiveFormats } from './get-active-formats'; +import { RichTextFormat, RichTextValue } from './types'; + +/** + * Gets the format object by type at the start of the selection. This can be + * used to get e.g. the URL of a link format at the current selection, but also + * to check if a format is active at the selection. Returns undefined if there + * is no format at the selection. + * + * @param {RichTextValue} value Value to inspect. + * @param {string} formatTypeName Format type to look for. + * + * @return {RichTextFormat|undefined} Active format object of the specified + * type, or undefined. + */ +export function getActiveFormat( value: RichTextValue, formatTypeName: string ): RichTextFormat|undefined { + return getActiveFormats( value ).find( + ( { type } ) => type === formatTypeName + ); +} diff --git a/packages/rich-text/lib/get-active-formats.ts b/packages/rich-text/lib/get-active-formats.ts new file mode 100644 index 0000000..1362465 --- /dev/null +++ b/packages/rich-text/lib/get-active-formats.ts @@ -0,0 +1,86 @@ +/** + * Internal dependencies + */ +import { isFormatEqual } from './is-format-equal'; +import { RichTextFormatList, RichTextValue } from './types'; + +/** + * Gets the all format objects at the start of the selection. + * + * @param {RichTextValue} value Value to inspect. + * @param {Array} EMPTY_ACTIVE_FORMATS Array to return if there are no + * active formats. + * + * @return {RichTextFormatList} Active format objects. + */ +export function getActiveFormats(value: RichTextValue, EMPTY_ACTIVE_FORMATS: RichTextFormatList = [] ): RichTextFormatList { + const { formats, start, end, activeFormats } = value; + if ( start === undefined ) { + return EMPTY_ACTIVE_FORMATS; + } + + if ( start === end ) { + // For a collapsed caret, it is possible to override the active formats. + if ( activeFormats ) { + return activeFormats; + } + + const formatsBefore = formats[ start - 1 ] || EMPTY_ACTIVE_FORMATS; + const formatsAfter = formats[ start ] || EMPTY_ACTIVE_FORMATS; + + // By default, select the lowest amount of formats possible (which means + // the caret is positioned outside the format boundary). The user can + // then use arrow keys to define `activeFormats`. + if ( formatsBefore.length < formatsAfter.length ) { + return formatsBefore; + } + + return formatsAfter; + } + + // If there's no formats at the start index, there are not active formats. + if ( ! formats[ start ] ) { + return EMPTY_ACTIVE_FORMATS; + } + + const selectedFormats = formats.slice( start, end ); + + // Clone the formats so we're not mutating the live value. + const _activeFormats = [ ...selectedFormats[ 0 ] ]; + let i = selectedFormats.length; + + // For performance reasons, start from the end where it's much quicker to + // realise that there are no active formats. + while ( i-- ) { + const formatsAtIndex = selectedFormats[ i ]; + + // If we run into any index without formats, we're sure that there's no + // active formats. + if ( ! formatsAtIndex ) { + return EMPTY_ACTIVE_FORMATS; + } + + let ii = _activeFormats.length; + + // Loop over the active formats and remove any that are not present at + // the current index. + while ( ii-- ) { + const format = _activeFormats[ ii ]; + + if ( + ! formatsAtIndex.find( ( _format ) => + isFormatEqual( format, _format ) + ) + ) { + _activeFormats.splice( ii, 1 ); + } + } + + // If there are no active formats, we can stop. + if ( _activeFormats.length === 0 ) { + return EMPTY_ACTIVE_FORMATS; + } + } + + return _activeFormats || EMPTY_ACTIVE_FORMATS; +} diff --git a/packages/rich-text/lib/get-active-object.ts b/packages/rich-text/lib/get-active-object.ts new file mode 100644 index 0000000..a41d53c --- /dev/null +++ b/packages/rich-text/lib/get-active-object.ts @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ + +import { OBJECT_REPLACEMENT_CHARACTER } from './special-characters'; +import { RichTextValue } from './types'; + +/** + * Gets the active object, if there is any. + * + * @param {RichTextValue} value Value to inspect. + * + * @return {RichTextFormat|void} Active object, or undefined. + */ +export function getActiveObject( { start = 0, end = 0, replacements, text }: RichTextValue ) { + if ( start + 1 !== end || text[ start ] !== OBJECT_REPLACEMENT_CHARACTER ) { + return; + } + + return replacements[ start ]; +} diff --git a/packages/rich-text/lib/get-text-content.ts b/packages/rich-text/lib/get-text-content.ts new file mode 100644 index 0000000..ef151be --- /dev/null +++ b/packages/rich-text/lib/get-text-content.ts @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import { OBJECT_REPLACEMENT_CHARACTER } from './special-characters'; +import { RichTextValue } from './types'; + +/** + * Get the textual content of a Rich Text value. This is similar to + * `Element.textContent`. + * + * @param {RichTextValue} value Value to use. + * + * @return {string} The text content. + */ +export function getTextContent( { text }: RichTextValue ): string { + return text.replace( OBJECT_REPLACEMENT_CHARACTER, '' ); +} diff --git a/packages/rich-text/lib/index.ts b/packages/rich-text/lib/index.ts new file mode 100644 index 0000000..92152ac --- /dev/null +++ b/packages/rich-text/lib/index.ts @@ -0,0 +1,32 @@ +export { useFormatTypes } from './use-format-types'; +export { applyFormat } from './apply-format'; +export { concat } from './concat'; +export { create } from './create'; +export { getActiveFormat } from './get-active-format'; +export { getActiveFormats } from './get-active-formats'; +export { getActiveObject } from './get-active-object'; +export { getTextContent } from './get-text-content'; +export { isCollapsed } from './is-collapsed'; +export { isEmpty } from './is-empty'; +export { join } from './join'; +export { removeFormat } from './remove-format'; +export { remove } from './remove'; +export { replace } from './replace'; +export { insert } from './insert'; +export { insertObject } from './insert-object'; +export { slice } from './slice'; +export { split } from './split'; +export { toDom } from './to-dom'; +export { toHTMLString } from './to-html-string'; +export { toggleFormat } from './toggle-format'; +export { createElement as __unstableCreateElement } from './create-element'; + +export { useAnchorRef } from './component/use-anchor-ref'; +export { useAnchor } from './component/use-anchor'; + +export { + default as __experimentalRichText, + useRichText as __unstableUseRichText, +} from './component'; + +export * from './types'; diff --git a/packages/rich-text/lib/insert-object.ts b/packages/rich-text/lib/insert-object.ts new file mode 100644 index 0000000..b27441c --- /dev/null +++ b/packages/rich-text/lib/insert-object.ts @@ -0,0 +1,29 @@ +/** + * Internal dependencies + */ + +import { insert } from './insert'; +import { OBJECT_REPLACEMENT_CHARACTER } from './special-characters'; +import { RichTextFormat, RichTextValue } from './types'; + +/** + * Insert a format as an object into a Rich Text value at the given + * `startIndex`. Any content between `startIndex` and `endIndex` will be + * removed. Indices are retrieved from the selection if none are provided. + * + * @param {RichTextValue} value Value to modify. + * @param {RichTextFormat} formatToInsert Format to insert as object. + * @param {number} [startIndex] Start index. + * @param {number} [endIndex] End index. + * + * @return {RichTextValue} A new value with the object inserted. + */ +export function insertObject( value: RichTextValue, formatToInsert: RichTextFormat, startIndex: number, endIndex: number ) { + const valueToInsert = { + formats: [ , ], + replacements: [ formatToInsert ], + text: OBJECT_REPLACEMENT_CHARACTER, + }; + + return insert( value, valueToInsert, startIndex, endIndex ); +} diff --git a/packages/rich-text/lib/insert.ts b/packages/rich-text/lib/insert.ts new file mode 100644 index 0000000..fda4976 --- /dev/null +++ b/packages/rich-text/lib/insert.ts @@ -0,0 +1,53 @@ +/** + * Internal dependencies + */ + +import { create } from './create'; +import { normaliseFormats } from './normalise-formats'; +import { RichTextValue } from './types'; + +/** + * Insert a Rich Text value, an HTML string, or a plain text string, into a + * Rich Text value at the given `startIndex`. Any content between `startIndex` + * and `endIndex` will be removed. Indices are retrieved from the selection if + * none are provided. + * + * @param {RichTextValue} value Value to modify. + * @param {RichTextValue|string} valueToInsert Value to insert. + * @param {number} [startIndex] Start index. + * @param {number} [endIndex] End index. + * + * @return {RichTextValue} A new value with the value inserted. + */ +export function insert( + value: RichTextValue, + valueToInsert: RichTextValue|string, + startIndex: number = value.start || 0, + endIndex: number = value.end || 0, +) { + const { formats, replacements, text } = value; + + if ( typeof valueToInsert === 'string' ) { + valueToInsert = create( { text: valueToInsert } ); + } + + const index = startIndex + valueToInsert.text.length; + + return normaliseFormats( { + formats: formats + .slice( 0, startIndex ) + .concat( valueToInsert.formats, formats.slice( endIndex ) ), + replacements: replacements + .slice( 0, startIndex ) + .concat( + valueToInsert.replacements, + replacements.slice( endIndex ) + ), + text: + text.slice( 0, startIndex ) + + valueToInsert.text + + text.slice( endIndex ), + start: index, + end: index, + } ); +} diff --git a/packages/rich-text/lib/is-collapsed.ts b/packages/rich-text/lib/is-collapsed.ts new file mode 100644 index 0000000..fcde801 --- /dev/null +++ b/packages/rich-text/lib/is-collapsed.ts @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import type { RichTextValue } from './types'; + +/** + * Check if the selection of a Rich Text value is collapsed or not. Collapsed + * means that no characters are selected, but there is a caret present. If there + * is no selection, `undefined` will be returned. This is similar to + * `window.getSelection().isCollapsed()`. + * + * @param props The rich text value to check. + * @param props.start + * @param props.end + * @return True if the selection is collapsed, false if not, undefined if there is no selection. + */ +export function isCollapsed( { + start, + end, +}: RichTextValue ): boolean | undefined { + if ( start === undefined || end === undefined ) { + return; + } + + return start === end; +} diff --git a/packages/rich-text/lib/is-empty.ts b/packages/rich-text/lib/is-empty.ts new file mode 100644 index 0000000..e3856ac --- /dev/null +++ b/packages/rich-text/lib/is-empty.ts @@ -0,0 +1,13 @@ +import { RichTextValue } from "./types"; + +/** + * Check if a Rich Text value is Empty, meaning it contains no text or any + * objects (such as images). + * + * @param {RichTextValue} value Value to use. + * + * @return {boolean} True if the value is empty, false if not. + */ +export function isEmpty( { text }: RichTextValue ): boolean { + return text.length === 0; +} diff --git a/packages/rich-text/lib/is-format-equal.ts b/packages/rich-text/lib/is-format-equal.ts new file mode 100644 index 0000000..3bfdb14 --- /dev/null +++ b/packages/rich-text/lib/is-format-equal.ts @@ -0,0 +1,58 @@ +import { RichTextFormat } from './types'; + +/** + * Optimised equality check for format objects. + * + * @param {?RichTextFormat} format1 Format to compare. + * @param {?RichTextFormat} format2 Format to compare. + * + * @return {boolean} True if formats are equal, false if not. + */ +export function isFormatEqual( format1: RichTextFormat, format2: RichTextFormat ): boolean { + // Both not defined. + if ( format1 === format2 ) { + return true; + } + + // Either not defined. + if ( ! format1 || ! format2 ) { + return false; + } + + if ( format1.type !== format2.type ) { + return false; + } + + const attributes1 = format1.attributes; + const attributes2 = format2.attributes; + + // Both not defined. + if ( attributes1 === attributes2 ) { + return true; + } + + // Either not defined. + if ( ! attributes1 || ! attributes2 ) { + return false; + } + + const keys1 = Object.keys( attributes1 ); + const keys2 = Object.keys( attributes2 ); + + if ( keys1.length !== keys2.length ) { + return false; + } + + const length = keys1.length; + + // Optimise for speed. + for ( let i = 0; i < length; i++ ) { + const name = keys1[ i ]; + + if ( attributes1[ name ] !== attributes2[ name ] ) { + return false; + } + } + + return true; +} diff --git a/packages/rich-text/lib/is-range-equal.ts b/packages/rich-text/lib/is-range-equal.ts new file mode 100644 index 0000000..e6acac3 --- /dev/null +++ b/packages/rich-text/lib/is-range-equal.ts @@ -0,0 +1,23 @@ +import { SimpleRange } from "./types"; + +/** + * Returns true if two ranges are equal, or false otherwise. Ranges are + * considered equal if their start and end occur in the same container and + * offset. + * + * @param {Range|null} a First range object to test. + * @param {Range|null} b First range object to test. + * + * @return {boolean} Whether the two ranges are equal. + */ +export function isRangeEqual( a: SimpleRange, b: SimpleRange ): boolean { + return ( + a === b || + ( a && + b && + a.startContainer === b.startContainer && + a.startOffset === b.startOffset && + a.endContainer === b.endContainer && + a.endOffset === b.endOffset ) + ); +} diff --git a/packages/rich-text/lib/join.ts b/packages/rich-text/lib/join.ts new file mode 100644 index 0000000..a0f4e48 --- /dev/null +++ b/packages/rich-text/lib/join.ts @@ -0,0 +1,34 @@ +/** + * Internal dependencies + */ + +import { create } from './create'; +import { normaliseFormats } from './normalise-formats'; +import { RichTextValue } from './types'; + +/** + * Combine an array of Rich Text values into one, optionally separated by + * `separator`, which can be a Rich Text value, HTML string, or plain text + * string. This is similar to `Array.prototype.join`. + * + * @param {Array} values An array of values to join. + * @param {string|RichTextValue} [separator] Separator string or value. + * + * @return {RichTextValue} A new combined value. + */ +export function join( values: RichTextValue[], separator: RichTextValue|string = '' ): RichTextValue { + if ( typeof separator === 'string' ) { + separator = create( { text: separator } ); + } + + return normaliseFormats( + values.reduce( ( accumlator, { formats, replacements, text } ) => ( { + formats: accumlator.formats.concat( separator.formats, formats ), + replacements: accumlator.replacements.concat( + separator.replacements, + replacements + ), + text: accumlator.text + separator.text + text, + } ) ) + ); +} diff --git a/packages/rich-text/lib/length.ts b/packages/rich-text/lib/length.ts new file mode 100644 index 0000000..c0fdb94 --- /dev/null +++ b/packages/rich-text/lib/length.ts @@ -0,0 +1,5 @@ +import { RichTextValue } from './types'; + +export function length( a: RichTextValue ): number { + return a.text.length; +} diff --git a/packages/rich-text/lib/normalise-formats.ts b/packages/rich-text/lib/normalise-formats.ts new file mode 100644 index 0000000..b798d0f --- /dev/null +++ b/packages/rich-text/lib/normalise-formats.ts @@ -0,0 +1,37 @@ +import { isFormatEqual } from './is-format-equal'; +import { RichTextValue } from './types'; + +/** + * Normalises formats: ensures subsequent adjacent equal formats have the same + * reference. + * + * @param {RichTextValue} value Value to normalise formats of. + * + * @return {RichTextValue} New value with normalised formats. + */ +export function normaliseFormats( value: RichTextValue ): RichTextValue { + const newFormats = value.formats.slice(); + + newFormats.forEach( ( formatsAtIndex, index ) => { + const formatsAtPreviousIndex = newFormats[ index - 1 ]; + + if ( formatsAtPreviousIndex ) { + const newFormatsAtIndex = formatsAtIndex.slice(); + + newFormatsAtIndex.forEach( ( format, formatIndex ) => { + const previousFormat = formatsAtPreviousIndex[ formatIndex ]; + + if ( isFormatEqual( format, previousFormat ) ) { + newFormatsAtIndex[ formatIndex ] = previousFormat; + } + } ); + + newFormats[ index ] = newFormatsAtIndex; + } + } ); + + return { + ...value, + formats: newFormats, + }; +} diff --git a/packages/rich-text/lib/remove-format.ts b/packages/rich-text/lib/remove-format.ts new file mode 100644 index 0000000..773769b --- /dev/null +++ b/packages/rich-text/lib/remove-format.ts @@ -0,0 +1,83 @@ +/** + * Internal dependencies + */ + +import { normaliseFormats } from './normalise-formats'; +import { RichTextFormatList, RichTextValue } from './types'; + +/** + * Remove any format object from a Rich Text value by type from the given + * `startIndex` to the given `endIndex`. Indices are retrieved from the + * selection if none are provided. + * + * @param {RichTextValue} value Value to modify. + * @param {string} formatType Format type to remove. + * @param {number} [startIndex] Start index. + * @param {number} [endIndex] End index. + * + * @return {RichTextValue} A new value with the format applied. + */ +export function removeFormat( + value: RichTextValue, + formatType: string, + startIndex: number = value.start || 0, + endIndex: number = value.end || 0, +): RichTextValue { + const { formats, activeFormats } = value; + const newFormats = formats.slice(); + + // If the selection is collapsed, expand start and end to the edges of the + // format. + if ( startIndex === endIndex ) { + const format = newFormats[ startIndex ]?.find( + ( { type } ) => type === formatType + ); + + if ( format ) { + while ( + newFormats[ startIndex ]?.find( + ( newFormat ) => newFormat === format + ) + ) { + filterFormats( newFormats, startIndex, formatType ); + startIndex--; + } + + endIndex++; + + while ( + newFormats[ endIndex ]?.find( + ( newFormat ) => newFormat === format + ) + ) { + filterFormats( newFormats, endIndex, formatType ); + endIndex++; + } + } + } else { + for ( let i = startIndex; i < endIndex; i++ ) { + if ( newFormats[ i ] ) { + filterFormats( newFormats, i, formatType ); + } + } + } + + return normaliseFormats( { + ...value, + formats: newFormats, + activeFormats: + activeFormats?.filter( ( { type } ) => type !== formatType ) || [], + } ); +} + +function filterFormats( formats: RichTextFormatList[], index: number, formatType: string ) { + const newFormats = formats[ index ].filter( + ( { type } ) => type !== formatType + ); + + if ( newFormats.length ) { + formats[ index ] = newFormats; + } else { + delete formats[ index ]; + } +} diff --git a/packages/rich-text/lib/remove.ts b/packages/rich-text/lib/remove.ts new file mode 100644 index 0000000..8994d58 --- /dev/null +++ b/packages/rich-text/lib/remove.ts @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ + +import { insert } from './insert'; +import { create } from './create'; +import { RichTextValue } from './types'; + +/** + * Remove content from a Rich Text value between the given `startIndex` and + * `endIndex`. Indices are retrieved from the selection if none are provided. + * + * @param {RichTextValue} value Value to modify. + * @param {number} [startIndex] Start index. + * @param {number} [endIndex] End index. + * + * @return {RichTextValue} A new value with the content removed. + */ +export function remove( value: RichTextValue, startIndex: number, endIndex: number ): RichTextValue { + return insert( value, create(), startIndex, endIndex ); +} diff --git a/packages/rich-text/lib/replace.ts b/packages/rich-text/lib/replace.ts new file mode 100644 index 0000000..fffa422 --- /dev/null +++ b/packages/rich-text/lib/replace.ts @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ + +import { normaliseFormats } from './normalise-formats'; +import { RichTextFormatList, RichTextValue } from './types'; + +/** + * Search a Rich Text value and replace the match(es) with `replacement`. This + * is similar to `String.prototype.replace`. + * + * @param {RichTextValue} value The value to modify. + * @param {RegExp|string} pattern A RegExp object or literal. Can also be + * a string. It is treated as a verbatim + * string and is not interpreted as a + * regular expression. Only the first + * occurrence will be replaced. + * @param {Function|string} replacement The match or matches are replaced with + * the specified or the value returned by + * the specified function. + * + * @return {RichTextValue} A new value with replacements applied. + */ +export function replace( + { formats, replacements, text, start, end }: RichTextValue, + pattern: RegExp|string, + replacement: (s: string, ...rest: any[]) => (string|RichTextValue)|string, +): RichTextValue { + text = text.replace( pattern, ( match, ...rest ) => { + const offset = rest[ rest.length - 2 ]; + let newFormats: Array; + let newReplacements: Array; + let newText = (typeof replacement === 'function') ? replacement( match, ...rest ) : replacement; + + if ( typeof newText === 'object' ) { + newFormats = newText.formats; + newReplacements = newText.replacements; + newText = newText.text; + } else { + newFormats = Array( newText.length ); + newReplacements = Array( newText.length ); + + if ( formats[ offset ] ) { + newFormats = newFormats.fill( formats[ offset ] ); + } + } + + formats = formats + .slice( 0, offset ) + .concat( newFormats, formats.slice( offset + match.length ) ); + replacements = replacements + .slice( 0, offset ) + .concat( + newReplacements, + replacements.slice( offset + match.length ) + ); + + if ( start ) { + start = end = offset + newText.length; + } + + return newText; + } ); + + return normaliseFormats( { formats, replacements, text, start, end } ); +} diff --git a/packages/rich-text/lib/slice.ts b/packages/rich-text/lib/slice.ts new file mode 100644 index 0000000..18c501c --- /dev/null +++ b/packages/rich-text/lib/slice.ts @@ -0,0 +1,26 @@ +import { RichTextValue } from "./types"; + +/** + * Slice a Rich Text value from `startIndex` to `endIndex`. Indices are + * retrieved from the selection if none are provided. This is similar to + * `String.prototype.slice`. + * + * @param {RichTextValue} value Value to modify. + * @param {number} [startIndex] Start index. + * @param {number} [endIndex] End index. + * + * @return {RichTextValue} A new extracted value. + */ +export function slice( value: RichTextValue, startIndex: number = value.start || 0, endIndex: number = value.end || 0 ): RichTextValue { + const { formats, replacements, text } = value; + + if ( startIndex === undefined || endIndex === undefined ) { + return { ...value }; + } + + return { + formats: formats.slice( startIndex, endIndex ), + replacements: replacements.slice( startIndex, endIndex ), + text: text.slice( startIndex, endIndex ), + }; +} diff --git a/packages/rich-text/lib/special-characters.ts b/packages/rich-text/lib/special-characters.ts new file mode 100644 index 0000000..a05f614 --- /dev/null +++ b/packages/rich-text/lib/special-characters.ts @@ -0,0 +1,10 @@ +/** + * Object replacement character, used as a placeholder for objects. + */ +export const OBJECT_REPLACEMENT_CHARACTER = '\ufffc'; + +/** + * Zero width non-breaking space, used as padding in the editable DOM tree when + * it is empty otherwise. + */ +export const ZWNBSP = '\ufeff'; diff --git a/packages/rich-text/lib/split.ts b/packages/rich-text/lib/split.ts new file mode 100644 index 0000000..761a3fe --- /dev/null +++ b/packages/rich-text/lib/split.ts @@ -0,0 +1,82 @@ +/** + * Internal dependencies + */ + +import { RichTextValue } from "./types"; + +/** @typedef {import('./types').RichTextValue} RichTextValue */ + +/** + * Split a Rich Text value in two at the given `startIndex` and `endIndex`, or + * split at the given separator. This is similar to `String.prototype.split`. + * Indices are retrieved from the selection if none are provided. + * + * @param {RichTextValue} value + * @param {number|string} [string] Start index, or string at which to split. + * + * @return {Array|undefined} An array of new values. + */ +export function split( value: RichTextValue, string: number|string, endIndex?: number): RichTextValue[]|undefined { + if ( typeof string !== 'string' ) { + return splitAtSelection(value, string, endIndex); + } + + const { formats, replacements, text, start, end } = value; + + let nextStart = 0; + + return text.split( string ).map( ( substring ) => { + const startIndex = nextStart; + const value: RichTextValue = { + formats: formats.slice( startIndex, startIndex + substring.length ), + replacements: replacements.slice( + startIndex, + startIndex + substring.length + ), + text: substring, + }; + + nextStart += string.length + substring.length; + + if ( start !== undefined && end !== undefined ) { + if ( start >= startIndex && start < nextStart ) { + value.start = start - startIndex; + } else if ( start < startIndex && end > startIndex ) { + value.start = 0; + } + + if ( end >= startIndex && end < nextStart ) { + value.end = end - startIndex; + } else if ( start < nextStart && end > nextStart ) { + value.end = substring.length; + } + } + + return value; + } ); +} + +function splitAtSelection( + { formats, replacements, text, start, end }: RichTextValue, + startIndex: number|undefined = start, + endIndex: number|undefined = end +): RichTextValue[]|undefined { + if ( start === undefined || end === undefined ) { + return; + } + + const before = { + formats: formats.slice( 0, startIndex ), + replacements: replacements.slice( 0, startIndex ), + text: text.slice( 0, startIndex ), + }; + const after = { + formats: formats.slice( endIndex ), + replacements: replacements.slice( endIndex ), + text: text.slice( endIndex ), + start: 0, + end: 0, + }; + + return [ before, after ]; +} diff --git a/packages/rich-text/lib/test/__snapshots__/to-dom.test.ts.snap b/packages/rich-text/lib/test/__snapshots__/to-dom.test.ts.snap new file mode 100644 index 0000000..b7bb73f --- /dev/null +++ b/packages/rich-text/lib/test/__snapshots__/to-dom.test.ts.snap @@ -0,0 +1,303 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`recordToDom should create a value with formatting 1`] = ` + + + test + + + +`; + +exports[`recordToDom should create a value with formatting for split tags 1`] = ` + + + test + + + +`; + +exports[`recordToDom should create a value with formatting with attributes 1`] = ` + + + test + + + +`; + +exports[`recordToDom should create a value with image object 1`] = ` + + + + + +`; + +exports[`recordToDom should create a value with image object and formatting 1`] = ` + + + + + + + + +`; + +exports[`recordToDom should create a value with image object and text after 1`] = ` + + + + + te + + st + +`; + +exports[`recordToDom should create a value with image object and text before 1`] = ` + + te + + st + + + + + +`; + +exports[`recordToDom should create a value with nested formatting 1`] = ` + + + + test + + + + +`; + +exports[`recordToDom should create a value without formatting 1`] = ` + + test + +`; + +exports[`recordToDom should create an empty value 1`] = ` + + + ๏ปฟ + +`; + +exports[`recordToDom should create an empty value from empty tags 1`] = ` + + + ๏ปฟ + +`; + +exports[`recordToDom should disarm on* attribute 1`] = ` + + + + + +`; + +exports[`recordToDom should disarm script 1`] = ` + + + ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 0, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [ , ], + replacements: [ + { + attributes: { 'data-rich-text-script': 'alert(%221%22)' }, + type: 'script', + }, + ], + text: '\ufffc', + }, + }, + { + description: 'should disarm on* attribute', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 0, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [ , ], + replacements: [ + { + attributes: { + 'data-disable-rich-text-onerror': "alert('1')", + }, + type: 'img', + }, + ], + text: '\ufffc', + }, + }, +]; + +export const specWithRegistration = [ + { + description: 'should create format by matching the class', + formatName: 'my-plugin/link', + formatType: { + title: 'Custom Link', + tagName: 'a', + className: 'custom-format', + edit() {}, + }, + html: 'a', + value: { + formats: [ + [ + { + type: 'my-plugin/link', + tagName: 'a', + attributes: {}, + unregisteredAttributes: {}, + }, + ], + ], + replacements: [ , ], + text: 'a', + }, + }, + { + description: 'should retain class names', + formatName: 'my-plugin/link', + formatType: { + title: 'Custom Link', + tagName: 'a', + className: 'custom-format', + edit() {}, + }, + html: 'a', + value: { + formats: [ + [ + { + type: 'my-plugin/link', + tagName: 'a', + attributes: {}, + unregisteredAttributes: { + class: 'test', + }, + }, + ], + ], + replacements: [ , ], + text: 'a', + }, + }, + { + description: 'should create base format', + formatName: 'core/link', + formatType: { + title: 'Link', + tagName: 'a', + className: null, + edit() {}, + }, + html: 'a', + value: { + formats: [ + [ + { + type: 'core/link', + tagName: 'a', + attributes: {}, + unregisteredAttributes: { + class: 'custom-format', + }, + }, + ], + ], + replacements: [ , ], + text: 'a', + }, + }, + { + description: 'should create fallback format', + html: 'a', + value: { + formats: [ + [ + { + type: 'a', + attributes: { + class: 'custom-format', + }, + }, + ], + ], + replacements: [ , ], + text: 'a', + }, + }, + { + description: 'should not create format if editable tree only', + formatName: 'my-plugin/link', + formatType: { + title: 'Custom Link', + tagName: 'a', + className: 'custom-format', + edit() {}, + __experimentalCreatePrepareEditableTree() {}, + }, + html: 'a', + value: { + formats: [ , ], + replacements: [ , ], + text: 'a', + }, + noToHTMLString: true, + }, + { + description: + 'should create format if editable tree only but changes need to be recorded', + formatName: 'my-plugin/link', + formatType: { + title: 'Custom Link', + tagName: 'a', + className: 'custom-format', + edit() {}, + __experimentalCreatePrepareEditableTree() {}, + __experimentalCreateOnChangeEditableValue() {}, + }, + html: 'a', + value: { + formats: [ + [ + { + type: 'my-plugin/link', + tagName: 'a', + attributes: {}, + unregisteredAttributes: {}, + }, + ], + ], + replacements: [ , ], + text: 'a', + }, + }, + { + description: 'should be non editable', + formatName: 'my-plugin/non-editable', + formatType: { + title: 'Non Editable', + tagName: 'a', + className: 'non-editable', + contentEditable: false, + edit() {}, + }, + html: 'a', + value: { + formats: [ , ], + replacements: [ + { + type: 'my-plugin/non-editable', + tagName: 'a', + attributes: {}, + unregisteredAttributes: {}, + innerHTML: 'a', + }, + ], + text: OBJECT_REPLACEMENT_CHARACTER, + }, + }, +]; diff --git a/packages/rich-text/lib/test/insert-object.test.ts b/packages/rich-text/lib/test/insert-object.test.ts new file mode 100644 index 0000000..5a6f0fe --- /dev/null +++ b/packages/rich-text/lib/test/insert-object.test.ts @@ -0,0 +1,33 @@ +import deepFreeze from 'deep-freeze'; +import { describe, expect, it } from 'vitest' +import { insertObject } from '../insert-object'; +import { getSparseArrayLength } from './helpers'; +import { OBJECT_REPLACEMENT_CHARACTER } from '../special-characters'; + +describe( 'insert', () => { + const obj = { type: 'obj' }; + const em = { type: 'em' }; + + it( 'should delete and insert', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const expected = { + formats: [ , , , [ em ], , , , , , , ], + replacements: [ , , obj, , , , , , , , ], + text: `on${ OBJECT_REPLACEMENT_CHARACTER }o three`, + start: 3, + end: 3, + }; + const result = insertObject( deepFreeze( record ), obj, 2, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 1 ); + expect( getSparseArrayLength( result.replacements ) ).toBe( 1 ); + } ); +} ); diff --git a/packages/rich-text/lib/test/insert.test.ts b/packages/rich-text/lib/test/insert.test.ts new file mode 100644 index 0000000..5cb1b63 --- /dev/null +++ b/packages/rich-text/lib/test/insert.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import deepFreeze from 'deep-freeze'; +import { insert } from '../insert'; +import { getSparseArrayLength } from './helpers'; + +describe( 'insert', () => { + const em = { type: 'em' }; + const strong = { type: 'strong' }; + + it( 'should delete and insert', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [], + text: 'one two three', + start: 6, + end: 6, + }; + const toInsert = { + formats: [ [ strong ] ], + replacements: [], + text: 'a', + }; + const expected = { + formats: [ , , [ strong ], [ em ], , , , , , , ], + replacements: [], + text: 'onao three', + start: 3, + end: 3, + }; + const result = insert( deepFreeze( record ), toInsert, 2, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); + + it( 'should insert line break with selection', () => { + const record = { + formats: [ , , ], + replacements: [], + text: 'tt', + start: 1, + end: 1, + }; + const toInsert = { + formats: [ , ], + replacements: [], + text: '\n', + }; + const expected = { + formats: [ , , , ], + replacements: [], + text: 't\nt', + start: 2, + end: 2, + }; + const result = insert( deepFreeze( record ), toInsert ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); +} ); diff --git a/packages/rich-text/lib/test/is-collapsed.test.ts b/packages/rich-text/lib/test/is-collapsed.test.ts new file mode 100644 index 0000000..fd55bef --- /dev/null +++ b/packages/rich-text/lib/test/is-collapsed.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { isCollapsed } from '../is-collapsed'; + +describe( 'isCollapsed', () => { + it( 'should return true for a collapsed selection', () => { + const record = { + start: 4, + end: 4, + }; + + expect( isCollapsed( record ) ).toBe( true ); + } ); +} ); diff --git a/packages/rich-text/lib/test/is-empty.test.ts b/packages/rich-text/lib/test/is-empty.test.ts new file mode 100644 index 0000000..a025d0c --- /dev/null +++ b/packages/rich-text/lib/test/is-empty.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { isEmpty } from '../is-empty'; + +describe( 'isEmpty', () => { + it( 'should return true', () => { + const one = { + formats: [], + text: '', + }; + + expect( isEmpty( one ) ).toBe( true ); + } ); + + it( 'should return false', () => { + const one = { + formats: [], + text: 'test', + }; + + expect( isEmpty( one ) ).toBe( false ); + } ); +} ); diff --git a/packages/rich-text/lib/test/is-format-equal.test.ts b/packages/rich-text/lib/test/is-format-equal.test.ts new file mode 100644 index 0000000..b5ba7c4 --- /dev/null +++ b/packages/rich-text/lib/test/is-format-equal.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { isFormatEqual } from '../is-format-equal'; + +describe( 'isFormatEqual', () => { + const spec = [ + { + format1: undefined, + format2: undefined, + isEqual: true, + description: 'should return true if both are undefined', + }, + { + format1: {}, + format2: undefined, + isEqual: false, + description: 'should return false if one is undefined', + }, + { + format1: { type: 'bold' }, + format2: { type: 'bold' }, + isEqual: true, + description: 'should return true if both have same type', + }, + { + format1: { type: 'bold' }, + format2: { type: 'italic' }, + isEqual: false, + description: 'should return false if one has different type', + }, + { + format1: { type: 'bold', attributes: {} }, + format2: { type: 'bold' }, + isEqual: false, + description: 'should return false if one has undefined attributes', + }, + { + format1: { type: 'bold', attributes: { a: '1' } }, + format2: { type: 'bold', attributes: { a: '1' } }, + isEqual: true, + description: 'should return true if both have same attributes', + }, + { + format1: { type: 'bold', attributes: { a: '1' } }, + format2: { type: 'bold', attributes: { b: '1' } }, + isEqual: false, + description: 'should return false if one has different attributes', + }, + { + format1: { type: 'bold', attributes: { a: '1' } }, + format2: { type: 'bold', attributes: { a: '1', b: '1' } }, + isEqual: false, + description: + 'should return false if one has a different amount of attributes', + }, + { + format1: { type: 'bold', attributes: { b: '1', a: '1' } }, + format2: { type: 'bold', attributes: { a: '1', b: '1' } }, + isEqual: true, + description: + 'should return true both have same attributes but different order', + }, + ]; + + spec.forEach( ( { format1, format2, isEqual, description } ) => { + // eslint-disable-next-line jest/valid-title + it( description, () => { + expect( isFormatEqual( format1, format2 ) ).toBe( isEqual ); + } ); + } ); +} ); diff --git a/packages/rich-text/lib/test/join.test.ts b/packages/rich-text/lib/test/join.test.ts new file mode 100644 index 0000000..48b2e76 --- /dev/null +++ b/packages/rich-text/lib/test/join.test.ts @@ -0,0 +1,45 @@ +import deepFreeze from 'deep-freeze'; +import { describe, expect, it } from 'vitest' +import { join } from '../join'; +import { getSparseArrayLength } from './helpers'; + +describe( 'join', () => { + const em = { type: 'em' }; + const separators = [ + ' ', + { + formats: [ , ], + replacements: [ , ], + text: ' ', + }, + ]; + + separators.forEach( ( separator ) => { + it( 'should join records with string separator', () => { + const one = { + formats: [ , , [ em ] ], + replacements: [ , , , ], + text: 'one', + }; + const two = { + formats: [ [ em ], , , ], + replacements: [ , , , ], + text: 'two', + }; + const three = { + formats: [ , , [ em ], , [ em ], , , ], + replacements: [ , , , , , , , ], + text: 'one two', + }; + const result = join( + [ deepFreeze( one ), deepFreeze( two ) ], + separator + ); + + expect( result ).not.toBe( one ); + expect( result ).not.toBe( two ); + expect( result ).toEqual( three ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); + } ); +} ); diff --git a/packages/rich-text/lib/test/normalise-formats.test.ts b/packages/rich-text/lib/test/normalise-formats.test.ts new file mode 100644 index 0000000..dfea544 --- /dev/null +++ b/packages/rich-text/lib/test/normalise-formats.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import deepFreeze from 'deep-freeze'; +import { normaliseFormats } from '../normalise-formats'; +import { getSparseArrayLength } from './helpers'; + +describe( 'normaliseFormats', () => { + const strong = { type: 'strong' }; + const em = { type: 'em' }; + + it( 'should normalise formats', () => { + const record = { + formats: [ + , + [ em ], + [ { ...em }, { ...strong } ], + [ em, strong ], + , + [ { ...em } ], + ], + text: 'one two three', + }; + const result = normaliseFormats( deepFreeze( record ) ); + + expect( result ).toEqual( record ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); + expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 2 ][ 0 ] ); + expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 3 ][ 0 ] ); + expect( result.formats[ 1 ][ 0 ] ).not.toBe( result.formats[ 5 ][ 0 ] ); + expect( result.formats[ 2 ][ 1 ] ).toBe( result.formats[ 3 ][ 1 ] ); + } ); +} ); diff --git a/packages/rich-text/lib/test/remove-format.test.ts b/packages/rich-text/lib/test/remove-format.test.ts new file mode 100644 index 0000000..0e8a8c0 --- /dev/null +++ b/packages/rich-text/lib/test/remove-format.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import deepFreeze from 'deep-freeze'; +import { removeFormat } from '../remove-format'; +import { getSparseArrayLength } from './helpers'; + +describe( 'removeFormat', () => { + const strong = { type: 'strong' }; + const em = { type: 'em' }; + + it( 'should remove format', () => { + const record = { + formats: [ + , + , + , + [ strong ], + [ em, strong ], + [ em, strong ], + [ em ], + , + , + , + , + , + , + ], + text: 'one two three', + }; + const expected = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + activeFormats: [], + text: 'one two three', + }; + const result = removeFormat( deepFreeze( record ), 'strong', 3, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + } ); + + it( 'should remove format for collased selection', () => { + const record = { + formats: [ + , + , + , + [ strong ], + [ em, strong ], + [ em, strong ], + [ em ], + , + , + , + , + , + , + ], + text: 'one two three', + }; + const expected = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + activeFormats: [], + text: 'one two three', + }; + const result = removeFormat( deepFreeze( record ), 'strong', 4, 4 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + } ); +} ); diff --git a/packages/rich-text/lib/test/replace.test.ts b/packages/rich-text/lib/test/replace.test.ts new file mode 100644 index 0000000..088f303 --- /dev/null +++ b/packages/rich-text/lib/test/replace.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import deepFreeze from 'deep-freeze'; +import { replace } from '../replace'; +import { getSparseArrayLength } from './helpers'; + +describe( 'replace', () => { + const em = { type: 'em' }; + + it( 'should replace string to string', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const expected = { + formats: [ , , , , [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , ], + text: 'one 2 three', + start: 5, + end: 5, + }; + const result = replace( deepFreeze( record ), 'two', '2' ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 1 ); + } ); + + it( 'should replace string to record', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const replacement = { + formats: [ , ], + replacements: [ , ], + text: '2', + }; + const expected = { + formats: [ , , , , , , , , , , , ], + replacements: [ , , , , , , , , , , , ], + text: 'one 2 three', + start: 5, + end: 5, + }; + const result = replace( deepFreeze( record ), 'two', replacement ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); + + it( 'should replace string to function', () => { + const record = { + formats: [ , , , , , , , , , , , , ], + replacements: [ , , , , , , , , , , , , ], + text: 'abc12345#$*%', + start: 6, + end: 6, + }; + const expected = { + formats: [ , , , , , , , , , , , , , , , , , , ], + replacements: [ , , , , , , , , , , , , , , , , , , ], + text: 'abc - 12345 - #$*%', + start: 18, + end: 18, + }; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace + const result = replace( + deepFreeze( record ), + /([^\d]*)(\d*)([^\w]*)/, + ( match, p1, p2, p3 ) => { + return [ p1, p2, p3 ].join( ' - ' ); + } + ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); +} ); diff --git a/packages/rich-text/lib/test/slice.test.ts b/packages/rich-text/lib/test/slice.test.ts new file mode 100644 index 0000000..7c02fa1 --- /dev/null +++ b/packages/rich-text/lib/test/slice.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import deepFreeze from 'deep-freeze'; +import { slice } from '../slice'; +import { getSparseArrayLength } from './helpers'; + +describe( 'slice', () => { + const em = { type: 'em' }; + + it( 'should slice', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + }; + const expected = { + formats: [ , [ em ], [ em ] ], + replacements: [ , , , ], + text: ' tw', + }; + const result = slice( deepFreeze( record ), 3, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); + + it( 'should slice record', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 3, + end: 6, + }; + const expected = { + formats: [ , [ em ], [ em ] ], + replacements: [ , , , ], + text: ' tw', + }; + const result = slice( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); +} ); diff --git a/packages/rich-text/lib/test/split.test.ts b/packages/rich-text/lib/test/split.test.ts new file mode 100644 index 0000000..119db9f --- /dev/null +++ b/packages/rich-text/lib/test/split.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest' +import deepFreeze from 'deep-freeze'; +import { split } from '../split'; +import { getSparseArrayLength } from './helpers'; + +describe( 'split', () => { + const em = { type: 'em' }; + + it( 'should split', () => { + const record = { + start: 5, + end: 10, + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + }; + const expected = [ + { + formats: [ , , , , [ em ], [ em ] ], + replacements: [ , , , , , , ], + text: 'one tw', + }, + { + start: 0, + end: 0, + formats: [ [ em ], , , , , , , ], + replacements: [ , , , , , , , ], + text: 'o three', + }, + ]; + const result = split( deepFreeze( record ), 6, 6 ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ).toBe( + getSparseArrayLength( expected[ index ].formats ) + ); + } ); + } ); + + it( 'should split with selection', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const expected = [ + { + formats: [ , , , , [ em ], [ em ] ], + replacements: [ , , , , , , ], + text: 'one tw', + }, + { + formats: [ [ em ], , , , , , , ], + replacements: [ , , , , , , , ], + text: 'o three', + start: 0, + end: 0, + }, + ]; + const result = split( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ).toBe( + getSparseArrayLength( expected[ index ].formats ) + ); + } ); + } ); + + it( 'should split empty', () => { + const record = { + formats: [], + replacements: [], + text: '', + start: 0, + end: 0, + }; + const expected = [ + { + formats: [], + replacements: [], + text: '', + }, + { + formats: [], + replacements: [], + text: '', + start: 0, + end: 0, + }, + ]; + const result = split( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ).toBe( + getSparseArrayLength( expected[ index ].formats ) + ); + } ); + } ); + + it( 'should split search', () => { + const record = { + start: 6, + end: 16, + formats: [ + , + , + , + , + [ em ], + [ em ], + [ em ], + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + ], + replacements: [ , , , , , , , , , , , , , , , , , , , , , , , ], + text: 'one two three four five', + }; + const expected = [ + { + formats: [ , , , ], + replacements: [ , , , ], + text: 'one', + }, + { + start: 2, + end: 3, + formats: [ [ em ], [ em ], [ em ] ], + replacements: [ , , , ], + text: 'two', + }, + { + start: 0, + end: 5, + formats: [ , , , , , ], + replacements: [ , , , , , ], + text: 'three', + }, + { + start: 0, + end: 2, + formats: [ , , , , ], + replacements: [ , , , , ], + text: 'four', + }, + { + formats: [ , , , , ], + replacements: [ , , , , ], + text: 'five', + }, + ]; + const result = split( deepFreeze( record ), ' ' ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ).toBe( + getSparseArrayLength( expected[ index ].formats ) + ); + } ); + } ); + + it( 'should split search 2', () => { + const record = { + start: 5, + end: 6, + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + replacements: [ , , , , , , , , , , , , , ], + text: 'one two three', + }; + const expected = [ + { + formats: [ , , , ], + replacements: [ , , , ], + text: 'one', + }, + { + start: 1, + end: 2, + formats: [ [ em ], [ em ], [ em ] ], + replacements: [ , , , ], + text: 'two', + }, + { + formats: [ , , , , , ], + replacements: [ , , , , , ], + text: 'three', + }, + ]; + const result = split( deepFreeze( record ), ' ' ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ).toBe( + getSparseArrayLength( expected[ index ].formats ) + ); + } ); + } ); + + it( 'should not split without selection', () => { + const record = { + formats: [], + replacements: [], + text: '', + }; + expect( split( deepFreeze( record ) ) ).toBe( undefined ); + } ); +} ); diff --git a/packages/rich-text/lib/test/to-dom.test.ts b/packages/rich-text/lib/test/to-dom.test.ts new file mode 100644 index 0000000..2a17b63 --- /dev/null +++ b/packages/rich-text/lib/test/to-dom.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, beforeAll } from 'vitest' +import { toDom, applyValue } from '../to-dom'; +import { createElement } from '../create-element'; +import { spec } from './helpers'; + +describe( 'recordToDom', () => { + beforeAll( () => { + // Initialize the rich-text store. + require( '../store' ); + } ); + + spec.forEach( ( { description, record, startPath, endPath } ) => { + // eslint-disable-next-line jest/valid-title + it( description, () => { + const { body, selection } = toDom( { + value: record, + } ); + expect( body ).toMatchSnapshot(); + expect( selection ).toEqual( { startPath, endPath } ); + } ); + } ); +} ); + +describe( 'applyValue', () => { + const cases = [ + { + current: 'test', + future: '', + movedCount: 0, + description: 'should remove nodes', + }, + { + current: '', + future: 'test', + movedCount: 1, + description: 'should add nodes', + }, + { + current: 'test', + future: 'test', + movedCount: 0, + description: 'should not modify', + }, + { + current: 'b', + future: 'b', + movedCount: 0, + description: 'should remove attribute', + }, + { + current: 'b', + future: 'b', + movedCount: 0, + description: 'should remove attributes', + }, + { + current: 'a', + future: 'c', + movedCount: 0, + description: 'should add attribute', + }, + { + current: 'a', + future: 'c', + movedCount: 0, + description: 'should add attributes', + }, + { + current: 'a', + future: 'a', + movedCount: 0, + description: 'should update attribute', + }, + { + current: 'a', + future: 'a', + movedCount: 0, + description: 'should update attributes', + }, + ]; + + cases.forEach( ( { current, future, description, movedCount } ) => { + // eslint-disable-next-line jest/valid-title + it( description, () => { + const body = createElement( document, current ).cloneNode( true ); + const futureBody = createElement( document, future ).cloneNode( + true + ); + const childNodes = Array.from( futureBody.childNodes ); + applyValue( futureBody, body ); + const count = childNodes.reduce( ( acc, { parentNode } ) => { + return parentNode === body ? acc + 1 : acc; + }, 0 ); + expect( body.innerHTML ).toEqual( future ); + expect( count ).toEqual( movedCount ); + } ); + } ); +} ); diff --git a/packages/rich-text/lib/test/to-html-string.test.ts b/packages/rich-text/lib/test/to-html-string.test.ts new file mode 100644 index 0000000..39df953 --- /dev/null +++ b/packages/rich-text/lib/test/to-html-string.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, beforeAll } from 'vitest' +import { useFormatTypes } from '../use-format-types'; +import { create } from '../create'; +import { toHTMLString } from '../to-html-string'; +import { withSetup, specWithRegistration } from './helpers'; + +function createNode( HTML ) { + const doc = document.implementation.createHTMLDocument( '' ); + doc.body.innerHTML = HTML; + return doc.body.firstChild; +} + +describe( 'toHTMLString', () => { + beforeAll( () => { + useFormatTypes(); + const { addFormatTypes, removeFormatTypes } = withSetup(() => useFormatTypes()); + + specWithRegistration.forEach( + ( { + description, + formatName, + formatType, + html, + value, + noToHTMLString, + } ) => { + if ( noToHTMLString ) { + return; + } + + // eslint-disable-next-line jest/valid-title + it( description, () => { + if ( formatName ) { + addFormatTypes( { name: formatName, ...formatType } ); + } + + const result = toHTMLString( { value } ); + + if ( formatName ) { + removeFormatTypes( formatName ); + } + + expect( result ).toEqual( html ); + } ); + } + ); + }); + + it( 'should extract recreate HTML 1', () => { + const HTML = + 'one two ๐Ÿ’ three'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( + HTML + ); + } ); + + it( 'should extract recreate HTML 2', () => { + const HTML = + 'one two ๐Ÿ’ test three'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( + HTML + ); + } ); + + it( 'should extract recreate HTML 3', () => { + const HTML = ''; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( + HTML + ); + } ); + + it( 'should extract recreate HTML 4', () => { + const HTML = 'two ๐Ÿ’'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( + HTML + ); + } ); + + it( 'should extract recreate HTML 5', () => { + const HTML = + 'If you want to learn more about how to build additional blocks, or if you are interested in helping with the project, head over to the GitHub repository.'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( + HTML + ); + } ); + + it( 'should serialize neighbouring formats of same type', () => { + const HTML = 'aa'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( + HTML + ); + } ); + + it( 'should serialize neighbouring same formats', () => { + const HTML = 'aa'; + const element = createNode( `

${ HTML }

` ); + + expect( toHTMLString( { value: create( { element } ) } ) ).toEqual( + HTML + ); + } ); +} ); diff --git a/packages/rich-text/lib/test/toggle-format.test.ts b/packages/rich-text/lib/test/toggle-format.test.ts new file mode 100644 index 0000000..3282b6e --- /dev/null +++ b/packages/rich-text/lib/test/toggle-format.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import deepFreeze from 'deep-freeze'; +import { toggleFormat } from '../toggle-format'; +import { getSparseArrayLength } from './helpers'; + +describe( 'toggleFormat', () => { + const strong = { type: 'strong' }; + const em = { type: 'em' }; + + it( 'should remove format if it is active', () => { + const record = { + formats: [ + , + , + , + // In reality, formats at a different index are never the same + // value. Only formats that create the same tag are the same + // value. + [ { type: 'strong' } ], + [ em, strong ], + [ em, strong ], + [ em ], + , + , + , + , + , + , + ], + text: 'one two three', + start: 3, + end: 6, + }; + const expected = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + activeFormats: [], + text: 'one two three', + start: 3, + end: 6, + }; + const result = toggleFormat( deepFreeze( record ), strong ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + } ); + + it( "should apply format if it doesn't exist at start of selection", () => { + const record = { + formats: [ , , , , [ em, strong ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + start: 3, + end: 6, + }; + const expected = { + formats: [ + , + , + , + [ strong ], + [ strong, em ], + [ strong, em ], + [ em ], + , + , + , + , + , + , + ], + activeFormats: [ strong ], + text: 'one two three', + start: 3, + end: 6, + }; + const result = toggleFormat( deepFreeze( record ), strong ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); + } ); +} ); diff --git a/packages/rich-text/lib/test/update-formats.test.ts b/packages/rich-text/lib/test/update-formats.test.ts new file mode 100644 index 0000000..7e8e5c7 --- /dev/null +++ b/packages/rich-text/lib/test/update-formats.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { updateFormats } from '../update-formats'; +import { getSparseArrayLength } from './helpers'; + +describe( 'updateFormats', () => { + const em = { type: 'em' }; + + it( 'should update formats with empty array', () => { + const value = { + formats: [ [ em ] ], + text: '1', + }; + const expected = { + ...value, + activeFormats: [], + formats: [ , ], + }; + const result = updateFormats( { + value, + start: 0, + end: 1, + formats: [], + } ); + + expect( result ).toEqual( expected ); + expect( result ).toBe( value ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); + + it( 'should update formats and update references', () => { + const value = { + formats: [ [ em ], , ], + text: '123', + }; + const expected = { + ...value, + activeFormats: [ em ], + formats: [ [ em ], [ em ] ], + }; + const result = updateFormats( { + value, + start: 1, + end: 2, + formats: [ { ...em } ], + } ); + + expect( result ).toEqual( expected ); + expect( result ).toBe( value ); + expect( result.formats[ 1 ][ 0 ] ).toBe( em ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); +} ); diff --git a/packages/rich-text/lib/to-dom.ts b/packages/rich-text/lib/to-dom.ts new file mode 100644 index 0000000..a415a05 --- /dev/null +++ b/packages/rich-text/lib/to-dom.ts @@ -0,0 +1,313 @@ +/** + * Internal dependencies + */ + +import { toTree } from './to-tree'; +import { createElement } from './create-element'; +import { isRangeEqual } from './is-range-equal'; +import { RichTextValue } from './types'; + +/** @typedef {import('./types').RichTextValue} RichTextValue */ + +/** + * Creates a path as an array of indices from the given root node to the given + * node. + * + * @param {Node} node Node to find the path of. + * @param {HTMLElement} rootNode Root node to find the path from. + * @param {Array} path Initial path to build on. + * + * @return {Array} The path from the root node to the node. + */ +function createPathToNode( node: Node, rootNode: HTMLElement, path: number[] ) { + let workingNode: Node|null = node; + const parentNode = node.parentNode!; + let i = 0; + + while ( ( workingNode = workingNode.previousSibling ) ) { + i++; + } + + path = [ i, ...path ]; + + if ( parentNode !== rootNode ) { + path = createPathToNode( parentNode, rootNode, path ); + } + + return path; +} + +/** + * Gets a node given a path (array of indices) from the given node. + * + * @param {HTMLElement} node Root node to find the wanted node in. + * @param {Array} path Path (indices) to the wanted node. + * + * @return {Object} Object with the found node and the remaining offset (if any). + */ +function getNodeByPath( node: HTMLElement, path: number[] ) { + let workingNode: Node = node; + path = [ ...path ]; + + while ( workingNode && path.length > 1 ) { + workingNode = workingNode.childNodes[ (path.shift() || 0) ]; + } + + return { + node, + offset: path[ 0 ], + }; +} + +function append( element: HTMLElement, child) { + if ( child.html !== undefined ) { + return ( element.innerHTML += child.html ); + } + + if ( typeof child === 'string' ) { + child = element.ownerDocument.createTextNode( child ); + } + + const { type, attributes } = child; + + if ( type ) { + child = element.ownerDocument.createElement( type ); + + for ( const key in attributes ) { + child.setAttribute( key, attributes[ key ] ); + } + } + + return element.appendChild( child ); +} + +function appendText( node, text ) { + node.appendData( text ); +} + +function getLastChild( { lastChild } ) { + return lastChild; +} + +function getParent( { parentNode } ) { + return parentNode; +} + +function isText( node ) { + return node.nodeType === node.TEXT_NODE; +} + +function getText( { nodeValue } ) { + return nodeValue; +} + +function remove( node ) { + return node.parentNode.removeChild( node ); +} + +export function toDom({ + value, + prepareEditableTree, + isEditableTree = true, + placeholder, + doc = document, +}: { + value: RichTextValue, + prepareEditableTree: Function, + isEditableTree?: boolean, + placeholder?: string, + doc?: Document, +}) { + let startPath: number[] = []; + let endPath: number[] = []; + + if ( prepareEditableTree ) { + value = { + ...value, + formats: prepareEditableTree( value ), + }; + } + + /** + * Returns a new instance of a DOM tree upon which RichText operations can be + * applied. + * + * Note: The current implementation will return a shared reference, reset on + * each call to `createEmpty`. Therefore, you should not hold a reference to + * the value to operate upon asynchronously, as it may have unexpected results. + * + * @return {Object} RichText tree. + */ + const createEmpty = () => createElement( doc, '' ); + + const tree = toTree( { + value, + createEmpty, + append, + getLastChild, + getParent, + isText, + getText, + remove, + appendText, + onStartIndex( body, pointer ) { + startPath = createPathToNode( pointer, body, [ + pointer.nodeValue.length, + ] ); + }, + onEndIndex( body, pointer ) { + endPath = createPathToNode( pointer, body, [ + pointer.nodeValue.length, + ] ); + }, + isEditableTree, + placeholder, + } ); + + return { + body: tree, + selection: { startPath, endPath }, + }; +} + +/** + * Create an `Element` tree from a Rich Text value and applies the difference to + * the `Element` tree contained by `current`. + * + * @param {Object} $1 Named arguments. + * @param {RichTextValue} $1.value Value to apply. + * @param {HTMLElement} $1.current The live root node to apply the element tree to. + * @param {Function} [$1.prepareEditableTree] Function to filter editorable formats. + * @param {boolean} [$1.__unstableDomOnly] Only apply elements, no selection. + * @param {string} [$1.placeholder] Placeholder text. + */ +export function apply( { + value, + current, + prepareEditableTree, + placeholder, +}: { + value: RichTextValue, + current: HTMLElement, + prepareEditableTree: Function, + placeholder: string, +}) { + // Construct a new element tree in memory. + const { body, selection } = toDom( { + value, + prepareEditableTree, + placeholder, + doc: current.ownerDocument, + } ); + + applyValue( body, current ); + + if ( value.start !== undefined) { + applySelection( selection, current ); + } +} + +export function applyValue( future: HTMLElement, current: HTMLElement ) { + let i = 0; + let futureChild: HTMLElement|null = null; + + while ( ( futureChild = future.firstChild as HTMLElement ) ) { + const currentChild = current.childNodes[ i ] as HTMLElement; + + if ( ! currentChild ) { + current.appendChild( futureChild ); + } else if ( ! currentChild.isEqualNode( futureChild ) ) { + if ( + currentChild.nodeName !== futureChild.nodeName || + ( currentChild.nodeType === currentChild.TEXT_NODE && + currentChild.data !== futureChild.data ) + ) { + current.replaceChild( futureChild, currentChild ); + } else { + const currentAttributes = currentChild.attributes; + const futureAttributes = futureChild.attributes; + + if ( currentAttributes ) { + let ii = currentAttributes.length; + + // Reverse loop because `removeAttribute` on `currentChild` + // changes `currentAttributes`. + while ( ii-- ) { + const { name } = currentAttributes[ ii ]; + + if ( ! futureChild.getAttribute( name ) ) { + currentChild.removeAttribute( name ); + } + } + } + + if ( futureAttributes ) { + for ( let ii = 0; ii < futureAttributes.length; ii++ ) { + const { name, value } = futureAttributes[ ii ]; + + if ( currentChild.getAttribute( name ) !== value ) { + currentChild.setAttribute( name, value ); + } + } + } + + applyValue( futureChild, currentChild ); + future.removeChild( futureChild ); + } + } else { + future.removeChild( futureChild ); + } + + i++; + } + + while ( current.childNodes[ i ] ) { + current.removeChild( current.childNodes[ i ] ); + } +} + +export function applySelection( { startPath, endPath }: { startPath: number[], endPath: number[] }, current: HTMLElement ) { + const { node: startContainer, offset: startOffset } = getNodeByPath( + current, + startPath + ); + const { node: endContainer, offset: endOffset } = getNodeByPath( + current, + endPath + ); + const { ownerDocument } = current; + const { defaultView } = ownerDocument; + const selection = (defaultView as Window).getSelection() as Selection; + const range = ownerDocument.createRange(); + + range.setStart( startContainer, startOffset ); + range.setEnd( endContainer, endOffset ); + + const { activeElement } = ownerDocument; + + if ( selection.rangeCount > 0 ) { + // If the to be added range and the live range are the same, there's no + // need to remove the live range and add the equivalent range. + if ( isRangeEqual( range, selection.getRangeAt( 0 ) ) ) { + return; + } + + selection.removeAllRanges(); + } + + selection.addRange( range ); + + // This function is not intended to cause a shift in focus. Since the above + // selection manipulations may shift focus, ensure that focus is restored to + // its previous state. + if ( activeElement !== ownerDocument.activeElement ) { + // The `instanceof` checks protect against edge cases where the focused + // element is not of the interface HTMLElement (does not have a `focus` + // or `blur` property). + // + // See: https://github.com/Microsoft/TypeScript/issues/5901#issuecomment-431649653 + if ( activeElement instanceof HTMLElement ) { + activeElement.focus(); + } + } +} diff --git a/packages/rich-text/lib/to-html-string.ts b/packages/rich-text/lib/to-html-string.ts new file mode 100644 index 0000000..7051278 --- /dev/null +++ b/packages/rich-text/lib/to-html-string.ts @@ -0,0 +1,124 @@ +/** + * WordPress dependencies + */ + +import { + escapeEditableHTML, + escapeAttribute, + isValidAttributeName, +} from '@wordpress/escape-html'; + +/** + * Internal dependencies + */ + +import { toTree } from './to-tree'; + +/** @typedef {import('./types').RichTextValue} RichTextValue */ + +/** + * Create an HTML string from a Rich Text value. + * + * @param {Object} $1 Named arguments. + * @param {RichTextValue} $1.value Rich text value. + * @param {boolean} [$1.preserveWhiteSpace] Preserves newlines if true. + * + * @return {string} HTML string. + */ +export function toHTMLString( { value, preserveWhiteSpace }: { value: RichTextValue, preserveWhiteSpace?: boolean } ): string { + const tree = toTree({ + value, + preserveWhiteSpace, + createEmpty, + append, + getLastChild, + getParent, + isText, + getText, + remove, + appendText, + }); + + return createChildrenHTML( tree.children ); +} + +function createEmpty() { + return {}; +} + +function getLastChild( { children } ) { + return children && children[ children.length - 1 ]; +} + +function append( parent, object ) { + if ( typeof object === 'string' ) { + object = { text: object }; + } + + object.parent = parent; + parent.children = parent.children || []; + parent.children.push( object ); + return object; +} + +function appendText( object, text ) { + object.text += text; +} + +function getParent( { parent } ) { + return parent; +} + +function isText( { text } ) { + return typeof text === 'string'; +} + +function getText( { text } ) { + return text; +} + +function remove( object ) { + const index = object.parent.children.indexOf( object ); + + if ( index !== -1 ) { + object.parent.children.splice( index, 1 ); + } + + return object; +} + +function createElementHTML( { type, attributes, object, children } ) { + let attributeString = ''; + + for ( const key in attributes ) { + if ( ! isValidAttributeName( key ) ) { + continue; + } + + attributeString += ` ${ key }="${ escapeAttribute( + attributes[ key ] + ) }"`; + } + + if ( object ) { + return `<${ type }${ attributeString }>`; + } + + return `<${ type }${ attributeString }>${ createChildrenHTML( + children + ) }`; +} + +function createChildrenHTML( children = [] ) { + return children + .map( ( child ) => { + if ( child.html !== undefined ) { + return child.html; + } + + return child.text === undefined + ? createElementHTML( child ) + : escapeEditableHTML( child.text ); + } ) + .join( '' ); +} diff --git a/packages/rich-text/lib/to-tree.ts b/packages/rich-text/lib/to-tree.ts new file mode 100644 index 0000000..163ed9f --- /dev/null +++ b/packages/rich-text/lib/to-tree.ts @@ -0,0 +1,343 @@ +import { useFormatTypes } from './use-format-types'; +import { getActiveFormats } from './get-active-formats'; +import { OBJECT_REPLACEMENT_CHARACTER, ZWNBSP } from './special-characters'; +import { RichTextValue } from './types'; + +function restoreOnAttributes( attributes: Record, isEditableTree: boolean ): Record { + if ( isEditableTree ) { + return attributes; + } + + const newAttributes: Record = {}; + + for ( const key in attributes ) { + let newKey = key; + if ( key.startsWith( 'data-disable-rich-text-' ) ) { + newKey = key.slice( 'data-disable-rich-text-'.length ); + } + + newAttributes[ newKey ] = attributes[ key ]; + } + + return newAttributes; +} + +/** + * Converts a format object to information that can be used to create an element + * from (type, attributes and object). + * + * @param {Object} $1 Named parameters. + * @param {string} $1.type The format type. + * @param {string} $1.tagName The tag name. + * @param {Object} $1.attributes The format attributes. + * @param {Object} $1.unregisteredAttributes The unregistered format + * attributes. + * @param {boolean} $1.object Whether or not it is an object + * format. + * @param {boolean} $1.boundaryClass Whether or not to apply a boundary + * class. + * @param {boolean} $1.isEditableTree + * + * @return {Object} Information to be used for element creation. + */ +function fromFormat( { + type, + tagName, + attributes, + unregisteredAttributes, + object, + boundaryClass, + isEditableTree, +}: { + type: string, + tagName: string, + attributes: Record, + unregisteredAttributes: Record + object: boolean, + boundaryClass: boolean, + isEditableTree: boolean, +}) { + const { findFormatTypeByName } = useFormatTypes(); + const formatType = findFormatTypeByName(type); + + let elementAttributes: Record = {}; + + if ( boundaryClass && isEditableTree ) { + elementAttributes[ 'data-rich-text-format-boundary' ] = 'true'; + } + + if ( ! formatType ) { + if ( attributes ) { + elementAttributes = { ...attributes, ...elementAttributes }; + } + + return { + type, + attributes: restoreOnAttributes( + elementAttributes, + isEditableTree + ), + object, + }; + } + + elementAttributes = { ...unregisteredAttributes, ...elementAttributes }; + + for ( const name in attributes ) { + const key = formatType.attributes + ? formatType.attributes[ name ] + : false; + + if ( key ) { + elementAttributes[ key ] = attributes[ name ]; + } else { + elementAttributes[ name ] = attributes[ name ]; + } + } + + if ( formatType.className ) { + if ( elementAttributes.class ) { + elementAttributes.class = `${ formatType.className } ${ elementAttributes.class }`; + } else { + elementAttributes.class = formatType.className; + } + } + + // When a format is declared as non editable, make it non editable in the + // editor. + if ( isEditableTree && formatType.contentEditable === false ) { + elementAttributes.contenteditable = 'false'; + } + + return { + type: tagName || formatType.tagName, + object: formatType.object, + attributes: restoreOnAttributes( elementAttributes, isEditableTree ), + }; +} + +/** + * Checks if both arrays of formats up until a certain index are equal. + * + * @param {Array} a Array of formats to compare. + * @param {Array} b Array of formats to compare. + * @param {number} index Index to check until. + */ +function isEqualUntil( a: T[], b: T[], index: number ): boolean { + do { + if ( a[ index ] !== b[ index ] ) { + return false; + } + } while ( index-- ); + + return true; +} + +export function toTree( { + value, + preserveWhiteSpace, + createEmpty, + append, + getLastChild, + getParent, + isText, + getText, + remove, + appendText, + onStartIndex, + onEndIndex, + isEditableTree, + placeholder, +}: { + value: RichTextValue, + preserveWhiteSpace?: boolean, + createEmpty: Function, + append: Function, + getLastChild: Function, + getParent: Function, + isText: Function, + getText: Function, + remove: Function, + appendText: Function, + onStartIndex?: Function, + onEndIndex?: Function, + isEditableTree?: boolean, + placeholder?: string, +}) { + const { formats, replacements, text, start, end } = value; + const formatsLength = formats.length + 1; + const tree = createEmpty(); + const activeFormats = getActiveFormats( value ); + const deepestActiveFormat = activeFormats[ activeFormats.length - 1 ]; + + let lastCharacterFormats; + let lastCharacter; + + append( tree, '' ); + + for ( let i = 0; i < formatsLength; i++ ) { + const character = text.charAt( i ); + const shouldInsertPadding = + isEditableTree && + // Pad the line if the line is empty. + ( ! lastCharacter || + // Pad the line if the previous character is a line break, otherwise + // the line break won't be visible. + lastCharacter === '\n' ); + + const characterFormats = formats[ i ]; + let pointer = getLastChild( tree ); + + if ( characterFormats ) { + characterFormats.forEach( ( format, formatIndex ) => { + if ( + pointer && + lastCharacterFormats && + // Reuse the last element if all formats remain the same. + isEqualUntil( + characterFormats, + lastCharacterFormats, + formatIndex + ) + ) { + pointer = getLastChild( pointer ); + return; + } + + const { type, tagName, attributes, unregisteredAttributes } = + format; + + const boundaryClass = + isEditableTree && format === deepestActiveFormat; + + const parent = getParent( pointer ); + const newNode = append( + parent, + fromFormat( { + type, + tagName, + attributes, + unregisteredAttributes, + boundaryClass, + isEditableTree, + } ) + ); + + if ( isText( pointer ) && getText( pointer ).length === 0 ) { + remove( pointer ); + } + + pointer = append( newNode, '' ); + } ); + } + + // If there is selection at 0, handle it before characters are inserted. + if ( i === 0 ) { + if ( onStartIndex && start === 0 ) { + onStartIndex( tree, pointer ); + } + + if ( onEndIndex && end === 0 ) { + onEndIndex( tree, pointer ); + } + } + + if ( character === OBJECT_REPLACEMENT_CHARACTER ) { + const replacement = replacements[ i ]; + if ( ! replacement ) { + continue; + } + const { type, attributes, innerHTML } = replacement; + const formatType = getFormatType( type ); + + if ( ! isEditableTree && type === 'script' ) { + pointer = append( + getParent( pointer ), + fromFormat( { + type: 'script', + isEditableTree, + } ) + ); + append( pointer, { + html: decodeURIComponent( + attributes[ 'data-rich-text-script' ] + ), + } ); + } else if ( formatType?.contentEditable === false ) { + // For non editable formats, render the stored inner HTML. + pointer = append( + getParent( pointer ), + fromFormat( { + ...replacement, + isEditableTree, + boundaryClass: start === i && end === i + 1, + } ) + ); + + if ( innerHTML ) { + append( pointer, { + html: innerHTML, + } ); + } + } else { + pointer = append( + getParent( pointer ), + fromFormat( { + ...replacement, + object: true, + isEditableTree, + } ) + ); + } + // Ensure pointer is text node. + pointer = append( getParent( pointer ), '' ); + } else if ( ! preserveWhiteSpace && character === '\n' ) { + pointer = append( getParent( pointer ), { + type: 'br', + attributes: isEditableTree + ? { + 'data-rich-text-line-break': 'true', + } + : undefined, + object: true, + } ); + // Ensure pointer is text node. + pointer = append( getParent( pointer ), '' ); + } else if ( ! isText( pointer ) ) { + pointer = append( getParent( pointer ), character ); + } else { + appendText( pointer, character ); + } + + if ( onStartIndex && start === i + 1 ) { + onStartIndex( tree, pointer ); + } + + if ( onEndIndex && end === i + 1 ) { + onEndIndex( tree, pointer ); + } + + if ( shouldInsertPadding && i === text.length ) { + append( getParent( pointer ), ZWNBSP ); + + // We CANNOT use CSS to add a placeholder with pseudo elements on + // the main block wrappers because that could clash with theme CSS. + if ( placeholder && text.length === 0 ) { + append( getParent( pointer ), { + type: 'span', + attributes: { + 'data-rich-text-placeholder': placeholder, + // Necessary to prevent the placeholder from catching + // selection and being editable. + style: 'pointer-events:none;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;', + }, + } ); + } + } + + lastCharacterFormats = characterFormats; + lastCharacter = character; + } + + return tree; +} diff --git a/packages/rich-text/lib/toggle-format.ts b/packages/rich-text/lib/toggle-format.ts new file mode 100644 index 0000000..c73a48f --- /dev/null +++ b/packages/rich-text/lib/toggle-format.ts @@ -0,0 +1,29 @@ +import { RichTextValue, RichTextFormat } from './types'; +import { getActiveFormat } from './get-active-format'; +import { removeFormat } from './remove-format'; +import { applyFormat } from './apply-format'; + +/** + * Toggles a format object to a Rich Text value at the current selection. + * + * @param {RichTextValue} value Value to modify. + * @param {RichTextFormat} format Format to apply or remove. + * + * @return {RichTextValue} A new value with the format applied or removed. + */ +export function toggleFormat( value: RichTextValue, format: RichTextFormat ) { + if ( getActiveFormat( value, format.type ) ) { + // For screen readers, will announce if formatting control is disabled. + if ( format.title ) { + // translators: %s: title of the formatting control + // speak( sprintf( __( '%s removed.' ), format.title ), 'assertive' ); + } + return removeFormat( value, format.type ); + } + // For screen readers, will announce if formatting control is enabled. + if ( format.title ) { + // translators: %s: title of the formatting control + // speak( sprintf( __( '%s applied.' ), format.title ), 'assertive' ); + } + return applyFormat( value, format ); +} diff --git a/packages/rich-text/lib/types.ts b/packages/rich-text/lib/types.ts new file mode 100644 index 0000000..719cb27 --- /dev/null +++ b/packages/rich-text/lib/types.ts @@ -0,0 +1,74 @@ +/** + * Stores the type of a rich text format, such as core/bold. + */ +// export type RichTextFormat = { +// type: +// | 'core/bold' +// | 'core/italic' +// | 'core/link ' +// | 'core/strikethrough' +// | 'core/image' +// | string; +// }; + +/** + * A list of rich text format types. + */ +export type RichTextFormatList = Array; + +/** + * An object which represents a formatted string. The text property contains the + * text to be formatted, and the formats property contains an array which indicates + * the formats that are applied to each character in the text. See the main + * `@wordpress/rich-text` documentation for more detail. + */ +export interface RichTextValue { + text: string; + formats: Array< RichTextFormatList >; + replacements: Array< RichTextFormatList>; + activeFormats?: RichTextFormatList; + start?: number; + end?: number; +} + +export interface RichTextFormatType { + // A string identifying the format. Must be unique across all registered formats. + name: string; + + // The HTML tag this format will wrap the selection with. + tagName: string; + + // Whether format makes content interactive or not. + interactive?: boolean; + + // Whether the content inside the format is editable + contentEditable?: boolean; + + // A class to match the format. + className?: string|null; + + // Name of the format. + title: string; + + // Should return a component for the user to interact with the new registered format. + edit: Function; + + // Extra attributes + attributes?: Record; +}; + +export interface RichTextFormat { + type: string; + tagName?: string; + title?: string; + formatType?: RichTextFormatType; + attributes?: Record; + unregisteredAttributes?: Record; +} + +export interface SimpleRange { + startOffset: number; + endOffset: number; + startContainer: Node; + endContainer: Node; +} diff --git a/packages/rich-text/lib/update-formats.ts b/packages/rich-text/lib/update-formats.ts new file mode 100644 index 0000000..668e1a7 --- /dev/null +++ b/packages/rich-text/lib/update-formats.ts @@ -0,0 +1,53 @@ +/** + * Internal dependencies + */ + +import { isFormatEqual } from './is-format-equal'; + +/** @typedef {import('./types').RichTextValue} RichTextValue */ + +/** + * Efficiently updates all the formats from `start` (including) until `end` + * (excluding) with the active formats. Mutates `value`. + * + * @param {Object} $1 Named paramentes. + * @param {RichTextValue} $1.value Value te update. + * @param {number} $1.start Index to update from. + * @param {number} $1.end Index to update until. + * @param {Array} $1.formats Replacement formats. + * + * @return {RichTextValue} Mutated value. + */ +export function updateFormats( { value, start, end, formats } ) { + // Start and end may be switched in case of delete. + const min = Math.min( start, end ); + const max = Math.max( start, end ); + const formatsBefore = value.formats[ min - 1 ] || []; + const formatsAfter = value.formats[ max ] || []; + + // First, fix the references. If any format right before or after are + // equal, the replacement format should use the same reference. + value.activeFormats = formats.map( ( format, index ) => { + if ( formatsBefore[ index ] ) { + if ( isFormatEqual( format, formatsBefore[ index ] ) ) { + return formatsBefore[ index ]; + } + } else if ( formatsAfter[ index ] ) { + if ( isFormatEqual( format, formatsAfter[ index ] ) ) { + return formatsAfter[ index ]; + } + } + + return format; + } ); + + while ( --end >= start ) { + if ( value.activeFormats.length > 0 ) { + value.formats[ end ] = value.activeFormats; + } else { + delete value.formats[ end ]; + } + } + + return value; +} diff --git a/packages/rich-text/lib/use-format-types.ts b/packages/rich-text/lib/use-format-types.ts new file mode 100644 index 0000000..ddb595f --- /dev/null +++ b/packages/rich-text/lib/use-format-types.ts @@ -0,0 +1,45 @@ +import { + Ref, + ref, + inject, +} from 'vue'; +import { RichTextFormatType } from './types'; + +export const SymFormatTypes = Symbol('Schlechtenburg rich text formats'); +export function useFormatTypes() { + const formatTypes: Ref = inject(SymFormatTypes, ref([])); + + const addFormatTypes = (typesToAdd: RichTextFormatType|RichTextFormatType[]) => { + formatTypes.value = [ + ...formatTypes.value, + ...(Array.isArray(typesToAdd) ? typesToAdd : [typesToAdd]), + ]; + }; + + const removeFormatTypes = (typesToRemove: string|string[]) => { + const isArray = Array.isArray(typesToRemove); + + formatTypes.value = formatTypes.value.filter(({ name }) => { + if (isArray) { + return !!typesToRemove.find((type) => type === name); + } else { + return name === typesToRemove; + } + }); + }; + + const findFormatType = (fn: (f:RichTextFormatType) => boolean) => formatTypes.value.find(type => fn(type)); + const findFormatTypeByName = (name: string) => formatTypes.value.find(type => type.name === name); + const getFormatTypeForClassName = (name: string) => formatTypes.value.find(type => type.className === name); + const getFormatTypeForBareElement = (name: string) => formatTypes.value.find(type => type.tagName === name); + + return { + formatTypes, + addFormatTypes, + removeFormatTypes, + findFormatTypeByName, + findFormatType, + getFormatTypeForClassName, + getFormatTypeForBareElement, + }; +} diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json new file mode 100644 index 0000000..e9516c1 --- /dev/null +++ b/packages/rich-text/package.json @@ -0,0 +1,36 @@ +{ + "name": "@schlechtenburg/rich-text", + "version": "0.0.0", + "description": "> TODO: description", + "author": "Benjamin Bรคdorf ", + "homepage": "", + "license": "GPL-3.0-or-later", + "main": "lib/index.ts", + "directories": { + "doc": "docs", + "lib": "lib" + }, + "files": [ + "lib", + "docs" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git@git.b12f.io:b12f/schlechtenburg.git" + }, + "scripts": { + "typecheck": "vuedx-typecheck --no-pretty ./lib", + "test": "echo \"Error: run tests from root\" && exit 1" + }, + "dependencies": { + "@wordpress/escape-html": "^3.9.0" + }, + "devDependencies": { + "@vuedx/typecheck": "^0.6.3", + "@vuedx/typescript-plugin-vue": "^0.6.3", + "vue": "^3.2.31" + } +} diff --git a/packages/standalone/__tests__/core.test.js b/packages/standalone/__tests__/core.test.js deleted file mode 100644 index 1166901..0000000 --- a/packages/standalone/__tests__/core.test.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -const core = require('..'); - -describe('@schlechtenburg/core', () => { - it('needs tests'); -}); diff --git a/packages/core/__tests__/core.test.js b/packages/standalone/__tests__/core.test.ts similarity index 100% rename from packages/core/__tests__/core.test.js rename to packages/standalone/__tests__/core.test.ts diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 0000000..332867b --- /dev/null +++ b/test/index.ts @@ -0,0 +1 @@ +export * from './with-setup'; diff --git a/test/jsdom.ts b/test/jsdom.ts new file mode 100644 index 0000000..83b3fab --- /dev/null +++ b/test/jsdom.ts @@ -0,0 +1,8 @@ +import { JSDOM } from 'jsdom'; + +const dom = new JSDOM(``); + +export const window = dom.window; +export const document = dom.window.document; + +export default dom; diff --git a/test/with-setup.ts b/test/with-setup.ts new file mode 100644 index 0000000..b3e2cd9 --- /dev/null +++ b/test/with-setup.ts @@ -0,0 +1,17 @@ +import { mount } from '@vue/test-utils'; +import { defineComponent } from 'vue' + +export function withSetup(composable: () => T): T { + let result: T; + mount(defineComponent({ + setup() { + result = composable(); + // suppress missing template warning + return () => {} + } + })); + + // return the result and the app instance + // for testing provide/unmount + return result; +} diff --git a/tsconfig.json b/tsconfig.json index 7ce9a13..c679be2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,9 @@ ], "resolveJsonModule": true, "allowSyntheticDefaultImports": true, + "types": [ + "@vitest/browser/providers/playwright" + ], "paths": {} } } diff --git a/vitest-example/HelloWorld.test.ts b/vitest-example/HelloWorld.test.ts new file mode 100644 index 0000000..c034192 --- /dev/null +++ b/vitest-example/HelloWorld.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from 'vitest' +import { render } from 'vitest-browser-vue' +import HelloWorld from './HelloWorld.vue' + +test('renders name', async () => { + const { getByText } = render(HelloWorld, { + props: { name: 'Vitest' }, + }) + await expect.element(getByText('Hello Vitest!')).toBeInTheDocument() +}) diff --git a/vitest-example/HelloWorld.vue b/vitest-example/HelloWorld.vue new file mode 100644 index 0000000..b291898 --- /dev/null +++ b/vitest-example/HelloWorld.vue @@ -0,0 +1,11 @@ + + + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2128e64 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + test: { + browser: { + enabled: true, + name: 'firefox', + provider: 'playwright', + // https://playwright.dev + providerOptions: {}, + }, + }, +})