rich-text: init

This commit is contained in:
b12f 2024-10-08 09:15:26 +02:00
parent 63ba5859ff
commit e55202bc67
Signed by: b12f
GPG key ID: 729956E1124F8F26
87 changed files with 8911 additions and 52 deletions

View file

@ -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

View file

@ -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"
}

View file

@ -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";
@ -21,6 +21,13 @@
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"; }
];
};
};

1835
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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": {

View file

@ -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();
});
});

View file

@ -0,0 +1 @@
package-lock=false

View file

@ -0,0 +1,251 @@
<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. -->
## 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.

View file

@ -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
<!-- START TOKEN(Autogenerated API docs) -->
### 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<RichTextValue>`: 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(
'<em>hello</em>' )`.
- 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<RichTextValue>|undefined`: An array of new values.
### store
Store definition for the rich-text namespace.
_Related_
- <https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore>
_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<HTMLElement>`: 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.
<!-- END TOKEN(Autogenerated API docs) -->
## 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).
<br /><br /><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p>

View file

@ -0,0 +1,107 @@
import { normaliseFormats } from './normalise-formats';
import { RichTextValue, RichTextFormat } from './types';
function replace<T>( 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,
],
} );
}

View file

@ -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 );
};
};

View file

@ -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 );
};
};

View file

@ -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 );
};
};

View file

@ -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 ]
);
}

View file

@ -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 );
};
};

View file

@ -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 );
};
};

View file

@ -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 );
};
};

View file

@ -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() {}

View file

@ -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‸<strong>b</strong>` (DOM) while visually it's `a<strong>‸b</strong>`.
// 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 `<mark>` element.
// When we _remove_ the color, it switches from a `<mark>` 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;
}

View file

@ -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;
}

View file

@ -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;
}, [] );
}

View file

@ -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() ) );
}

View file

@ -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;
}

View file

@ -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<string,any> } ): 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<string, any> = {};
const unregisteredAttributes: Record<string, any> = {};
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<string, any>{
let accumulator: Record<string, any> = {};
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;
}

View file

@ -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
);
}

View file

@ -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;
}

View file

@ -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 ];
}

View file

@ -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, '' );
}

View file

@ -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';

View file

@ -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 );
}

View file

@ -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,
} );
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 )
);
}

View file

@ -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<RichTextValue>} 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,
} ) )
);
}

View file

@ -0,0 +1,5 @@
import { RichTextValue } from './types';
export function length( a: RichTextValue ): number {
return a.text.length;
}

View file

@ -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,
};
}

View file

@ -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 ];
}
}

View file

@ -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 );
}

View file

@ -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<RichTextFormatList>;
let newReplacements: Array<RichTextFormatList>;
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 } );
}

View file

@ -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 ),
};
}

View file

@ -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';

View file

@ -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<RichTextValue>|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 ];
}

View file

@ -0,0 +1,303 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`recordToDom should create a value with formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
test
</em>
</body>
`;
exports[`recordToDom should create a value with formatting for split tags 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
test
</em>
</body>
`;
exports[`recordToDom should create a value with formatting with attributes 1`] = `
<body>
<a
data-rich-text-format-boundary="true"
href="#"
>
test
</a>
</body>
`;
exports[`recordToDom should create a value with image object 1`] = `
<body>
<img
src=""
/>
</body>
`;
exports[`recordToDom should create a value with image object and formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
<img
src=""
/>
</em>
</body>
`;
exports[`recordToDom should create a value with image object and text after 1`] = `
<body>
<em>
<img
src=""
/>
te
</em>
st
</body>
`;
exports[`recordToDom should create a value with image object and text before 1`] = `
<body>
te
<em>
st
<img
src=""
/>
</em>
</body>
`;
exports[`recordToDom should create a value with nested formatting 1`] = `
<body>
<em>
<strong
data-rich-text-format-boundary="true"
>
test
</strong>
</em>
</body>
`;
exports[`recordToDom should create a value without formatting 1`] = `
<body>
test
</body>
`;
exports[`recordToDom should create an empty value 1`] = `
<body>

</body>
`;
exports[`recordToDom should create an empty value from empty tags 1`] = `
<body>

</body>
`;
exports[`recordToDom should disarm on* attribute 1`] = `
<body>
<img
data-disable-rich-text-onerror="alert('1')"
/>
</body>
`;
exports[`recordToDom should disarm script 1`] = `
<body>
<script
data-rich-text-script="alert(%221%22)"
/>
</body>
`;
exports[`recordToDom should filter format boundary attributes 1`] = `
<body>
<strong
data-rich-text-format-boundary="true"
>
test
</strong>
</body>
`;
exports[`recordToDom should handle br 1`] = `
<body>
<br
data-rich-text-line-break="true"
/>

</body>
`;
exports[`recordToDom should handle br with formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
<br
data-rich-text-line-break="true"
/>
</em>

</body>
`;
exports[`recordToDom should handle br with text 1`] = `
<body>
te
<br
data-rich-text-line-break="true"
/>
st
</body>
`;
exports[`recordToDom should handle double br 1`] = `
<body>
a
<br
data-rich-text-line-break="true"
/>
<br
data-rich-text-line-break="true"
/>
b
</body>
`;
exports[`recordToDom should handle selection before br 1`] = `
<body>
a
<br
data-rich-text-line-break="true"
/>
<br
data-rich-text-line-break="true"
/>
b
</body>
`;
exports[`recordToDom should ignore manually added object replacement character 1`] = `
<body>
test
</body>
`;
exports[`recordToDom should ignore manually added object replacement character with formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
hi
</em>
</body>
`;
exports[`recordToDom should not error with overlapping formats (1) 1`] = `
<body>
<a
href="#"
>
<em>
1
</em>
<strong
data-rich-text-format-boundary="true"
>
2
</strong>
</a>
</body>
`;
exports[`recordToDom should not error with overlapping formats (2) 1`] = `
<body>
<em>
<a
data-rich-text-format-boundary="true"
href="#"
>
1
</a>
</em>
<strong>
<a
data-rich-text-format-boundary="true"
href="#"
>
2
</a>
</strong>
</body>
`;
exports[`recordToDom should preserve emoji 1`] = `
<body>
🍒
</body>
`;
exports[`recordToDom should preserve emoji in formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
🍒
</em>
</body>
`;
exports[`recordToDom should preserve non breaking space 1`] = `
<body>
test  test
</body>
`;
exports[`recordToDom should remove padding 1`] = `
<body>

</body>
`;

View file

@ -0,0 +1,265 @@
import deepFreeze from 'deep-freeze';
import { describe, expect, it } from 'vitest'
import { applyFormat } from '../apply-format';
import { getSparseArrayLength } from './helpers';
describe( 'applyFormat', () => {
const strong = { type: 'strong' };
const em = { type: 'em' };
const a = { type: 'a', attributes: { href: '#' } };
const a2 = { type: 'a', attributes: { href: '#test' } };
it( 'should apply format', () => {
const record = {
formats: [ , , , , ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ em ],
formats: [ [ em ], [ em ], [ em ], [ em ] ],
};
const result = applyFormat( deepFreeze( record ), em, 0, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format on top of existing format', () => {
const record = {
formats: [ [ strong ], [ strong ], [ strong ], [ strong ] ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ em ],
formats: [
[ strong, em ],
[ strong, em ],
[ strong, em ],
[ strong, em ],
],
};
const result = applyFormat( deepFreeze( record ), em, 0, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format and remove same format type', () => {
const record = {
formats: [ [ strong ], [ em, strong ], [ em, strong ], [ strong ] ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ em ],
formats: [
[ strong, em ],
[ strong, em ],
[ strong, em ],
[ strong, em ],
],
};
const result = applyFormat( deepFreeze( record ), em, 0, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format around existing format', () => {
const record = {
formats: [ , [ em ], [ em ], , ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ strong ],
formats: [ [ strong ], [ strong, em ], [ strong, em ], [ strong ] ],
};
const result = applyFormat( deepFreeze( record ), strong, 0, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format around existing format with edge right', () => {
const record = {
formats: [ , [ em ], [ em ], , ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ strong ],
formats: [ [ strong ], [ strong, em ], [ strong, em ], , ],
};
const result = applyFormat( deepFreeze( record ), strong, 0, 3 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should apply format around existing format with edge left', () => {
const record = {
formats: [ , [ em ], [ em ], , ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ strong ],
formats: [ , [ strong, em ], [ strong, em ], [ strong ] ],
};
const result = applyFormat( deepFreeze( record ), strong, 1, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should apply format around existing format with break', () => {
const record = {
formats: [ , [ em ], , [ em ] ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ strong ],
formats: [ , [ strong, em ], [ strong ], [ strong, em ] ],
};
const result = applyFormat( deepFreeze( record ), strong, 1, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should apply format crossing existing format', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
text: 'one two three',
};
const expected = {
activeFormats: [ strong ],
formats: [
,
,
,
[ strong ],
[ strong, em ],
[ strong, em ],
[ em ],
,
,
,
,
,
,
],
text: 'one two three',
};
const result = applyFormat( deepFreeze( record ), strong, 3, 6 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format by selection', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
text: 'one two three',
start: 3,
end: 6,
};
const expected = {
activeFormats: [ strong ],
formats: [
,
,
,
[ strong ],
[ strong, em ],
[ strong, em ],
[ em ],
,
,
,
,
,
,
],
text: 'one two three',
start: 3,
end: 6,
};
const result = applyFormat( deepFreeze( record ), strong );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format in placeholder if selection is collapsed', () => {
const record = {
formats: [ , , , , [ a ], [ a ], [ a ], , , , , , , ],
text: 'one two three',
start: 0,
end: 0,
};
const expected = {
...record,
activeFormats: [ a2 ],
};
const result = applyFormat( deepFreeze( record ), a2 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should apply format on existing format if selection is collapsed', () => {
const record = {
activeFormats: [ a ],
formats: [ , , , , [ a ], [ a ], [ a ], , , , , , , ],
text: 'one two three',
start: 4,
end: 4,
};
const expected = {
activeFormats: [ a2 ],
formats: [ , , , , [ a2 ], [ a2 ], [ a2 ], , , , , , , ],
text: 'one two three',
start: 4,
end: 4,
};
const result = applyFormat( deepFreeze( record ), a2 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should merge equal neighbouring formats', () => {
const record = {
// Use a different reference but equal content.
formats: [ , , [ { ...em } ], [ { ...em } ] ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ em ],
// All references should be the same.
formats: [ [ em ], [ em ], [ em ], [ em ] ],
};
const result = applyFormat( deepFreeze( record ), em, 0, 2 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
} );

View file

@ -0,0 +1,32 @@
import deepFreeze from 'deep-freeze';
import { describe, expect, it } from 'vitest'
import { concat } from '../concat';
import { getSparseArrayLength } from './helpers';
describe( 'concat', () => {
const em = { type: 'em' };
it( 'should merge records', () => {
const one = {
formats: [ , , [ em ] ],
replacements: [ , , , ],
text: 'one',
};
const two = {
formats: [ [ em ], , , ],
replacements: [ , , , ],
text: 'two',
};
const three = {
formats: [ , , [ em ], [ em ], , , ],
replacements: [ , , , , , , ],
text: 'onetwo',
};
const merged = concat( deepFreeze( one ), deepFreeze( two ) );
expect( merged ).not.toBe( one );
expect( merged ).toEqual( three );
expect( getSparseArrayLength( merged.formats ) ).toBe( 2 );
} );
} );

View file

@ -0,0 +1,122 @@
import { describe, expect, it, beforeAll } from 'vitest'
import { create, removeReservedCharacters } from '../create';
import { OBJECT_REPLACEMENT_CHARACTER, ZWNBSP } from '../special-characters';
import { createElement } from '../create-element';
import { getSparseArrayLength, spec, specWithRegistration } from './helpers';
describe( 'create', () => {
const em = { type: 'em' };
const strong = { type: 'strong' };
beforeAll( () => {
// Initialize the rich-text store.
require( '../store' );
} );
spec.forEach( ( { description, html, createRange, record } ) => {
if ( html === undefined ) {
return;
}
// eslint-disable-next-line jest/valid-title
it( description, () => {
const element = createElement( document, html );
const range = createRange( element );
const createdRecord = create( {
element,
range,
} );
const formatsLength = getSparseArrayLength( record.formats );
const createdFormatsLength = getSparseArrayLength(
createdRecord.formats
);
expect( createdRecord ).toEqual( record );
expect( createdFormatsLength ).toEqual( formatsLength );
} );
} );
specWithRegistration.forEach(
( {
description,
formatName,
formatType,
html,
value: expectedValue,
} ) => {
// eslint-disable-next-line jest/valid-title
it( description, () => {
if ( formatName ) {
registerFormatType( formatName, formatType );
}
const result = create( { html } );
if ( formatName ) {
unregisterFormatType( formatName );
}
expect( result ).toEqual( expectedValue );
} );
}
);
it( 'should reference formats', () => {
const value = create( { html: '<em>te<strong>st</strong></em>' } );
expect( value ).toEqual( {
formats: [ [ em ], [ em ], [ em, strong ], [ em, strong ] ],
replacements: [ , , , , ],
text: 'test',
} );
// Format objects.
expect( value.formats[ 0 ][ 0 ] ).toBe( value.formats[ 1 ][ 0 ] );
expect( value.formats[ 0 ][ 0 ] ).toBe( value.formats[ 2 ][ 0 ] );
expect( value.formats[ 2 ][ 1 ] ).toBe( value.formats[ 3 ][ 1 ] );
// Format arrays per index.
expect( value.formats[ 0 ] ).toBe( value.formats[ 1 ] );
expect( value.formats[ 2 ] ).toBe( value.formats[ 3 ] );
} );
it( 'should use different reference for equal format', () => {
const value = create( { html: '<a href="#">a</a><a href="#">a</a>' } );
// Format objects.
expect( value.formats[ 0 ][ 0 ] ).not.toBe( value.formats[ 1 ][ 0 ] );
// Format arrays per index.
expect( value.formats[ 0 ] ).not.toBe( value.formats[ 1 ] );
} );
it( 'should use different reference for different format', () => {
const value = create( { html: '<a href="#">a</a><a href="#a">a</a>' } );
// Format objects.
expect( value.formats[ 0 ][ 0 ] ).not.toBe( value.formats[ 1 ][ 0 ] );
// Format arrays per index.
expect( value.formats[ 0 ] ).not.toBe( value.formats[ 1 ] );
} );
it( 'removeReservedCharacters should remove all reserved characters', () => {
expect(
removeReservedCharacters( `${ OBJECT_REPLACEMENT_CHARACTER }` )
).toEqual( '' );
expect( removeReservedCharacters( `${ ZWNBSP }` ) ).toEqual( '' );
expect(
removeReservedCharacters(
`${ OBJECT_REPLACEMENT_CHARACTER }c${ OBJECT_REPLACEMENT_CHARACTER }at${ OBJECT_REPLACEMENT_CHARACTER }`
)
).toEqual( 'cat' );
expect(
removeReservedCharacters( `${ ZWNBSP }b${ ZWNBSP }at${ ZWNBSP }` )
).toEqual( 'bat' );
expect(
removeReservedCharacters(
`te${ OBJECT_REPLACEMENT_CHARACTER }st${ ZWNBSP }${ ZWNBSP }`
)
).toEqual( 'test' );
} );
} );

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { getActiveFormat } from '../get-active-format';
describe( 'getActiveFormat', () => {
const em = { type: 'em' };
const strong = { type: 'strong' };
it( 'should return undefined if there is no selection', () => {
const record = {
formats: [ [ em ], [ em ], [ em ] ],
text: 'one',
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
it( 'should return format when active over whole selection', () => {
const record = {
formats: [ [ em ], [ strong ], , ],
text: 'one',
start: 0,
end: 1,
};
expect( getActiveFormat( record, 'em' ) ).toBe( em );
} );
it( 'should return not return format when not active over whole selection', () => {
const record = {
formats: [ [ em ], [ strong ], , ],
text: 'one',
start: 0,
end: 2,
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
it( 'should return undefined if at the boundary before', () => {
const record = {
formats: [ [ em ], , [ em ] ],
text: 'one',
start: 3,
end: 3,
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
it( 'should return undefined if at the boundary after', () => {
const record = {
formats: [ [ em ], , [ em ] ],
text: 'one',
start: 1,
end: 1,
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
it( 'should return format if inside format', () => {
const record = {
formats: [ [ em ], [ em ], [ em ] ],
text: 'one',
start: 1,
end: 1,
};
expect( getActiveFormat( record, 'em' ) ).toBe( em );
} );
it( 'should return activeFormats', () => {
const record = {
formats: [ [ em ], , [ em ] ],
text: 'one',
start: 1,
end: 1,
activeFormats: [ em ],
};
expect( getActiveFormat( record, 'em' ) ).toBe( em );
} );
it( 'should not return activeFormats for uncollapsed selection', () => {
const record = {
formats: [ [ em ], , [ em ] ],
text: 'one',
start: 1,
end: 2,
activeFormats: [ em ],
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
} );

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { getActiveObject } from '../get-active-object';
import { OBJECT_REPLACEMENT_CHARACTER } from '../special-characters';
describe( 'getActiveObject', () => {
it( 'should return object if selected', () => {
const record = {
replacements: [ { type: 'img' } ],
text: OBJECT_REPLACEMENT_CHARACTER,
start: 0,
end: 1,
};
expect( getActiveObject( record ) ).toEqual( { type: 'img' } );
} );
it( 'should return nothing if nothing is selected', () => {
const record = {
replacements: [ { type: 'img' } ],
text: OBJECT_REPLACEMENT_CHARACTER,
start: 0,
end: 0,
};
expect( getActiveObject( record ) ).toBe( undefined );
} );
it( 'should return nothing if te selection is not an object', () => {
const record = {
replacements: [ { type: 'em' } ],
text: 'a',
start: 0,
end: 1,
};
expect( getActiveObject( record ) ).toBe( undefined );
} );
} );

View file

@ -0,0 +1,724 @@
import { ZWNBSP, OBJECT_REPLACEMENT_CHARACTER } from '../../special-characters';
export * from '../../../../../test';
export function getSparseArrayLength( array ) {
return array.reduce( ( accumulator ) => accumulator + 1, 0 );
}
const em = { type: 'em' };
const strong = { type: 'strong' };
const img = { type: 'img', attributes: { src: '' } };
const a = { type: 'a', attributes: { href: '#' } };
export const spec = [
{
description: 'should create an empty value',
html: '',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 0,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [],
replacements: [],
text: '',
},
},
{
description:
'should ignore manually added object replacement character',
html: `test${ OBJECT_REPLACEMENT_CHARACTER }`,
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description:
'should ignore manually added object replacement character with formatting',
html: `<em>h${ OBJECT_REPLACEMENT_CHARACTER }i</em>`,
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 2 ],
record: {
start: 0,
end: 2,
formats: [ [ em ], [ em ] ],
replacements: [ , , ],
text: 'hi',
},
},
{
description: 'should preserve non breaking space',
html: 'test\u00a0 test',
createRange: ( element ) => ( {
startOffset: 5,
startContainer: element.firstChild,
endOffset: 5,
endContainer: element.firstChild,
} ),
startPath: [ 0, 5 ],
endPath: [ 0, 5 ],
record: {
start: 5,
end: 5,
formats: [ , , , , , , , , , , ],
replacements: [ , , , , , , , , , , ],
text: 'test\u00a0 test',
},
},
{
description: 'should create an empty value from empty tags',
html: '<em></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [],
replacements: [],
text: '',
},
},
{
description: 'should create a value without formatting',
html: 'test',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element.firstChild,
endOffset: 4,
endContainer: element.firstChild,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should preserve emoji',
html: '🍒',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 2 ],
record: {
start: 0,
end: 2,
formats: [ , , ],
replacements: [ , , ],
text: '🍒',
},
},
{
description: 'should preserve emoji in formatting',
html: '<em>🍒</em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 2 ],
record: {
start: 0,
end: 2,
formats: [ [ em ], [ em ] ],
replacements: [ , , ],
text: '🍒',
},
},
{
description: 'should create a value with formatting',
html: '<em>test</em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element.firstChild,
endOffset: 1,
endContainer: element.firstChild,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ [ em ], [ em ], [ em ], [ em ] ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should create a value with nested formatting',
html: '<em><strong>test</strong></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0, 0 ],
endPath: [ 0, 0, 0, 4 ],
record: {
start: 0,
end: 4,
formats: [
[ em, strong ],
[ em, strong ],
[ em, strong ],
[ em, strong ],
],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should create a value with formatting for split tags',
html: '<em>te</em><em>st</em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element.querySelector( 'em' ),
endOffset: 1,
endContainer: element.querySelector( 'em' ),
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 2 ],
record: {
start: 0,
end: 2,
formats: [ [ em ], [ em ], [ em ], [ em ] ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should create a value with formatting with attributes',
html: '<a href="#">test</a>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ [ a ], [ a ], [ a ], [ a ] ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should create a value with image object',
html: '<img src="">',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [ , ],
replacements: [ img ],
text: '\ufffc',
},
},
{
description: 'should create a value with image object and formatting',
html: '<em><img src=""></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element.querySelector( 'img' ),
endOffset: 1,
endContainer: element.querySelector( 'img' ),
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 2, 0 ],
record: {
start: 0,
end: 1,
formats: [ [ em ] ],
replacements: [ img ],
text: '\ufffc',
},
},
{
description: 'should create a value with image object and text before',
html: 'te<em>st<img src=""></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 2,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 1, 2, 0 ],
record: {
start: 0,
end: 5,
formats: [ , , [ em ], [ em ], [ em ] ],
replacements: [ , , , , img ],
text: 'test\ufffc',
},
},
{
description: 'should create a value with image object and text after',
html: '<em><img src="">te</em>st',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 2,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 1, 2 ],
record: {
start: 0,
end: 5,
formats: [ [ em ], [ em ], [ em ], , , ],
replacements: [ img, , , , , ],
text: '\ufffctest',
},
},
{
description: 'should handle br',
html: '<br>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [ , ],
replacements: [ , ],
text: '\n',
},
},
{
description: 'should handle br with text',
html: 'te<br>st',
createRange: ( element ) => ( {
startOffset: 1,
startContainer: element,
endOffset: 2,
endContainer: element,
} ),
startPath: [ 0, 2 ],
endPath: [ 2, 0 ],
record: {
start: 2,
end: 3,
formats: [ , , , , , ],
replacements: [ , , , , , ],
text: 'te\nst',
},
},
{
description: 'should handle br with formatting',
html: '<em><br></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 2, 0 ],
record: {
start: 0,
end: 1,
formats: [ [ em ] ],
replacements: [ , ],
text: '\n',
},
},
{
description: 'should handle double br',
html: 'a<br><br>b',
createRange: ( element ) => ( {
startOffset: 2,
startContainer: element,
endOffset: 3,
endContainer: element,
} ),
startPath: [ 2, 0 ],
endPath: [ 4, 0 ],
record: {
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'a\n\nb',
start: 2,
end: 3,
},
},
{
description: 'should handle selection before br',
html: 'a<br><br>b',
createRange: ( element ) => ( {
startOffset: 2,
startContainer: element,
endOffset: 2,
endContainer: element,
} ),
startPath: [ 2, 0 ],
endPath: [ 2, 0 ],
record: {
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'a\n\nb',
start: 2,
end: 2,
},
},
{
description: 'should remove padding',
html: ZWNBSP,
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [],
replacements: [],
text: '',
},
},
{
description: 'should filter format boundary attributes',
html: '<strong data-rich-text-format-boundary="true">test</strong>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ [ strong ], [ strong ], [ strong ], [ strong ] ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should not error with overlapping formats (1)',
html: '<a href="#"><em>1</em><strong>2</strong></a>',
createRange: ( element ) => ( {
startOffset: 1,
startContainer: element.firstChild,
endOffset: 1,
endContainer: element.firstChild,
} ),
startPath: [ 0, 0, 0, 1 ],
endPath: [ 0, 0, 0, 1 ],
record: {
start: 1,
end: 1,
formats: [
[ a, em ],
[ a, strong ],
],
replacements: [ , , ],
text: '12',
},
},
{
description: 'should not error with overlapping formats (2)',
html: '<em><a href="#">1</a></em><strong><a href="#">2</a></strong>',
createRange: ( element ) => ( {
startOffset: 1,
startContainer: element.firstChild,
endOffset: 1,
endContainer: element.firstChild,
} ),
startPath: [ 0, 0, 0, 1 ],
endPath: [ 0, 0, 0, 1 ],
record: {
start: 1,
end: 1,
formats: [
[ em, a ],
[ strong, a ],
],
replacements: [ , , ],
text: '12',
},
},
{
description: 'should disarm script',
html: '<script>alert("1")</script>',
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: '<img onerror="alert(\'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-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 class="custom-format">a</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 class="custom-format test">a</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 class="custom-format">a</a>',
value: {
formats: [
[
{
type: 'core/link',
tagName: 'a',
attributes: {},
unregisteredAttributes: {
class: 'custom-format',
},
},
],
],
replacements: [ , ],
text: 'a',
},
},
{
description: 'should create fallback format',
html: '<a class="custom-format">a</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 class="custom-format">a</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 class="custom-format">a</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 class="non-editable">a</a>',
value: {
formats: [ , ],
replacements: [
{
type: 'my-plugin/non-editable',
tagName: 'a',
attributes: {},
unregisteredAttributes: {},
innerHTML: 'a',
},
],
text: OBJECT_REPLACEMENT_CHARACTER,
},
},
];

View file

@ -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 );
} );
} );

View file

@ -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 );
} );
} );

View file

@ -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 );
} );
} );

View file

@ -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 );
} );
} );

View file

@ -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 );
} );
} );
} );

View file

@ -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 );
} );
} );
} );

View file

@ -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 ] );
} );
} );

View file

@ -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 );
} );
} );

View file

@ -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 );
} );
} );

View file

@ -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 );
} );
} );

View file

@ -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 );
} );
} );

View file

@ -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: '<span data-1="">b</span>',
future: '<span>b</span>',
movedCount: 0,
description: 'should remove attribute',
},
{
current: '<span data-1="" data-2="">b</span>',
future: '<span>b</span>',
movedCount: 0,
description: 'should remove attributes',
},
{
current: '<span>a</span>',
future: '<span data-1="">c</span>',
movedCount: 0,
description: 'should add attribute',
},
{
current: '<span>a</span>',
future: '<span data-1="" data-2="">c</span>',
movedCount: 0,
description: 'should add attributes',
},
{
current: '<span data-1="i">a</span>',
future: '<span data-1="ii">a</span>',
movedCount: 0,
description: 'should update attribute',
},
{
current: '<span data-1="i" data-2="ii">a</span>',
future: '<span data-1="ii" data-2="i">a</span>',
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 );
} );
} );
} );

View file

@ -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 <em>two 🍒</em> <a href="#"><img src=""><strong>three</strong></a><img src="">';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should extract recreate HTML 2', () => {
const HTML =
'one <em>two 🍒</em> <a href="#">test <img src=""><strong>three</strong></a><img src="">';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should extract recreate HTML 3', () => {
const HTML = '<img src="">';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should extract recreate HTML 4', () => {
const HTML = '<em>two 🍒</em>';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should extract recreate HTML 5', () => {
const HTML =
'<em>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 <a href="https://github.com/WordPress/gutenberg">GitHub repository</a>.</em>';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should serialize neighbouring formats of same type', () => {
const HTML = '<a href="a">a</a><a href="b">a</a>';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should serialize neighbouring same formats', () => {
const HTML = '<a href="a">a</a><a href="a">a</a>';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
} );

View file

@ -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 );
} );
} );

View file

@ -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 );
} );
} );

View file

@ -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();
}
}
}

View file

@ -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
) }</${ type }>`;
}
function createChildrenHTML( children = [] ) {
return children
.map( ( child ) => {
if ( child.html !== undefined ) {
return child.html;
}
return child.text === undefined
? createElementHTML( child )
: escapeEditableHTML( child.text );
} )
.join( '' );
}

View file

@ -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<string, any>, isEditableTree: boolean ): Record<string, any> {
if ( isEditableTree ) {
return attributes;
}
const newAttributes: Record<string, any> = {};
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<string, any>,
unregisteredAttributes: Record<string, any>
object: boolean,
boundaryClass: boolean,
isEditableTree: boolean,
}) {
const { findFormatTypeByName } = useFormatTypes();
const formatType = findFormatTypeByName(type);
let elementAttributes: Record<string, any> = {};
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<T>( 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;
}

View file

@ -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 );
}

View file

@ -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<RichTextFormat>;
/**
* 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<string, any>;
};
export interface RichTextFormat {
type: string;
tagName?: string;
title?: string;
formatType?: RichTextFormatType;
attributes?: Record<string, any>;
unregisteredAttributes?: Record<string, any>;
}
export interface SimpleRange {
startOffset: number;
endOffset: number;
startContainer: Node;
endContainer: Node;
}

View file

@ -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;
}

View file

@ -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<RichTextFormatType[]> = 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,
};
}

View file

@ -0,0 +1,36 @@
{
"name": "@schlechtenburg/rich-text",
"version": "0.0.0",
"description": "> TODO: description",
"author": "Benjamin Bädorf <hello@benjaminbaedorf.eu>",
"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"
}
}

View file

@ -1,7 +0,0 @@
'use strict';
const core = require('..');
describe('@schlechtenburg/core', () => {
it('needs tests');
});

1
test/index.ts Normal file
View file

@ -0,0 +1 @@
export * from './with-setup';

8
test/jsdom.ts Normal file
View file

@ -0,0 +1,8 @@
import { JSDOM } from 'jsdom';
const dom = new JSDOM(`<!DOCTYPE html><html><body></body></html>`);
export const window = dom.window;
export const document = dom.window.document;
export default dom;

17
test/with-setup.ts Normal file
View file

@ -0,0 +1,17 @@
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue'
export function withSetup<T>(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;
}

View file

@ -17,6 +17,9 @@
],
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"types": [
"@vitest/browser/providers/playwright"
],
"paths": {}
}
}

View file

@ -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()
})

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
name: string
}>()
</script>
<template>
<div>
<h1>Hello {{ name }}!</h1>
</div>
</template>

15
vitest.config.ts Normal file
View file

@ -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: {},
},
},
})