diff --git a/.changeset/chatty-flies-pay.md b/.changeset/chatty-flies-pay.md new file mode 100644 index 000000000..8651323a3 --- /dev/null +++ b/.changeset/chatty-flies-pay.md @@ -0,0 +1,5 @@ +--- +'slate-dom': minor +--- + +- Add `splitDecorationsByChild` to split an array of decorated ranges by child index. diff --git a/.changeset/silly-crabs-sort.md b/.changeset/silly-crabs-sort.md new file mode 100644 index 000000000..5716d7e20 --- /dev/null +++ b/.changeset/silly-crabs-sort.md @@ -0,0 +1,5 @@ +--- +'slate': patch +--- + +- PERF: Use pure JS instead of Immer for applying operations and transforming points and ranges. Immer is now used only for producing fragments. diff --git a/.changeset/tiny-scissors-yell.md b/.changeset/tiny-scissors-yell.md new file mode 100644 index 000000000..4fb814622 --- /dev/null +++ b/.changeset/tiny-scissors-yell.md @@ -0,0 +1,14 @@ +--- +'slate-react': minor +--- + +- Implement experimental chunking optimization (disabled by default, see https://docs.slatejs.org/walkthroughs/09-performance). +- Add `useElement` and `useElementIf` hooks to get the current element. +- **BREAKING CHANGE:** Decorations are no longer recomputed when a node's parent re-renders, only when the node itself re-renders or when the `decorate` function is changed. + - Ensure that `decorate` is a pure function of the node passed into it. Depending on the node's parent may result in decorations not being recomputed when you expect them to be. + - If this change impacts you, consider changing your `decorate` function to work on the node's parent instead. + - For example, if your `decorate` function decorates a `code-line` based on the parent `code-block`'s language, decorate the `code-block` instead. + - This is unlikely to result in any performance detriment, since in previous versions of `slate-react`, the decorations of all siblings were recomputed when one sibling was modified. +- Increase minimum `slate-dom` version to `0.116.0`. +- Deprecate the `useSlateWithV` hook +- PERF: Use subscribable pattern for `useSlate`, `useSelected` and decorations to reduce re-renders. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d20da8c15..e1acc6ddc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,11 @@ jobs: run: yarn && yarn build && yarn ${{ matrix.command }} env: CI: true + + - name: Upload Playwright test results + if: ${{ !cancelled() && matrix.command == 'test:integration' }} + uses: actions/upload-artifact@v4 + with: + name: test-results + path: test-results + retention-days: 30 diff --git a/.gitignore b/.gitignore index c408a4335..a885b8a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ packages/*/yarn.lock site/out/ tmp/ test-results/ +coverage .DS_Store # Recommendation from https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored (not using Zero-installs) diff --git a/.yarn/sdks/integrations.yml b/.yarn/sdks/integrations.yml index aa9d0d0ad..401be9985 100644 --- a/.yarn/sdks/integrations.yml +++ b/.yarn/sdks/integrations.yml @@ -2,4 +2,5 @@ # Manual changes might be lost! integrations: + - vim - vscode diff --git a/docs/Summary.md b/docs/Summary.md index 2b8b2faa1..f6e312487 100644 --- a/docs/Summary.md +++ b/docs/Summary.md @@ -11,7 +11,8 @@ - [Executing Commands](walkthroughs/05-executing-commands.md) - [Saving to a Database](walkthroughs/06-saving-to-a-database.md) - [Enabling Collaborative Editing](walkthroughs/07-enabling-collaborative-editing.md) -- [Using the Bundled Source](walkthroughs/xx-using-the-bundled-source.md) +- [Using the Bundled Source](walkthroughs/08-using-the-bundled-source.md) +- [Improving Performance](walkthroughs/09-performance.md) ## Concepts diff --git a/docs/concepts/09-rendering.md b/docs/concepts/09-rendering.md index 15c790fbc..b2bf404a2 100644 --- a/docs/concepts/09-rendering.md +++ b/docs/concepts/09-rendering.md @@ -192,3 +192,7 @@ It is also possible to apply custom styles with a stylesheet and `className`. Ho - Provide your styles using the `style` prop instead of a stylesheet, which overrides the default inline styles. - Pass the `disableDefaultStyles` prop to the `` component. - Use `!important` in your stylesheet declarations to make them override the inline styles. + +## Performance + +See [Improving Performance](../walkthroughs/09-performance.md) for ways to improve the rendering performance of the editor. diff --git a/docs/images/performance/firefox-inp.png b/docs/images/performance/firefox-inp.png new file mode 100644 index 000000000..9dcbc031e Binary files /dev/null and b/docs/images/performance/firefox-inp.png differ diff --git a/docs/libraries/slate-react/hooks.md b/docs/libraries/slate-react/hooks.md index 2ef22d140..eadef7f41 100644 --- a/docs/libraries/slate-react/hooks.md +++ b/docs/libraries/slate-react/hooks.md @@ -1,19 +1,19 @@ # Slate React Hooks -- [Check hooks](hooks.md#check-hooks) -- [Editor hooks](hooks.md#editor-hooks) -- [Selection hooks](hooks.md#selection-hooks) - -### Check hooks - -React hooks for Slate editors - #### `useComposing(): boolean` Get the current `composing` state of the editor. It deals with `compositionstart`, `compositionupdate`, `compositionend` events. Composition events are triggered by typing (composing) with a language that uses a composition character (e.g. Chinese, Japanese, Korean, etc.) [example](https://en.wikipedia.org/wiki/Input_method#/media/File:Typing_%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4_in_Dubeolsik_keyboard_layout.gif). +#### `useElement(): Element` + +Get the current element object. Re-renders whenever the element or any of its descendants changes. + +#### `useElementIf(): Element | null` + +The same as `useElement()` but returns `null` instead of throwing an error when not inside an element. + #### `useFocused(): boolean` Get the current `focused` state of the editor. @@ -24,35 +24,27 @@ Get the current `readOnly` state of the editor. #### `useSelected(): boolean` -Get the current `selected` state of an element. - -### Editor hooks +Get the current `selected` state of an element. An element is selected if `editor.selection` exists and overlaps any part of the element. #### `useSlate(): Editor` -Get the current editor object from the React context. Re-renders the context whenever changes occur in the editor. - -#### `useSlateWithV(): { editor: Editor, v: number }` - -The same as `useSlate()` but includes a version counter which you can use to prevent re-renders. +Get the current editor object. Re-renders whenever changes occur in the editor. #### `useSlateStatic(): Editor` Get the current editor object from the React context. A version of useSlate that does not re-render the context. Previously called `useEditor`. -### Selection hooks - #### `useSlateSelection(): (BaseRange & { placeholder?: string | undefined; onPlaceholderResize?: ((node: HTMLElement | null) => void) | undefined }) | null` -Get the current editor selection from the React context. Only re-renders when the selection changes. +Get the current editor selection. Only re-renders when the selection changes. #### `useSlateSelector(selector: (editor: Editor) => T, equalityFn?: (a: T, b: T) => boolean): T` -Similar to `useSlateSelection` but uses redux style selectors to prevent rerendering on every keystroke. +Use redux style selectors to prevent re-rendering on every keystroke. -Returns a subset of the full selection value based on the `selector`. +Bear in mind re-rendering can only prevented if the returned value is a value type or for reference types (e.g. objects and arrays) add a custom equality function. -Bear in mind rerendering can only prevented if the returned value is a value type or for reference types (e.g. objects and arrays) add a custom equality function for the `equalityFn` argument. +If `selector` is memoized using `useCallback`, then it will only be called when it or the editor state changes. Otherwise, `selector` will be called every time the component renders. Example: diff --git a/docs/walkthroughs/xx-using-the-bundled-source.md b/docs/walkthroughs/08-using-the-bundled-source.md similarity index 100% rename from docs/walkthroughs/xx-using-the-bundled-source.md rename to docs/walkthroughs/08-using-the-bundled-source.md diff --git a/docs/walkthroughs/09-performance.md b/docs/walkthroughs/09-performance.md new file mode 100644 index 000000000..194398873 --- /dev/null +++ b/docs/walkthroughs/09-performance.md @@ -0,0 +1,118 @@ +# Improving Performance + +When building a text editor, it's important for user interactions to take place without any noticeable delay. For small and moderately sized documents (less than 1000 blocks), you probably don't need to worry about performance. If your editor needs to support very large documents (10,000+ or 100,000+ blocks), follow this guide to ensure the editor stays responsive. + +The [Huge Document](https://slatejs.org/examples/huge-document) example contains an interactive playground where you can explore the effect of various factors on the performance of a very simple Slate editor. + +The type of performance this guide is mostly concerned with is the **Interaction to Next Paint** (INP) while typing. If the INP is below roughly 100ms, typing should feel very responsive. The editor will still be usable when the INP duration is longer, but it will feel increasingly sluggish and unpleasant to use. + +Other performance metrics to be aware of (but which are not currently covered in this guide) are **time to first paint** and the INP when performing non-typing operations (such as selecting all content or pasting). + +INP is easiest to measure in Chrome using the [Performance panel](https://developer.chrome.com/docs/devtools/performance) in DevTools, but there are ways to determine it in Firefox and Safari too. For example, in Firefox, you can use the [Firefox Profiler](https://profiler.firefox.com/) to see a timeline of events. + +![Screenshot of the Stack Chart tab of the Firefox Profiler, annotated to show a breakdown of time spent in core Slate, React, and painting the DOM.](../images/performance/firefox-inp.png) + +There are three main areas that can be optimized: + +- [Slate core](#optimizing-slate-core) +- [React](#optimizing-react) +- [DOM painting](#optimizing-dom-painting) + +Before you start optimizing, make sure you know which of these areas is most responsible for any slowness you're seeing. The best way of doing this is to use your browser's profiler (see the example for Firefox above), but you can also use these heuristics to guess which area is most at fault: + +1. If performance is much better in Firefox than in Chrome or Safari, DOM painting is usually the problem (tested May 2025). +2. If disabling any custom normalization logic improves performance, the normalization logic is the problem. +3. Otherwise, it's likely to be React. + +## Optimizing Slate Core + +Usually, if the core Slate logic is causing a noticeable delay, it's because of [normalizing](../concepts/11-normalizing.md). If custom normalization logic is causing slowness in your app, consider whether the logic can be made more efficient. + +Understand that `normalizeNode` is called once for every modified node and every ancestor of a modified node. As a result, `normalizeNode` is called for the editor node whenever anything changes in the editor, but for other nodes it is called much less frequently. + +Make sure you only normalize the node passed into `normalizeNode` and (occasionally) its direct children, not its children's descendants. Normalization logic should only be applied directly to the editor node when absolutely necessary, such when enforcing that the last block in the document is a paragraph. + +## Optimizing React + +### Reduce Renders + +The `renderElement` prop and any React component it returns will re-render every time the element or any of its descendants changes. This is unavoidable. However, sometimes custom logic can cause React components to re-render more often than this, which can have a detrimental effect on performance. + +Ensure that function props such as `renderElement`, `renderLeaf`, `renderChunk` and `decorate` do not change on every render. Either they should be defined at the top level of the file (not inside a component or hook), or they should be wrapped inside a `useCallback` and all dependencies should be properly memoized. + +If unmodified elements are being re-rendered, check to see if they are subscribing to any contexts or hooks that are causing unnecessary re-renders. You can also apply these techniques to any toolbars or other non-element React components that may be re-rendering in response to changes in the editor. + +The `useSlate`, `useSlateSelection`, `useSlateSelector`, `useSelected` and `useFocused` hooks cause React components to re-render in various circumstances. If you're using `useSlate`, consider if you can use `useSlateStatic` (which does not cause re-renders) instead. If you're using `useSlateSelection`, consider using `editor.selection`. If you only care about some value derived from the editor (such as whether a given mark is active), use `useSlateSelector` to only re-render when this value changes. + +If your components depend on custom React contexts containing non-primitive values (such as objects or arrays), ensure that these values are properly memoized so that components only re-render when these values change. In some circumstances, you may instead want to consider passing a ref object or an unchanging getter function to retrieve the latest value. + +```tsx +// Provider +const myDataRef = useRef(myData) +myDataRef.current = myData +return {children} + +// Consumer +// Does not re-render when `myData` changes +const myDataRef = useContext(MyContext) + +const onClick = () => { + console.log(myDataRef.current) +} +``` + +### Enable Chunking (experimental) + +Chunking is an internal optimization used by `slate-react`, and must be explicitly enabled. It works by splitting a node's children into nested "chunks", each of which is a separately memoized React component. This reduces the amount of work React needs to do when processing changes to the JSX, resulting in a 10x speed-up in ideal circumstances. + +To enable chunking, you need to implement `editor.getChunkSize(node: Ancestor) => number | null`, which controls the number of nodes per lowest-level chunk for a given parent node. In most circumstances, setting the chunk size to 1000 for the editor and `null` for all other ancestors works well. + +```typescript +editor.getChunkSize = node => (Editor.isEditor(node) ? 1000 : null) +``` + +Note that chunking can only be enabled for nodes whose children are all block elements. Attempting to enable chunking for leaf blocks (blocks containing inline nodes) will have no effect. + +By default, chunking has no effect on the DOM. You can override this by passing a `renderChunk` prop to `Editable`. + +## Optimizing DOM Painting + +In Chrome and Safari, painting large numbers of DOM nodes can be extremely slow, over 100x slower than the core Slate logic and React rendering combined in some cases. In Firefox, the impact of painting on performance is much less significant. + +The best way of speeding up painting large documents is to use the [`content-visibility`](https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility) CSS property. When set to `auto`, this property instructs browsers not to paint content that is off-screen. However, it also comes with a performance overhead proportional to the number of DOM nodes it is applied to, which is especially bad in Safari. When rendering large documents in Safari, applying `content-visibility: auto` to each Slate element individually is often slower than not using it at all. + +The recommended solution is to enable [chunking](#enable-chunking-experimental) and apply `content-visibility: auto` on each lowest-level chunk by passing a `renderChunk` prop to `Editable`. + +```tsx +const renderChunk = ({ attributes, lowest, children }: RenderChunkProps) => ( +
+ {children} +
+) +``` + +Note that this will modify the DOM structure of your editor, which may have adverse effects on its appearance. During development, it is recommended to set the chunk size to a small number such as 3 so that styling issues caused by nested chunks are easier to detect. + +If you previously had a CSS rule such as this to apply spacing between top-level blocks: + +```css +[data-slate-editor] > * + * { + margin-top: 1em; +} +``` + +It should be changed to this: + +```css +[data-slate-editor] > * + *, +[data-slate-chunk] > * + * { + margin-top: 1em; +} +``` + +Also bear in mind this warning about `content-visibility: auto` from MDN: + +> Since styles for off-screen content are not rendered, elements intentionally hidden with `display: none` or `visibility: hidden` _will still appear in the accessibility tree_. If you don't want an element to appear in the accessibility tree, use `aria-hidden="true"`. diff --git a/jest.config.js b/jest.config.js index d04c9ff3c..b62954178 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,6 +10,16 @@ const config = { ], }, testEnvironment: 'jsdom', + collectCoverage: true, + collectCoverageFrom: ['./packages/slate-react/src/chunking/*'], + coverageThreshold: { + './packages/slate-react/src/chunking/*': { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, } module.exports = config diff --git a/package.json b/package.json index 8580a62c2..b68429014 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@changesets/cli": "^2.26.2", "@emotion/css": "^11.11.2", "@faker-js/faker": "^8.2.0", - "@playwright/test": "^1.39.0", + "@playwright/test": "^1.52.0", "@types/is-hotkey": "^0.1.10", "@types/is-url": "^1.2.32", "@types/jest": "29.5.6", diff --git a/packages/slate-dom/src/index.ts b/packages/slate-dom/src/index.ts index 4742d7bc4..e5c4a4640 100644 --- a/packages/slate-dom/src/index.ts +++ b/packages/slate-dom/src/index.ts @@ -59,6 +59,7 @@ export { Key } from './utils/key' export { isElementDecorationsEqual, isTextDecorationsEqual, + splitDecorationsByChild, } from './utils/range-list' export { diff --git a/packages/slate-dom/src/utils/range-list.ts b/packages/slate-dom/src/utils/range-list.ts index 1c0b5e19c..8201fc1b4 100644 --- a/packages/slate-dom/src/utils/range-list.ts +++ b/packages/slate-dom/src/utils/range-list.ts @@ -1,5 +1,6 @@ -import { Range } from 'slate' +import { Ancestor, DecoratedRange, Editor, Range } from 'slate' import { PLACEHOLDER_SYMBOL } from './weak-maps' +import { DOMEditor } from '../plugin/dom-editor' export const shallowCompare = ( obj1: { [key: string]: unknown }, @@ -29,9 +30,17 @@ const isDecorationFlagsEqual = (range: Range, other: Range) => { */ export const isElementDecorationsEqual = ( - list: Range[], - another: Range[] + list: Range[] | null, + another: Range[] | null ): boolean => { + if (list === another) { + return true + } + + if (!list || !another) { + return false + } + if (list.length !== another.length) { return false } @@ -57,9 +66,17 @@ export const isElementDecorationsEqual = ( */ export const isTextDecorationsEqual = ( - list: Range[], - another: Range[] + list: Range[] | null, + another: Range[] | null ): boolean => { + if (list === another) { + return true + } + + if (!list || !another) { + return false + } + if (list.length !== another.length) { return false } @@ -80,3 +97,65 @@ export const isTextDecorationsEqual = ( return true } + +/** + * Split and group decorations by each child of a node. + * + * @returns An array with length equal to that of `node.children`. Each index + * corresponds to a child of `node`, and the value is an array of decorations + * for that child. + */ + +export const splitDecorationsByChild = ( + editor: Editor, + node: Ancestor, + decorations: DecoratedRange[] +): DecoratedRange[][] => { + const decorationsByChild = Array.from( + node.children, + (): DecoratedRange[] => [] + ) + + if (decorations.length === 0) { + return decorationsByChild + } + + const path = DOMEditor.findPath(editor, node) + const level = path.length + const ancestorRange = Editor.range(editor, path) + + const cachedChildRanges = new Array(node.children.length) + + const getChildRange = (index: number) => { + const cachedRange = cachedChildRanges[index] + if (cachedRange) return cachedRange + const childRange = Editor.range(editor, [...path, index]) + cachedChildRanges[index] = childRange + return childRange + } + + for (const decoration of decorations) { + const decorationRange = Range.intersection(ancestorRange, decoration) + if (!decorationRange) continue + + const [startPoint, endPoint] = Range.edges(decorationRange) + const startIndex = startPoint.path[level] + const endIndex = endPoint.path[level] + + for (let i = startIndex; i <= endIndex; i++) { + const ds = decorationsByChild[i] + if (!ds) continue + + const childRange = getChildRange(i) + const childDecorationRange = Range.intersection(childRange, decoration) + if (!childDecorationRange) continue + + ds.push({ + ...decoration, + ...childDecorationRange, + }) + } + } + + return decorationsByChild +} diff --git a/packages/slate-react/package.json b/packages/slate-react/package.json index 9f38c7224..2323760de 100644 --- a/packages/slate-react/package.json +++ b/packages/slate-react/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@babel/runtime": "^7.23.2", + "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^14.0.0", "@types/is-hotkey": "^0.1.8", "@types/jest": "29.5.6", @@ -42,7 +43,7 @@ "react": ">=18.2.0", "react-dom": ">=18.2.0", "slate": ">=0.114.0", - "slate-dom": ">=0.110.2" + "slate-dom": ">=0.116.0" }, "umdGlobals": { "react": "React", diff --git a/packages/slate-react/src/chunking/children-helper.ts b/packages/slate-react/src/chunking/children-helper.ts new file mode 100644 index 000000000..aa3bb6116 --- /dev/null +++ b/packages/slate-react/src/chunking/children-helper.ts @@ -0,0 +1,122 @@ +import { Editor, Descendant } from 'slate' +import { Key } from 'slate-dom' +import { ChunkLeaf } from './types' +import { ReactEditor } from '../plugin/react-editor' + +/** + * Traverse an array of children, providing helpers useful for reconciling the + * children array with a chunk tree + */ +export class ChildrenHelper { + private editor: Editor + private children: Descendant[] + + /** + * Sparse array of Slate node keys, each index corresponding to an index in + * the children array + * + * Fetching the key for a Slate node is expensive, so we cache them here. + */ + private cachedKeys: Array + + /** + * The index of the next node to be read in the children array + */ + public pointerIndex: number + + constructor(editor: Editor, children: Descendant[]) { + this.editor = editor + this.children = children + this.cachedKeys = new Array(children.length) + this.pointerIndex = 0 + } + + /** + * Read a given number of nodes, advancing the pointer by that amount + */ + public read(n: number): Descendant[] { + // PERF: If only one child was requested (the most common case), use array + // indexing instead of slice + if (n === 1) { + return [this.children[this.pointerIndex++]] + } + + const slicedChildren = this.remaining(n) + this.pointerIndex += n + + return slicedChildren + } + + /** + * Get the remaining children without advancing the pointer + * + * @param [maxChildren] Limit the number of children returned. + */ + public remaining(maxChildren?: number): Descendant[] { + if (maxChildren === undefined) { + return this.children.slice(this.pointerIndex) + } + + return this.children.slice( + this.pointerIndex, + this.pointerIndex + maxChildren + ) + } + + /** + * Whether all children have been read + */ + public get reachedEnd() { + return this.pointerIndex >= this.children.length + } + + /** + * Determine whether a node with a given key appears in the unread part of the + * children array, and return its index relative to the current pointer if so + * + * Searching for the node object itself using indexOf is most efficient, but + * will fail to locate nodes that have been modified. In this case, nodes + * should be identified by their keys instead. + * + * Searching an array of keys using indexOf is very inefficient since fetching + * the keys for all children in advance is very slow. Insead, if the node + * search fails to return a value, fetch the keys of each remaining child one + * by one and compare it to the known key. + */ + public lookAhead(node: Descendant, key: Key) { + const elementResult = this.children.indexOf(node, this.pointerIndex) + if (elementResult > -1) return elementResult - this.pointerIndex + + for (let i = this.pointerIndex; i < this.children.length; i++) { + const candidateNode = this.children[i] + const candidateKey = this.findKey(candidateNode, i) + if (candidateKey === key) return i - this.pointerIndex + } + + return -1 + } + + /** + * Convert an array of Slate nodes to an array of chunk leaves, each + * containing the node and its key + */ + public toChunkLeaves(nodes: Descendant[], startIndex: number): ChunkLeaf[] { + return nodes.map((node, i) => ({ + type: 'leaf', + node, + key: this.findKey(node, startIndex + i), + index: startIndex + i, + })) + } + + /** + * Get the key for a Slate node, cached using the node's index + */ + private findKey(node: Descendant, index: number): Key { + const cachedKey = this.cachedKeys[index] + if (cachedKey) return cachedKey + const key = ReactEditor.findKey(this.editor, node) + this.cachedKeys[index] = key + return key + } +} diff --git a/packages/slate-react/src/chunking/chunk-tree-helper.ts b/packages/slate-react/src/chunking/chunk-tree-helper.ts new file mode 100644 index 000000000..34bd36a81 --- /dev/null +++ b/packages/slate-react/src/chunking/chunk-tree-helper.ts @@ -0,0 +1,574 @@ +import { Path } from 'slate' +import { Key } from 'slate-dom' +import { + Chunk, + ChunkTree, + ChunkLeaf, + ChunkDescendant, + ChunkAncestor, +} from './types' + +type SavedPointer = + | 'start' + | { + chunk: ChunkAncestor + node: ChunkDescendant + } + +export interface ChunkTreeHelperOptions { + chunkSize: number + debug?: boolean +} + +/** + * Traverse and modify a chunk tree + */ +export class ChunkTreeHelper { + /** + * The root of the chunk tree + */ + private root: ChunkTree + + /** + * The ideal size of a chunk + */ + private chunkSize: number + + /** + * Whether debug mode is enabled + * + * If enabled, the pointer state will be checked for internal consistency + * after each mutating operation. + */ + private debug: boolean + + /** + * Whether the traversal has reached the end of the chunk tree + * + * When this is true, the pointerChunk and pointerIndex point to the last + * top-level node in the chunk tree, although pointerNode returns null. + */ + private reachedEnd: boolean + + /** + * The chunk containing the current node + */ + private pointerChunk: ChunkAncestor + + /** + * The index of the current node within pointerChunk + * + * Can be -1 to indicate that the pointer is before the start of the tree. + */ + private pointerIndex: number + + /** + * Similar to a Slate path; tracks the path of pointerChunk relative to the + * root. + * + * Used to move the pointer from the current chunk to the parent chunk more + * efficiently. + */ + private pointerIndexStack: number[] + + /** + * Indexing the current chunk's children has a slight time cost, which adds up + * when traversing very large trees, so the current node is cached. + * + * A value of undefined means that the current node is not cached. This + * property must be set to undefined whenever the pointer is moved, unless + * the pointer is guaranteed to point to the same node that it did previously. + */ + private cachedPointerNode: ChunkDescendant | null | undefined + + constructor( + chunkTree: ChunkTree, + { chunkSize, debug }: ChunkTreeHelperOptions + ) { + this.root = chunkTree + this.chunkSize = chunkSize + // istanbul ignore next + this.debug = debug ?? false + this.pointerChunk = chunkTree + this.pointerIndex = -1 + this.pointerIndexStack = [] + this.reachedEnd = false + this.validateState() + } + + /** + * Move the pointer to the next leaf in the chunk tree + */ + public readLeaf(): ChunkLeaf | null { + // istanbul ignore next + if (this.reachedEnd) return null + + // Get the next sibling or aunt node + while (true) { + if (this.pointerIndex + 1 < this.pointerSiblings.length) { + this.pointerIndex++ + this.cachedPointerNode = undefined + break + } else if (this.pointerChunk.type === 'root') { + this.reachedEnd = true + return null + } else { + this.exitChunk() + } + } + + this.validateState() + + // If the next sibling or aunt is a chunk, descend into it + this.enterChunkUntilLeaf(false) + + return this.pointerNode as ChunkLeaf + } + + /** + * Move the pointer to the previous leaf in the chunk tree + */ + public returnToPreviousLeaf() { + // If we were at the end of the tree, descend into the end of the last + // chunk in the tree + if (this.reachedEnd) { + this.reachedEnd = false + this.enterChunkUntilLeaf(true) + return + } + + // Get the previous sibling or aunt node + while (true) { + if (this.pointerIndex >= 1) { + this.pointerIndex-- + this.cachedPointerNode = undefined + break + } else if (this.pointerChunk.type === 'root') { + this.pointerIndex = -1 + return + } else { + this.exitChunk() + } + } + + this.validateState() + + // If the previous sibling or aunt is a chunk, descend into it + this.enterChunkUntilLeaf(true) + } + + /** + * Insert leaves before the current leaf, leaving the pointer unchanged + */ + public insertBefore(leaves: ChunkLeaf[]) { + this.returnToPreviousLeaf() + this.insertAfter(leaves) + this.readLeaf() + } + + /** + * Insert leaves after the current leaf, leaving the pointer on the last + * inserted leaf + * + * The insertion algorithm first checks for any chunk we're currently at the + * end of that can receive additional leaves. Next, it tries to insert leaves + * at the starts of any subsequent chunks. + * + * Any remaining leaves are passed to rawInsertAfter to be chunked and + * inserted at the highest possible level. + */ + public insertAfter(leaves: ChunkLeaf[]) { + // istanbul ignore next + if (leaves.length === 0) return + + let beforeDepth = 0 + let afterDepth = 0 + + // While at the end of a chunk, insert any leaves that will fit, and then + // exit the chunk + while ( + this.pointerChunk.type === 'chunk' && + this.pointerIndex === this.pointerSiblings.length - 1 + ) { + const remainingCapacity = this.chunkSize - this.pointerSiblings.length + const toInsertCount = Math.min(remainingCapacity, leaves.length) + + if (toInsertCount > 0) { + const leavesToInsert = leaves.splice(0, toInsertCount) + this.rawInsertAfter(leavesToInsert, beforeDepth) + } + + this.exitChunk() + beforeDepth++ + } + + if (leaves.length === 0) return + + // Save the pointer so that we can come back here after inserting leaves + // into the starts of subsequent blocks + const rawInsertPointer = this.savePointer() + + // If leaves are inserted into the start of a subsequent block, then we + // eventually need to restore the pointer to the last such inserted leaf + let finalPointer: SavedPointer | null = null + + // Move the pointer into the chunk containing the next leaf, if it exists + if (this.readLeaf()) { + // While at the start of a chunk, insert any leaves that will fit, and + // then exit the chunk + while (this.pointerChunk.type === 'chunk' && this.pointerIndex === 0) { + const remainingCapacity = this.chunkSize - this.pointerSiblings.length + const toInsertCount = Math.min(remainingCapacity, leaves.length) + + if (toInsertCount > 0) { + const leavesToInsert = leaves.splice(-toInsertCount, toInsertCount) + + // Insert the leaves at the start of the chunk + this.pointerIndex = -1 + this.cachedPointerNode = undefined + this.rawInsertAfter(leavesToInsert, afterDepth) + + // If this is the first batch of insertions at the start of a + // subsequent chunk, set the final pointer to the last inserted leaf + if (!finalPointer) { + finalPointer = this.savePointer() + } + } + + this.exitChunk() + afterDepth++ + } + } + + this.restorePointer(rawInsertPointer) + + // If there are leaves left to insert, insert them between the end of the + // previous chunk and the start of the first subsequent chunk, or wherever + // the pointer ended up after the first batch of insertions + const minDepth = Math.max(beforeDepth, afterDepth) + this.rawInsertAfter(leaves, minDepth) + + if (finalPointer) { + this.restorePointer(finalPointer) + } + + this.validateState() + } + + /** + * Remove the current node and decrement the pointer, deleting any ancestor + * chunk that becomes empty as a result + */ + public remove() { + this.pointerSiblings.splice(this.pointerIndex--, 1) + this.cachedPointerNode = undefined + + if ( + this.pointerSiblings.length === 0 && + this.pointerChunk.type === 'chunk' + ) { + this.exitChunk() + this.remove() + } else { + this.invalidateChunk() + } + + this.validateState() + } + + /** + * Add the current chunk and all ancestor chunks to the list of modified + * chunks + */ + public invalidateChunk() { + for (let c = this.pointerChunk; c.type === 'chunk'; c = c.parent) { + this.root.modifiedChunks.add(c) + } + } + + /** + * Whether the pointer is at the start of the tree + */ + private get atStart() { + return this.pointerChunk.type === 'root' && this.pointerIndex === -1 + } + + /** + * The siblings of the current node + */ + private get pointerSiblings(): ChunkDescendant[] { + return this.pointerChunk.children + } + + /** + * Get the current node (uncached) + * + * If the pointer is at the start or end of the document, returns null. + * + * Usually, the current node is a chunk leaf, although it can be a chunk + * while insertions are in progress. + */ + private getPointerNode(): ChunkDescendant | null { + if (this.reachedEnd || this.pointerIndex === -1) { + return null + } + + return this.pointerSiblings[this.pointerIndex] + } + + /** + * Cached getter for the current node + */ + private get pointerNode(): ChunkDescendant | null { + if (this.cachedPointerNode !== undefined) return this.cachedPointerNode + const pointerNode = this.getPointerNode() + this.cachedPointerNode = pointerNode + return pointerNode + } + + /** + * Get the path of a chunk relative to the root, returning null if the chunk + * is not connected to the root + */ + private getChunkPath(chunk: ChunkAncestor): number[] | null { + const path: number[] = [] + + for (let c = chunk; c.type === 'chunk'; c = c.parent) { + const index = c.parent.children.indexOf(c) + + // istanbul ignore next + if (index === -1) { + return null + } + + path.unshift(index) + } + + return path + } + + /** + * Save the current pointer to be restored later + */ + private savePointer(): SavedPointer { + if (this.atStart) return 'start' + + // istanbul ignore next + if (!this.pointerNode) { + throw new Error('Cannot save pointer when pointerNode is null') + } + + return { + chunk: this.pointerChunk, + node: this.pointerNode, + } + } + + /** + * Restore the pointer to a previous state + */ + private restorePointer(savedPointer: SavedPointer) { + if (savedPointer === 'start') { + this.pointerChunk = this.root + this.pointerIndex = -1 + this.pointerIndexStack = [] + this.reachedEnd = false + this.cachedPointerNode = undefined + return + } + + // Since nodes may have been inserted or removed prior to the saved + // pointer since it was saved, the index and index stack must be + // recomputed. This is slow, but this is fine since restoring a pointer is + // not a frequent operation. + + const { chunk, node } = savedPointer + const index = chunk.children.indexOf(node) + + // istanbul ignore next + if (index === -1) { + throw new Error( + 'Cannot restore point because saved node is no longer in saved chunk' + ) + } + + const indexStack = this.getChunkPath(chunk) + + // istanbul ignore next + if (!indexStack) { + throw new Error( + 'Cannot restore point because saved chunk is no longer connected to root' + ) + } + + this.pointerChunk = chunk + this.pointerIndex = index + this.pointerIndexStack = indexStack + this.reachedEnd = false + this.cachedPointerNode = node + this.validateState() + } + + /** + * Assuming the current node is a chunk, move the pointer into that chunk + * + * @param end If true, place the pointer on the last node of the chunk. + * Otherwise, place the pointer on the first node. + */ + private enterChunk(end: boolean) { + // istanbul ignore next + if (this.pointerNode?.type !== 'chunk') { + throw new Error('Cannot enter non-chunk') + } + + this.pointerIndexStack.push(this.pointerIndex) + this.pointerChunk = this.pointerNode + this.pointerIndex = end ? this.pointerSiblings.length - 1 : 0 + this.cachedPointerNode = undefined + this.validateState() + + // istanbul ignore next + if (this.pointerChunk.children.length === 0) { + throw new Error('Cannot enter empty chunk') + } + } + + /** + * Assuming the current node is a chunk, move the pointer into that chunk + * repeatedly until the current node is a leaf + * + * @param end If true, place the pointer on the last node of the chunk. + * Otherwise, place the pointer on the first node. + */ + private enterChunkUntilLeaf(end: boolean) { + while (this.pointerNode?.type === 'chunk') { + this.enterChunk(end) + } + } + + /** + * Move the pointer to the parent chunk + */ + private exitChunk() { + // istanbul ignore next + if (this.pointerChunk.type === 'root') { + throw new Error('Cannot exit root') + } + + const previousPointerChunk = this.pointerChunk + this.pointerChunk = previousPointerChunk.parent + this.pointerIndex = this.pointerIndexStack.pop()! + this.cachedPointerNode = undefined + this.validateState() + } + + /** + * Insert leaves immediately after the current node, leaving the pointer on + * the last inserted leaf + * + * Leaves are chunked according to the number of nodes already in the parent + * plus the number of nodes being inserted, or the minimum depth if larger + */ + private rawInsertAfter(leaves: ChunkLeaf[], minDepth: number) { + if (leaves.length === 0) return + + const groupIntoChunks = ( + leaves: ChunkLeaf[], + parent: ChunkAncestor, + perChunk: number + ): ChunkDescendant[] => { + if (perChunk === 1) return leaves + const chunks: Chunk[] = [] + + for (let i = 0; i < this.chunkSize; i++) { + const chunkNodes = leaves.slice(i * perChunk, (i + 1) * perChunk) + if (chunkNodes.length === 0) break + + const chunk: Chunk = { + type: 'chunk', + key: new Key(), + parent, + children: [], + } + + chunk.children = groupIntoChunks( + chunkNodes, + chunk, + perChunk / this.chunkSize + ) + chunks.push(chunk) + } + + return chunks + } + + // Determine the chunking depth based on the number of existing nodes in + // the chunk and the number of nodes being inserted + const newTotal = this.pointerSiblings.length + leaves.length + let depthForTotal = 0 + + for (let i = this.chunkSize; i < newTotal; i *= this.chunkSize) { + depthForTotal++ + } + + // A depth of 0 means no chunking + const depth = Math.max(depthForTotal, minDepth) + const perTopLevelChunk = Math.pow(this.chunkSize, depth) + + const chunks = groupIntoChunks(leaves, this.pointerChunk, perTopLevelChunk) + this.pointerSiblings.splice(this.pointerIndex + 1, 0, ...chunks) + this.pointerIndex += chunks.length + this.cachedPointerNode = undefined + this.invalidateChunk() + this.validateState() + } + + /** + * If debug mode is enabled, ensure that the state is internally consistent + */ + // istanbul ignore next + private validateState() { + if (!this.debug) return + + const validateDescendant = (node: ChunkDescendant) => { + if (node.type === 'chunk') { + const { parent, children } = node + + if (!parent.children.includes(node)) { + throw new Error( + `Debug: Chunk ${node.key.id} has an incorrect parent property` + ) + } + + children.forEach(validateDescendant) + } + } + + this.root.children.forEach(validateDescendant) + + if ( + this.cachedPointerNode !== undefined && + this.cachedPointerNode !== this.getPointerNode() + ) { + throw new Error( + 'Debug: The cached pointer is incorrect and has not been invalidated' + ) + } + + const actualIndexStack = this.getChunkPath(this.pointerChunk) + + if (!actualIndexStack) { + throw new Error('Debug: The pointer chunk is not connected to the root') + } + + if (!Path.equals(this.pointerIndexStack, actualIndexStack)) { + throw new Error( + `Debug: The cached index stack [${this.pointerIndexStack.join( + ', ' + )}] does not match the path of the pointer chunk [${actualIndexStack.join( + ', ' + )}]` + ) + } + } +} diff --git a/packages/slate-react/src/chunking/get-chunk-tree-for-node.ts b/packages/slate-react/src/chunking/get-chunk-tree-for-node.ts new file mode 100644 index 000000000..fd073b315 --- /dev/null +++ b/packages/slate-react/src/chunking/get-chunk-tree-for-node.ts @@ -0,0 +1,47 @@ +import { Ancestor, Editor } from 'slate' +import { Key } from 'slate-dom' +import { ChunkTree } from './types' +import { ReconcileOptions, reconcileChildren } from './reconcile-children' +import { ReactEditor } from '../plugin/react-editor' + +export const KEY_TO_CHUNK_TREE = new WeakMap() + +/** + * Get or create the chunk tree for a Slate node + * + * If the reconcile option is provided, the chunk tree will be updated to + * match the current children of the node. The children are chunked + * automatically using the given chunk size. + */ +export const getChunkTreeForNode = ( + editor: Editor, + node: Ancestor, + // istanbul ignore next + options: { + reconcile?: Omit | false + } = {} +) => { + const key = ReactEditor.findKey(editor, node) + let chunkTree = KEY_TO_CHUNK_TREE.get(key) + + if (!chunkTree) { + chunkTree = { + type: 'root', + movedNodeKeys: new Set(), + modifiedChunks: new Set(), + children: [], + } + + KEY_TO_CHUNK_TREE.set(key, chunkTree) + } + + if (options.reconcile) { + reconcileChildren(editor, { + chunkTree, + children: node.children, + ...options.reconcile, + }) + } + + return chunkTree +} diff --git a/packages/slate-react/src/chunking/index.ts b/packages/slate-react/src/chunking/index.ts new file mode 100644 index 000000000..ee8ea2a2d --- /dev/null +++ b/packages/slate-react/src/chunking/index.ts @@ -0,0 +1,2 @@ +export * from './get-chunk-tree-for-node' +export * from './types' diff --git a/packages/slate-react/src/chunking/reconcile-children.ts b/packages/slate-react/src/chunking/reconcile-children.ts new file mode 100644 index 000000000..f8911a171 --- /dev/null +++ b/packages/slate-react/src/chunking/reconcile-children.ts @@ -0,0 +1,127 @@ +import { Editor, Descendant } from 'slate' +import { ChunkTree, ChunkLeaf } from './types' +import { ChunkTreeHelper, ChunkTreeHelperOptions } from './chunk-tree-helper' +import { ChildrenHelper } from './children-helper' + +export interface ReconcileOptions extends ChunkTreeHelperOptions { + chunkTree: ChunkTree + children: Descendant[] + chunkSize: number + rerenderChildren?: number[] + onInsert?: (node: Descendant, index: number) => void + onUpdate?: (node: Descendant, index: number) => void + onIndexChange?: (node: Descendant, index: number) => void + debug?: boolean +} + +/** + * Update the chunk tree to match the children array, inserting, removing and + * updating differing nodes + */ +export const reconcileChildren = ( + editor: Editor, + { + chunkTree, + children, + chunkSize, + rerenderChildren = [], + onInsert, + onUpdate, + onIndexChange, + debug, + }: ReconcileOptions +) => { + chunkTree.modifiedChunks.clear() + + const chunkTreeHelper = new ChunkTreeHelper(chunkTree, { chunkSize, debug }) + const childrenHelper = new ChildrenHelper(editor, children) + + let treeLeaf: ChunkLeaf | null + + // Read leaves from the tree one by one, each one representing a single Slate + // node. Each leaf from the tree is compared to the current node in the + // children array to determine whether nodes have been inserted, removed or + // updated. + while ((treeLeaf = chunkTreeHelper.readLeaf())) { + // Check where the tree node appears in the children array. In the most + // common case (where no insertions or removals have occurred), this will be + // 0. If the node has been removed, this will be -1. If new nodes have been + // inserted before the node, or if the node has been moved to a later + // position in the same children array, this will be a positive number. + const lookAhead = childrenHelper.lookAhead(treeLeaf.node, treeLeaf.key) + + // If the node was moved, we want to remove it and insert it later, rather + // then re-inserting all intermediate nodes before it. + const wasMoved = lookAhead > 0 && chunkTree.movedNodeKeys.has(treeLeaf.key) + + // If the tree leaf was moved or removed, remove it + if (lookAhead === -1 || wasMoved) { + chunkTreeHelper.remove() + continue + } + + // Get the matching Slate node and any nodes that may have been inserted + // prior to it. Insert these into the chunk tree. + const insertedChildrenStartIndex = childrenHelper.pointerIndex + const insertedChildren = childrenHelper.read(lookAhead + 1) + const matchingChild = insertedChildren.pop()! + + if (insertedChildren.length) { + const leavesToInsert = childrenHelper.toChunkLeaves( + insertedChildren, + insertedChildrenStartIndex + ) + + chunkTreeHelper.insertBefore(leavesToInsert) + + insertedChildren.forEach((node, relativeIndex) => { + onInsert?.(node, insertedChildrenStartIndex + relativeIndex) + }) + } + + const matchingChildIndex = childrenHelper.pointerIndex - 1 + + // Make sure the chunk tree contains the most recent version of the Slate + // node + if (treeLeaf.node !== matchingChild) { + treeLeaf.node = matchingChild + chunkTreeHelper.invalidateChunk() + onUpdate?.(matchingChild, matchingChildIndex) + } + + // Update the index if it has changed + if (treeLeaf.index !== matchingChildIndex) { + treeLeaf.index = matchingChildIndex + onIndexChange?.(matchingChild, matchingChildIndex) + } + + // Manually invalidate chunks containing specific children that we want to + // re-render + if (rerenderChildren.includes(matchingChildIndex)) { + chunkTreeHelper.invalidateChunk() + } + } + + // If there are still Slate nodes remaining from the children array that were + // not matched to nodes in the tree, insert them at the end of the tree + if (!childrenHelper.reachedEnd) { + const remainingChildren = childrenHelper.remaining() + + const leavesToInsert = childrenHelper.toChunkLeaves( + remainingChildren, + childrenHelper.pointerIndex + ) + + // Move the pointer back to the final leaf in the tree, or the start of the + // tree if the tree is currently empty + chunkTreeHelper.returnToPreviousLeaf() + + chunkTreeHelper.insertAfter(leavesToInsert) + + remainingChildren.forEach((node, relativeIndex) => { + onInsert?.(node, childrenHelper.pointerIndex + relativeIndex) + }) + } + + chunkTree.movedNodeKeys.clear() +} diff --git a/packages/slate-react/src/chunking/types.ts b/packages/slate-react/src/chunking/types.ts new file mode 100644 index 000000000..b86033184 --- /dev/null +++ b/packages/slate-react/src/chunking/types.ts @@ -0,0 +1,52 @@ +import { Descendant } from 'slate' +import { Key } from 'slate-dom' + +export interface ChunkTree { + type: 'root' + children: ChunkDescendant[] + + /** + * The keys of any Slate nodes that have been moved using move_node since the + * last render + * + * Detecting when a node has been moved to a different position in the + * children array is impossible to do efficiently while reconciling the chunk + * tree. This interferes with the reconciliation logic since it is treated as + * if the intermediate nodes were inserted and removed, causing them to be + * re-chunked unnecessarily. + * + * This set is used to detect when a node has been moved so that this case + * can be handled correctly and efficiently. + */ + movedNodeKeys: Set + + /** + * The chunks whose descendants have been modified during the most recent + * reconciliation + * + * Used to determine when the otherwise memoized React components for each + * chunk should be re-rendered. + */ + modifiedChunks: Set +} + +export interface Chunk { + type: 'chunk' + key: Key + parent: ChunkAncestor + children: ChunkDescendant[] +} + +// A chunk leaf is unrelated to a Slate leaf; it is a leaf of the chunk tree, +// containing a single element that is a child of the Slate node the chunk tree +// belongs to. +export interface ChunkLeaf { + type: 'leaf' + key: Key + node: Descendant + index: number +} + +export type ChunkAncestor = ChunkTree | Chunk +export type ChunkDescendant = Chunk | ChunkLeaf +export type ChunkNode = ChunkTree | Chunk | ChunkLeaf diff --git a/packages/slate-react/src/components/chunk-tree.tsx b/packages/slate-react/src/components/chunk-tree.tsx new file mode 100644 index 000000000..d55b8c024 --- /dev/null +++ b/packages/slate-react/src/components/chunk-tree.tsx @@ -0,0 +1,65 @@ +import React, { Fragment } from 'react' +import { Element } from 'slate' +import { Key } from 'slate-dom' +import { RenderChunkProps } from './editable' +import { + Chunk as TChunk, + ChunkAncestor as TChunkAncestor, + ChunkTree as TChunkTree, +} from '../chunking' + +const defaultRenderChunk = ({ children }: RenderChunkProps) => children + +const ChunkAncestor = (props: { + root: TChunkTree + ancestor: C + renderElement: (node: Element, index: number, key: Key) => JSX.Element + renderChunk?: (props: RenderChunkProps) => JSX.Element +}) => { + const { + root, + ancestor, + renderElement, + renderChunk = defaultRenderChunk, + } = props + + return ancestor.children.map(chunkNode => { + if (chunkNode.type === 'chunk') { + const key = chunkNode.key.id + + const renderedChunk = renderChunk({ + highest: ancestor === root, + lowest: chunkNode.children.some(c => c.type === 'leaf'), + attributes: { 'data-slate-chunk': true }, + children: ( + + ), + }) + + return {renderedChunk} + } + + // Only blocks containing no inlines are chunked + const element = chunkNode.node as Element + + return renderElement(element, chunkNode.index, chunkNode.key) + }) +} + +const ChunkTree = ChunkAncestor + +const MemoizedChunk = React.memo( + ChunkAncestor, + (prev, next) => + prev.root === next.root && + prev.renderElement === next.renderElement && + prev.renderChunk === next.renderChunk && + !next.root.modifiedChunks.has(next.ancestor) +) + +export default ChunkTree diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index c11a9a932..8e8a6c41e 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -27,7 +27,7 @@ import { } from 'slate' import { useAndroidInputManager } from '../hooks/android-input-manager/use-android-input-manager' import useChildren from '../hooks/use-children' -import { DecorateContext } from '../hooks/use-decorate' +import { DecorateContext, useDecorateContext } from '../hooks/use-decorations' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' import { ReadOnlyContext } from '../hooks/use-read-only' import { useSlate } from '../hooks/use-slate' @@ -77,6 +77,7 @@ import { import { RestoreDOM } from './restore-dom/restore-dom' import { AndroidInputManager } from '../hooks/android-input-manager/android-input-manager' import { ComposingContext } from '../hooks/use-composing' +import { useFlushDeferredSelectorsOnRender } from '../hooks/use-slate-selector' type DeferredOperation = () => void @@ -100,6 +101,18 @@ export interface RenderElementProps { } } +/** + * `RenderChunkProps` are passed to the `renderChunk` handler + */ +export interface RenderChunkProps { + highest: boolean + lowest: boolean + children: any + attributes: { + 'data-slate-chunk': true + } +} + /** * `RenderLeafProps` are passed to the `renderLeaf` handler. */ @@ -145,6 +158,7 @@ export type EditableProps = { role?: string style?: React.CSSProperties renderElement?: (props: RenderElementProps) => JSX.Element + renderChunk?: (props: RenderChunkProps) => JSX.Element renderLeaf?: (props: RenderLeafProps) => JSX.Element renderText?: (props: RenderTextProps) => JSX.Element renderPlaceholder?: (props: RenderPlaceholderProps) => JSX.Element @@ -170,6 +184,7 @@ export const Editable = forwardRef( placeholder, readOnly = false, renderElement, + renderChunk, renderLeaf, renderText, renderPlaceholder = defaultRenderPlaceholder, @@ -210,6 +225,11 @@ export const Editable = forwardRef( // The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it // needs to be manually focused. + // + // If this stops working in Firefox, make sure nothing is causing this + // component to re-render during the initial mount. If the DOM selection is + // set by `useIsomorphicLayoutEffect` before `onDOMSelectionChange` updates + // `editor.selection`, the DOM selection can be removed accidentally. useEffect(() => { if (ref.current && autoFocus) { ref.current.focus() @@ -920,6 +940,7 @@ export const Editable = forwardRef( }, [scheduleOnDOMSelectionChange, state]) const decorations = decorate([editor, []]) + const decorateContext = useDecorateContext(decorate) const showPlaceholder = placeholder && @@ -999,10 +1020,12 @@ export const Editable = forwardRef( }) }) + useFlushDeferredSelectorsOnRender() + return ( - + diff --git a/packages/slate-react/src/components/element.tsx b/packages/slate-react/src/components/element.tsx index 37a4b83cc..1c553ed07 100644 --- a/packages/slate-react/src/components/element.tsx +++ b/packages/slate-react/src/components/element.tsx @@ -1,13 +1,7 @@ import getDirection from 'direction' import React, { useCallback } from 'react' import { JSX } from 'react' -import { - Editor, - Element as SlateElement, - Node, - Range, - DecoratedRange, -} from 'slate' +import { Editor, Element as SlateElement, Node, DecoratedRange } from 'slate' import { ReactEditor, useReadOnly, useSlateStatic } from '..' import useChildren from '../hooks/use-children' import { isElementDecorationsEqual } from 'slate-dom' @@ -19,6 +13,7 @@ import { NODE_TO_PARENT, } from 'slate-dom' import { + RenderChunkProps, RenderElementProps, RenderLeafProps, RenderPlaceholderProps, @@ -26,6 +21,11 @@ import { } from './editable' import Text from './text' +import { useDecorations } from '../hooks/use-decorations' + +const defaultRenderElement = (props: RenderElementProps) => ( + +) /** * Element. @@ -35,23 +35,24 @@ const Element = (props: { decorations: DecoratedRange[] element: SlateElement renderElement?: (props: RenderElementProps) => JSX.Element + renderChunk?: (props: RenderChunkProps) => JSX.Element renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element renderText?: (props: RenderTextProps) => JSX.Element renderLeaf?: (props: RenderLeafProps) => JSX.Element - selection: Range | null }) => { const { - decorations, + decorations: parentDecorations, element, - renderElement = (p: RenderElementProps) => , + renderElement = defaultRenderElement, + renderChunk, renderPlaceholder, renderLeaf, renderText, - selection, } = props const editor = useSlateStatic() const readOnly = useReadOnly() const isInline = editor.isInline(element) + const decorations = useDecorations(element, parentDecorations) const key = ReactEditor.findKey(editor, element) const ref = useCallback( (ref: HTMLElement | null) => { @@ -72,10 +73,10 @@ const Element = (props: { decorations, node: element, renderElement, + renderChunk, renderPlaceholder, renderLeaf, renderText, - selection, }) // Attributes that the developer must mix into the element in their @@ -149,14 +150,11 @@ const MemoizedElement = React.memo(Element, (prev, next) => { return ( prev.element === next.element && prev.renderElement === next.renderElement && + prev.renderChunk === next.renderChunk && prev.renderText === next.renderText && prev.renderLeaf === next.renderLeaf && prev.renderPlaceholder === next.renderPlaceholder && - isElementDecorationsEqual(prev.decorations, next.decorations) && - (prev.selection === next.selection || - (!!prev.selection && - !!next.selection && - Range.equals(prev.selection, next.selection))) + isElementDecorationsEqual(prev.decorations, next.decorations) ) }) diff --git a/packages/slate-react/src/components/leaf.tsx b/packages/slate-react/src/components/leaf.tsx index cee5c2d0c..10ecc6d1c 100644 --- a/packages/slate-react/src/components/leaf.tsx +++ b/packages/slate-react/src/components/leaf.tsx @@ -9,11 +9,7 @@ import { JSX } from 'react' import { Element, LeafPosition, Text } from 'slate' import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer' import String from './string' -import { - PLACEHOLDER_SYMBOL, - EDITOR_TO_PLACEHOLDER_ELEMENT, - EDITOR_TO_FORCE_RENDER, -} from 'slate-dom' +import { PLACEHOLDER_SYMBOL, EDITOR_TO_PLACEHOLDER_ELEMENT } from 'slate-dom' import { RenderLeafProps, RenderPlaceholderProps } from './editable' import { useSlateStatic } from '../hooks/use-slate-static' import { IS_WEBKIT, IS_ANDROID } from 'slate-dom' @@ -43,6 +39,8 @@ function clearTimeoutRef(timeoutRef: MutableRefObject) { } } +const defaultRenderLeaf = (props: RenderLeafProps) => + /** * Individual leaves in a text node with unique formatting. */ @@ -61,7 +59,7 @@ const Leaf = (props: { text, parent, renderPlaceholder, - renderLeaf = (props: RenderLeafProps) => , + renderLeaf = defaultRenderLeaf, leafPosition, } = props diff --git a/packages/slate-react/src/components/slate.tsx b/packages/slate-react/src/components/slate.tsx index 1ed3af02d..45dff3056 100644 --- a/packages/slate-react/src/components/slate.tsx +++ b/packages/slate-react/src/components/slate.tsx @@ -3,7 +3,6 @@ import { Descendant, Editor, Node, Operation, Scrubber, Selection } from 'slate' import { EDITOR_TO_ON_CHANGE } from 'slate-dom' import { FocusedContext } from '../hooks/use-focused' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' -import { SlateContext, SlateContextValue } from '../hooks/use-slate' import { useSelectorContext, SlateSelectorContext, @@ -35,7 +34,8 @@ export const Slate = (props: { ...rest } = props - const [context, setContext] = React.useState(() => { + // Run once on first mount, but before `useEffect` or render + React.useState(() => { if (!Node.isNodeList(initialValue)) { throw new Error( `[Slate] initialValue is invalid! Expected a list of elements but got: ${Scrubber.stringify( @@ -43,18 +43,19 @@ export const Slate = (props: { )}` ) } + if (!Editor.isEditor(editor)) { throw new Error( `[Slate] editor is invalid! You passed: ${Scrubber.stringify(editor)}` ) } + editor.children = initialValue Object.assign(editor, rest) - return { v: 0, editor } }) const { selectorContext, onChange: handleSelectorChange } = - useSelectorContext(editor) + useSelectorContext() const onContextChange = useCallback( (options?: { operation?: Operation }) => { @@ -70,11 +71,7 @@ export const Slate = (props: { onValueChange?.(editor.children) } - setContext(prevContext => ({ - v: prevContext.v + 1, - editor, - })) - handleSelectorChange(editor) + handleSelectorChange() }, [editor, handleSelectorChange, onChange, onSelectionChange, onValueChange] ) @@ -117,13 +114,11 @@ export const Slate = (props: { return ( - - - - {children} - - - + + + {children} + + ) } diff --git a/packages/slate-react/src/components/text.tsx b/packages/slate-react/src/components/text.tsx index e592faf81..d9d436b22 100644 --- a/packages/slate-react/src/components/text.tsx +++ b/packages/slate-react/src/components/text.tsx @@ -13,6 +13,9 @@ import { RenderTextProps, } from './editable' import Leaf from './leaf' +import { useDecorations } from '../hooks/use-decorations' + +const defaultRenderText = (props: RenderTextProps) => /** * Text. @@ -28,16 +31,18 @@ const Text = (props: { text: SlateText }) => { const { - decorations, + decorations: parentDecorations, isLast, parent, renderPlaceholder, renderLeaf, - renderText = (props: RenderTextProps) => , + renderText = defaultRenderText, text, } = props + const editor = useSlateStatic() const ref = useRef(null) + const decorations = useDecorations(text, parentDecorations) const decoratedLeaves = SlateText.decorations(text, decorations) const key = ReactEditor.findKey(editor, text) const children = [] diff --git a/packages/slate-react/src/hooks/use-children.tsx b/packages/slate-react/src/hooks/use-children.tsx index ec170a54e..66fecf3d6 100644 --- a/packages/slate-react/src/hooks/use-children.tsx +++ b/packages/slate-react/src/hooks/use-children.tsx @@ -1,13 +1,8 @@ -import React from 'react' -import { - Ancestor, - Descendant, - Editor, - Element, - Range, - DecoratedRange, -} from 'slate' +import React, { useCallback, useRef } from 'react' +import { Ancestor, Editor, Element, DecoratedRange, Text } from 'slate' +import { Key, isElementDecorationsEqual } from 'slate-dom' import { + RenderChunkProps, RenderElementProps, RenderLeafProps, RenderPlaceholderProps, @@ -17,10 +12,16 @@ import { import ElementComponent from '../components/element' import TextComponent from '../components/text' import { ReactEditor } from '../plugin/react-editor' -import { IS_NODE_MAP_DIRTY, NODE_TO_INDEX, NODE_TO_PARENT } from 'slate-dom' -import { useDecorate } from './use-decorate' -import { SelectedContext } from './use-selected' +import { + IS_NODE_MAP_DIRTY, + NODE_TO_INDEX, + NODE_TO_PARENT, + splitDecorationsByChild, +} from 'slate-dom' import { useSlateStatic } from './use-slate-static' +import { getChunkTreeForNode } from '../chunking' +import ChunkTree from '../components/chunk-tree' +import { ElementContext } from './use-element' /** * Children. @@ -30,81 +31,160 @@ const useChildren = (props: { decorations: DecoratedRange[] node: Ancestor renderElement?: (props: RenderElementProps) => JSX.Element + renderChunk?: (props: RenderChunkProps) => JSX.Element renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element renderText?: (props: RenderTextProps) => JSX.Element renderLeaf?: (props: RenderLeafProps) => JSX.Element - selection: Range | null }) => { const { decorations, node, renderElement, + renderChunk, renderPlaceholder, renderText, renderLeaf, - selection, } = props - const decorate = useDecorate() const editor = useSlateStatic() IS_NODE_MAP_DIRTY.set(editor as ReactEditor, false) - const path = ReactEditor.findPath(editor, node) - const children = [] - const isLeafBlock = - Element.isElement(node) && - !editor.isInline(node) && - Editor.hasInlines(editor, node) - for (let i = 0; i < node.children.length; i++) { - const p = path.concat(i) - const n = node.children[i] as Descendant - const key = ReactEditor.findKey(editor, n) - const range = Editor.range(editor, p) - const sel = selection && Range.intersection(range, selection) - const ds = decorate([n, p]) + const isEditor = Editor.isEditor(node) + const isBlock = !isEditor && Element.isElement(node) && !editor.isInline(node) + const isLeafBlock = isBlock && Editor.hasInlines(editor, node) + const chunkSize = isLeafBlock ? null : editor.getChunkSize(node) + const chunking = !!chunkSize - for (const dec of decorations) { - const d = Range.intersection(dec, range) + const { decorationsByChild, childrenToRedecorate } = useDecorationsByChild( + editor, + node, + decorations + ) - if (d) { - ds.push(d) - } - } + // Update the index and parent of each child. + // PERF: If chunking is enabled, this is done while traversing the chunk tree + // instead to eliminate unnecessary weak map operations. + if (!chunking) { + node.children.forEach((n, i) => { + NODE_TO_INDEX.set(n, i) + NODE_TO_PARENT.set(n, node) + }) + } - if (Element.isElement(n)) { - children.push( - + const renderElementComponent = useCallback( + (n: Element, i: number, cachedKey?: Key) => { + const key = cachedKey ?? ReactEditor.findKey(editor, n) + + return ( + - + ) - } else { - children.push( - - ) - } + }, + [ + editor, + decorationsByChild, + renderElement, + renderChunk, + renderPlaceholder, + renderLeaf, + renderText, + ] + ) - NODE_TO_INDEX.set(n, i) - NODE_TO_PARENT.set(n, node) + const renderTextComponent = (n: Text, i: number) => { + const key = ReactEditor.findKey(editor, n) + + return ( + + ) } - return children + if (!chunking) { + return node.children.map((n, i) => + Text.isText(n) ? renderTextComponent(n, i) : renderElementComponent(n, i) + ) + } + + const chunkTree = getChunkTreeForNode(editor, node, { + reconcile: { + chunkSize, + rerenderChildren: childrenToRedecorate, + onInsert: (n, i) => { + NODE_TO_INDEX.set(n, i) + NODE_TO_PARENT.set(n, node) + }, + onUpdate: (n, i) => { + NODE_TO_INDEX.set(n, i) + NODE_TO_PARENT.set(n, node) + }, + onIndexChange: (n, i) => { + NODE_TO_INDEX.set(n, i) + }, + }, + }) + + return ( + + ) +} + +const useDecorationsByChild = ( + editor: Editor, + node: Ancestor, + decorations: DecoratedRange[] +) => { + const decorationsByChild = splitDecorationsByChild(editor, node, decorations) + + // The value we return is a mutable array of `DecoratedRange[]` arrays. This + // lets us avoid passing an immutable array of decorations for each child into + // `ChunkTree` using props. Each `DecoratedRange[]` is only updated if the + // decorations at that index have changed, which speeds up the equality check + // for the `decorations` prop in the memoized `Element` and `Text` components. + const mutableDecorationsByChild = useRef(decorationsByChild).current + + // Track the list of child indices whose decorations have changed, so that we + // can tell the chunk tree to re-render these children. + const childrenToRedecorate: number[] = [] + + // Resize the mutable array to match the latest result + mutableDecorationsByChild.length = decorationsByChild.length + + for (let i = 0; i < decorationsByChild.length; i++) { + const decorations = decorationsByChild[i] + + const previousDecorations: DecoratedRange[] | null = + mutableDecorationsByChild[i] ?? null + + if (!isElementDecorationsEqual(previousDecorations, decorations)) { + mutableDecorationsByChild[i] = decorations + childrenToRedecorate.push(i) + } + } + + return { decorationsByChild: mutableDecorationsByChild, childrenToRedecorate } } export default useChildren diff --git a/packages/slate-react/src/hooks/use-decorate.ts b/packages/slate-react/src/hooks/use-decorate.ts deleted file mode 100644 index 7155efe87..000000000 --- a/packages/slate-react/src/hooks/use-decorate.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createContext, useContext } from 'react' -import { DecoratedRange, NodeEntry } from 'slate' - -/** - * A React context for sharing the `decorate` prop of the editable. - */ - -export const DecorateContext = createContext< - (entry: NodeEntry) => DecoratedRange[] ->(() => []) - -/** - * Get the current `decorate` prop of the editable. - */ - -export const useDecorate = (): ((entry: NodeEntry) => DecoratedRange[]) => { - return useContext(DecorateContext) -} diff --git a/packages/slate-react/src/hooks/use-decorations.ts b/packages/slate-react/src/hooks/use-decorations.ts new file mode 100644 index 000000000..b10b88a35 --- /dev/null +++ b/packages/slate-react/src/hooks/use-decorations.ts @@ -0,0 +1,81 @@ +import { createContext, useCallback, useContext, useMemo, useRef } from 'react' +import { DecoratedRange, Descendant, NodeEntry, Text } from 'slate' +import { isTextDecorationsEqual, isElementDecorationsEqual } from 'slate-dom' +import { useSlateStatic } from './use-slate-static' +import { ReactEditor } from '../plugin/react-editor' +import { useGenericSelector } from './use-generic-selector' +import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect' + +type Callback = () => void + +/** + * A React context for sharing the `decorate` prop of the editable and + * subscribing to changes on this prop. + */ + +export const DecorateContext = createContext<{ + decorate: (entry: NodeEntry) => DecoratedRange[] + addEventListener: (callback: Callback) => () => void +}>({} as any) + +export const useDecorations = ( + node: Descendant, + parentDecorations: DecoratedRange[] +): DecoratedRange[] => { + const editor = useSlateStatic() + const { decorate, addEventListener } = useContext(DecorateContext) + + // Not memoized since we want nodes to be decorated on each render + const selector = () => { + const path = ReactEditor.findPath(editor, node) + return decorate([node, path]) + } + + const equalityFn = Text.isText(node) + ? isTextDecorationsEqual + : isElementDecorationsEqual + + const [decorations, update] = useGenericSelector(selector, equalityFn) + + useIsomorphicLayoutEffect(() => { + const unsubscribe = addEventListener(update) + update() + return unsubscribe + }, [addEventListener, update]) + + return useMemo( + () => [...decorations, ...parentDecorations], + [decorations, parentDecorations] + ) +} + +export const useDecorateContext = ( + decorateProp: (entry: NodeEntry) => DecoratedRange[] +) => { + const eventListeners = useRef(new Set()) + + const latestDecorate = useRef(decorateProp) + + useIsomorphicLayoutEffect(() => { + latestDecorate.current = decorateProp + eventListeners.current.forEach(listener => listener()) + }, [decorateProp]) + + const decorate = useCallback( + (entry: NodeEntry) => latestDecorate.current(entry), + [] + ) + + const addEventListener = useCallback((callback: Callback) => { + eventListeners.current.add(callback) + + return () => { + eventListeners.current.delete(callback) + } + }, []) + + return useMemo( + () => ({ decorate, addEventListener }), + [decorate, addEventListener] + ) +} diff --git a/packages/slate-react/src/hooks/use-element.ts b/packages/slate-react/src/hooks/use-element.ts new file mode 100644 index 000000000..21585eebc --- /dev/null +++ b/packages/slate-react/src/hooks/use-element.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react' +import { Element } from 'slate' + +export const ElementContext = createContext(null) + +/** + * Get the current element. + */ + +export const useElement = (): Element => { + const context = useContext(ElementContext) + + if (!context) { + throw new Error( + 'The `useElement` hook must be used inside `renderElement`.' + ) + } + + return context +} + +/** + * Get the current element, or return null if not inside `renderElement`. + */ +export const useElementIf = () => useContext(ElementContext) diff --git a/packages/slate-react/src/hooks/use-generic-selector.tsx b/packages/slate-react/src/hooks/use-generic-selector.tsx new file mode 100644 index 000000000..7bb8705af --- /dev/null +++ b/packages/slate-react/src/hooks/use-generic-selector.tsx @@ -0,0 +1,92 @@ +import { useCallback, useReducer, useRef } from 'react' + +/** + * Create a selector that updates when an `update` function is called, and + * which only causes the component to render when the result of `selector` + * differs from the previous result according to `equalityFn`. + * + * If `selector` is memoized using `useCallback`, then it will only be called + * when it changes or when `update` is called. Otherwise, `selector` will be + * called every time the component renders. + * + * @example + * const [state, update] = useGenericSelector(selector, equalityFn) + * + * useIsomorphicLayoutEffect(() => { + * return addEventListener(update) + * }, [addEventListener, update]) + * + * return state + */ + +export function useGenericSelector( + selector: () => T, + equalityFn: (a: T | null, b: T) => boolean +): [state: T, update: () => void] { + const [, forceRender] = useReducer(s => s + 1, 0) + + const latestSubscriptionCallbackError = useRef() + const latestSelector = useRef<() => T>(() => null as any) + const latestSelectedState = useRef(null) + let selectedState: T + + try { + if ( + selector !== latestSelector.current || + latestSubscriptionCallbackError.current + ) { + const selectorResult = selector() + + if (equalityFn(latestSelectedState.current, selectorResult)) { + selectedState = latestSelectedState.current as T + } else { + selectedState = selectorResult + } + } else { + selectedState = latestSelectedState.current as T + } + } catch (err) { + if (latestSubscriptionCallbackError.current && isError(err)) { + err.message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n` + } + + throw err + } + + latestSelector.current = selector + latestSelectedState.current = selectedState + latestSubscriptionCallbackError.current = undefined + + const update = useCallback(() => { + try { + const newSelectedState = latestSelector.current() + + if (equalityFn(latestSelectedState.current, newSelectedState)) { + return + } + + latestSelectedState.current = newSelectedState + } catch (err) { + // we ignore all errors here, since when the component + // is re-rendered, the selectors are called again, and + // will throw again, if neither props nor store state + // changed + if (err instanceof Error) { + latestSubscriptionCallbackError.current = err + } else { + latestSubscriptionCallbackError.current = new Error(String(err)) + } + } + + forceRender() + + // don't rerender on equalityFn change since we want to be able to define it inline + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return [selectedState, update] +} + +function isError(error: any): error is Error { + return error instanceof Error +} diff --git a/packages/slate-react/src/hooks/use-selected.ts b/packages/slate-react/src/hooks/use-selected.ts index fd96418a7..d26393f65 100644 --- a/packages/slate-react/src/hooks/use-selected.ts +++ b/packages/slate-react/src/hooks/use-selected.ts @@ -1,15 +1,37 @@ -import { createContext, useContext } from 'react' - -/** - * A React context for sharing the `selected` state of an element. - */ - -export const SelectedContext = createContext(false) +import { useCallback } from 'react' +import { Editor, Range } from 'slate' +import { useElementIf } from './use-element' +import { useSlateSelector } from './use-slate-selector' +import { ReactEditor } from '../plugin/react-editor' /** * Get the current `selected` state of an element. */ export const useSelected = (): boolean => { - return useContext(SelectedContext) + const element = useElementIf() + + // Breaking the rules of hooks is fine here since `!element` will remain true + // or false for the entire lifetime of the component this hook is called from. + // TODO: Decide if we want to throw an error instead when calling + // `useSelected` outside of an element (potentially a breaking change). + if (!element) return false + + // eslint-disable-next-line react-hooks/rules-of-hooks + const selector = useCallback( + (editor: Editor) => { + if (!editor.selection) return false + const path = ReactEditor.findPath(editor, element) + const range = Editor.range(editor, path) + return !!Range.intersection(range, editor.selection) + }, + [element] + ) + + // eslint-disable-next-line react-hooks/rules-of-hooks + return useSlateSelector(selector, undefined, { + // Defer the selector until after `Editable` has rendered so that the path + // will be accurate. + deferred: true, + }) } diff --git a/packages/slate-react/src/hooks/use-slate-selector.tsx b/packages/slate-react/src/hooks/use-slate-selector.tsx index 288b40481..c43ebd62a 100644 --- a/packages/slate-react/src/hooks/use-slate-selector.tsx +++ b/packages/slate-react/src/hooks/use-slate-selector.tsx @@ -1,120 +1,78 @@ -import { - createContext, - useCallback, - useContext, - useMemo, - useReducer, - useRef, -} from 'react' +import { createContext, useCallback, useContext, useMemo, useRef } from 'react' import { Editor } from 'slate' import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect' +import { useSlateStatic } from './use-slate-static' +import { useGenericSelector } from './use-generic-selector' -function isError(error: any): error is Error { - return error instanceof Error +type Callback = () => void + +export interface SlateSelectorOptions { + /** + * If true, defer calling the selector function until after `Editable` has + * finished rendering. This ensures that `ReactEditor.findPath` won't return + * an outdated path if called inside the selector. + */ + deferred?: boolean } -type EditorChangeHandler = (editor: Editor) => void /** - * A React context for sharing the editor selector context in a way to control rerenders + * A React context for sharing the editor selector context in a way to control + * re-renders. */ export const SlateSelectorContext = createContext<{ - getSlate: () => Editor - addEventListener: (callback: EditorChangeHandler) => () => void + addEventListener: ( + callback: Callback, + options?: SlateSelectorOptions + ) => () => void + flushDeferred: () => void }>({} as any) const refEquality = (a: any, b: any) => a === b /** - * use redux style selectors to prevent rerendering on every keystroke. - * Bear in mind rerendering can only prevented if the returned value is a value type or for reference types (e.g. objects and arrays) add a custom equality function. + * Use redux style selectors to prevent re-rendering on every keystroke. * - * Example: - * ``` - * const isSelectionActive = useSlateSelector(editor => Boolean(editor.selection)); - * ``` + * Bear in mind re-rendering can only prevented if the returned value is a value + * type or for reference types (e.g. objects and arrays) add a custom equality + * function. + * + * If `selector` is memoized using `useCallback`, then it will only be called + * when it or the editor state changes. Otherwise, `selector` will be called + * every time the component renders. + * + * @example + * const isSelectionActive = useSlateSelector(editor => Boolean(editor.selection)) */ + export function useSlateSelector( selector: (editor: Editor) => T, - equalityFn: (a: T, b: T) => boolean = refEquality -) { - const [, forceRender] = useReducer(s => s + 1, 0) + equalityFn: (a: T | null, b: T) => boolean = refEquality, + { deferred }: SlateSelectorOptions = {} +): T { const context = useContext(SlateSelectorContext) if (!context) { throw new Error( `The \`useSlateSelector\` hook must be used inside the component's context.` ) } - const { getSlate, addEventListener } = context + const { addEventListener } = context - const latestSubscriptionCallbackError = useRef() - const latestSelector = useRef<(editor: Editor) => T>(() => null as any) - const latestSelectedState = useRef(null as any as T) - let selectedState: T - - try { - if ( - selector !== latestSelector.current || - latestSubscriptionCallbackError.current - ) { - const selectorResult = selector(getSlate()) - - if (equalityFn(latestSelectedState.current, selectorResult)) { - selectedState = latestSelectedState.current - } else { - selectedState = selectorResult - } - } else { - selectedState = latestSelectedState.current - } - } catch (err) { - if (latestSubscriptionCallbackError.current && isError(err)) { - err.message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n` - } - - throw err - } - useIsomorphicLayoutEffect(() => { - latestSelector.current = selector - latestSelectedState.current = selectedState - latestSubscriptionCallbackError.current = undefined - }) - - useIsomorphicLayoutEffect( - () => { - function checkForUpdates() { - try { - const newSelectedState = latestSelector.current(getSlate()) - - if (equalityFn(newSelectedState, latestSelectedState.current)) { - return - } - - latestSelectedState.current = newSelectedState - } catch (err) { - // we ignore all errors here, since when the component - // is re-rendered, the selectors are called again, and - // will throw again, if neither props nor store state - // changed - if (err instanceof Error) { - latestSubscriptionCallbackError.current = err - } else { - latestSubscriptionCallbackError.current = new Error(String(err)) - } - } - - forceRender() - } - - const unsubscribe = addEventListener(checkForUpdates) - - checkForUpdates() - - return () => unsubscribe() - }, - // don't rerender on equalityFn change since we want to be able to define it inline - [addEventListener, getSlate] + const editor = useSlateStatic() + const genericSelector = useCallback( + () => selector(editor), + [editor, selector] ) + const [selectedState, update] = useGenericSelector( + genericSelector, + equalityFn + ) + + useIsomorphicLayoutEffect(() => { + const unsubscribe = addEventListener(update, { deferred }) + update() + return unsubscribe + }, [addEventListener, update, deferred]) return selectedState } @@ -122,33 +80,49 @@ export function useSlateSelector( /** * Create selector context with editor updating on every editor change */ -export function useSelectorContext(editor: Editor) { - const eventListeners = useRef([]).current - const slateRef = useRef<{ - editor: Editor - }>({ - editor, - }).current - const onChange = useCallback( - (editor: Editor) => { - slateRef.editor = editor - eventListeners.forEach((listener: EditorChangeHandler) => - listener(editor) - ) +export function useSelectorContext() { + const eventListeners = useRef(new Set()) + const deferredEventListeners = useRef(new Set()) + + const onChange = useCallback(() => { + eventListeners.current.forEach(listener => listener()) + }, []) + + const flushDeferred = useCallback(() => { + deferredEventListeners.current.forEach(listener => listener()) + deferredEventListeners.current.clear() + }, []) + + const addEventListener = useCallback( + ( + callbackProp: Callback, + { deferred = false }: SlateSelectorOptions = {} + ) => { + const callback = deferred + ? () => deferredEventListeners.current.add(callbackProp) + : callbackProp + + eventListeners.current.add(callback) + + return () => { + eventListeners.current.delete(callback) + } }, - [eventListeners, slateRef] + [] + ) + + const selectorContext = useMemo( + () => ({ + addEventListener, + flushDeferred, + }), + [addEventListener, flushDeferred] ) - const selectorContext = useMemo(() => { - return { - getSlate: () => slateRef.editor, - addEventListener: (callback: EditorChangeHandler) => { - eventListeners.push(callback) - return () => { - eventListeners.splice(eventListeners.indexOf(callback), 1) - } - }, - } - }, [eventListeners, slateRef]) return { selectorContext, onChange } } + +export function useFlushDeferredSelectorsOnRender() { + const { flushDeferred } = useContext(SlateSelectorContext) + useIsomorphicLayoutEffect(flushDeferred) +} diff --git a/packages/slate-react/src/hooks/use-slate.tsx b/packages/slate-react/src/hooks/use-slate.tsx index 27e84e771..bf6acf0dc 100644 --- a/packages/slate-react/src/hooks/use-slate.tsx +++ b/packages/slate-react/src/hooks/use-slate.tsx @@ -1,47 +1,64 @@ -import { createContext, useContext } from 'react' +import { MutableRefObject, useContext, useMemo, useReducer } from 'react' import { Editor } from 'slate' -import { ReactEditor } from '../plugin/react-editor' +import { SlateSelectorContext } from './use-slate-selector' +import { useSlateStatic } from './use-slate-static' +import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect' /** - * A React context for sharing the editor object, in a way that re-renders the - * context whenever changes occur. - */ - -export interface SlateContextValue { - v: number - editor: ReactEditor -} - -export const SlateContext = createContext<{ - v: number - editor: ReactEditor -} | null>(null) - -/** - * Get the current editor object from the React context. + * Get the current editor object and re-render whenever it changes. */ export const useSlate = (): Editor => { - const context = useContext(SlateContext) + const { addEventListener } = useContext(SlateSelectorContext) + const [, forceRender] = useReducer(s => s + 1, 0) - if (!context) { + if (!addEventListener) { throw new Error( `The \`useSlate\` hook must be used inside the component's context.` ) } - const { editor } = context - return editor + useIsomorphicLayoutEffect( + () => addEventListener(forceRender), + [addEventListener] + ) + + return useSlateStatic() } +const EDITOR_TO_V = new WeakMap>() + +const getEditorVersionRef = (editor: Editor): MutableRefObject => { + let v = EDITOR_TO_V.get(editor) + + if (v) { + return v + } + + v = { current: 0 } + EDITOR_TO_V.set(editor, v) + + // Register the `onChange` handler exactly once per editor + const { onChange } = editor + + editor.onChange = options => { + v!.current++ + onChange(options) + } + + return v +} + +/** + * Get the current editor object and its version, which increments on every + * change. + * + * @deprecated The `v` counter is no longer used except for this hook, and may + * be removed in a future version. + */ + export const useSlateWithV = (): { editor: Editor; v: number } => { - const context = useContext(SlateContext) - - if (!context) { - throw new Error( - `The \`useSlate\` hook must be used inside the component's context.` - ) - } - - return context + const editor = useSlate() + const vRef = useMemo(() => getEditorVersionRef(editor), [editor]) + return { editor, v: vRef.current } } diff --git a/packages/slate-react/src/index.ts b/packages/slate-react/src/index.ts index fee79b000..0f1ffce23 100644 --- a/packages/slate-react/src/index.ts +++ b/packages/slate-react/src/index.ts @@ -2,6 +2,7 @@ export { Editable, RenderElementProps, + RenderChunkProps, RenderLeafProps, RenderPlaceholderProps, DefaultPlaceholder, @@ -14,6 +15,7 @@ export { Slate } from './components/slate' // Hooks export { useEditor } from './hooks/use-editor' +export { useElement, useElementIf } from './hooks/use-element' export { useSlateStatic } from './hooks/use-slate-static' export { useComposing } from './hooks/use-composing' export { useFocused } from './hooks/use-focused' diff --git a/packages/slate-react/src/plugin/react-editor.ts b/packages/slate-react/src/plugin/react-editor.ts index ce199041f..5ab4a9952 100644 --- a/packages/slate-react/src/plugin/react-editor.ts +++ b/packages/slate-react/src/plugin/react-editor.ts @@ -1,10 +1,18 @@ +import { Ancestor } from 'slate' import { DOMEditor, type DOMEditorInterface } from 'slate-dom' /** * A React and DOM-specific version of the `Editor` interface. */ -export interface ReactEditor extends DOMEditor {} +export interface ReactEditor extends DOMEditor { + /** + * Determines the chunk size used by the children chunking optimization. If + * null is returned (which is the default), the chunking optimization is + * disabled. + */ + getChunkSize: (node: Ancestor) => number | null +} export interface ReactEditorInterface extends DOMEditorInterface {} diff --git a/packages/slate-react/src/plugin/with-react.ts b/packages/slate-react/src/plugin/with-react.ts index 033dfd07e..b8258e1a8 100644 --- a/packages/slate-react/src/plugin/with-react.ts +++ b/packages/slate-react/src/plugin/with-react.ts @@ -1,8 +1,9 @@ import ReactDOM from 'react-dom' -import { BaseEditor } from 'slate' +import { BaseEditor, Node } from 'slate' import { withDOM } from 'slate-dom' import { ReactEditor } from './react-editor' import { REACT_MAJOR_VERSION } from '../utils/environment' +import { getChunkTreeForNode } from '../chunking' /** * `withReact` adds React and DOM specific behaviors to the editor. @@ -20,7 +21,9 @@ export const withReact = ( e = withDOM(e, clipboardFormatKey) - const { onChange } = e + const { onChange, apply } = e + + e.getChunkSize = () => null e.onChange = options => { // COMPAT: React < 18 doesn't batch `setState` hook calls, which means @@ -38,5 +41,24 @@ export const withReact = ( }) } + // On move_node, if the chunking optimization is enabled for the parent of the + // node being moved, add the moved node to the movedNodeKeys set of the + // parent's chunk tree. + e.apply = operation => { + if (operation.type === 'move_node') { + const parent = Node.parent(e, operation.path) + const chunking = !!e.getChunkSize(parent) + + if (chunking) { + const node = Node.get(e, operation.path) + const chunkTree = getChunkTreeForNode(e, parent) + const key = ReactEditor.findKey(e, node) + chunkTree.movedNodeKeys.add(key) + } + } + + apply(operation) + } + return e } diff --git a/packages/slate-react/test/chunking.spec.ts b/packages/slate-react/test/chunking.spec.ts new file mode 100644 index 000000000..c8c2ed125 --- /dev/null +++ b/packages/slate-react/test/chunking.spec.ts @@ -0,0 +1,953 @@ +import { + Descendant, + Editor, + Element, + Node, + Transforms, + createEditor, +} from 'slate' +import { Key } from 'slate-dom' +import { ReactEditor, withReact } from '../src' +import { + Chunk, + ChunkAncestor, + ChunkDescendant, + ChunkLeaf, + ChunkNode, + ChunkTree, + KEY_TO_CHUNK_TREE, + getChunkTreeForNode, +} from '../src/chunking' +import { ReconcileOptions } from '../src/chunking/reconcile-children' + +const block = (text: string): Element => ({ children: [{ text }] }) + +const blocks = (count: number) => + Array.from( + { + length: count, + }, + (_, i) => block(i.toString()) + ) + +const reconcileEditor = ( + editor: ReactEditor, + options: Omit = {} +) => + getChunkTreeForNode(editor, editor, { + reconcile: { + chunkSize: 3, + debug: true, + ...options, + }, + }) + +type TreeShape = string | TreeShape[] + +const getTreeShape = (chunkNode: ChunkNode): TreeShape => { + if (chunkNode.type === 'leaf') { + return Node.string(chunkNode.node) + } + + return chunkNode.children.map(getTreeShape) +} + +const getChildrenAndTreeForShape = ( + editor: ReactEditor, + treeShape: TreeShape[] +): { children: Descendant[]; chunkTree: ChunkTree } => { + const children: Descendant[] = [] + + const shapeToNode = ( + ts: TreeShape, + parent: ChunkAncestor + ): ChunkDescendant => { + if (Array.isArray(ts)) { + const chunk: Chunk = { + type: 'chunk', + key: new Key(), + parent, + children: [], + } + + chunk.children = ts.map(child => shapeToNode(child, chunk)) + + return chunk + } + + const node = block(ts) + const index = children.length + children.push(node) + + return { + type: 'leaf', + key: ReactEditor.findKey(editor, node), + node, + index, + } + } + + const chunkTree: ChunkTree = { + type: 'root', + modifiedChunks: new Set(), + movedNodeKeys: new Set(), + children: [], + } + + chunkTree.children = treeShape.map(child => shapeToNode(child, chunkTree)) + + return { children, chunkTree } +} + +const withChunking = (editor: ReactEditor) => { + editor.getChunkSize = node => (Editor.isEditor(node) ? 3 : null) + return editor +} + +const createEditorWithShape = (treeShape: TreeShape[]) => { + const editor = withChunking(withReact(createEditor())) + const { children, chunkTree } = getChildrenAndTreeForShape(editor, treeShape) + editor.children = children + const key = ReactEditor.findKey(editor, editor) + KEY_TO_CHUNK_TREE.set(key, chunkTree) + return editor +} + +// https://stackoverflow.com/a/29450606 +const createPRNG = (seed: number) => { + const mask = 0xffffffff + let m_w = (123456789 + seed) & mask + let m_z = (987654321 - seed) & mask + + return () => { + m_z = (36969 * (m_z & 65535) + (m_z >>> 16)) & mask + m_w = (18000 * (m_w & 65535) + (m_w >>> 16)) & mask + + let result = ((m_z << 16) + (m_w & 65535)) >>> 0 + result /= 4294967296 + return result + } +} + +describe('getChunkTreeForNode', () => { + describe('chunking initial value', () => { + const getShapeForInitialCount = (count: number) => { + const editor = withChunking(withReact(createEditor())) + editor.children = blocks(count) + const chunkTree = reconcileEditor(editor) + return getTreeShape(chunkTree) + } + + it('returns empty tree for 0 children', () => { + expect(getShapeForInitialCount(0)).toEqual([]) + }) + + it('returns flat tree for 1 child', () => { + expect(getShapeForInitialCount(1)).toEqual(['0']) + }) + + it('returns flat tree for 3 children', () => { + expect(getShapeForInitialCount(3)).toEqual(['0', '1', '2']) + }) + + it('returns 1 layer of chunking for 4 children', () => { + expect(getShapeForInitialCount(4)).toEqual([['0', '1', '2'], ['3']]) + }) + + it('returns 1 layer of chunking for 9 children', () => { + expect(getShapeForInitialCount(9)).toEqual([ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7', '8'], + ]) + }) + + it('returns 2 layers of chunking for 10 children', () => { + expect(getShapeForInitialCount(10)).toEqual([ + [ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7', '8'], + ], + [['9']], + ]) + }) + + it('returns 2 layers of chunking for 27 children', () => { + expect(getShapeForInitialCount(27)).toEqual([ + [ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7', '8'], + ], + [ + ['9', '10', '11'], + ['12', '13', '14'], + ['15', '16', '17'], + ], + [ + ['18', '19', '20'], + ['21', '22', '23'], + ['24', '25', '26'], + ], + ]) + }) + + it('returns 3 layers of chunking for 28 children', () => { + expect(getShapeForInitialCount(28)).toEqual([ + [ + [ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7', '8'], + ], + [ + ['9', '10', '11'], + ['12', '13', '14'], + ['15', '16', '17'], + ], + [ + ['18', '19', '20'], + ['21', '22', '23'], + ['24', '25', '26'], + ], + ], + [[['27']]], + ]) + }) + + it('calls onInsert for initial children', () => { + const editor = withChunking(withReact(createEditor())) + editor.children = blocks(3) + + const onInsert = jest.fn() + reconcileEditor(editor, { onInsert }) + + expect(onInsert.mock.calls).toEqual([ + [editor.children[0], 0], + [editor.children[1], 1], + [editor.children[2], 2], + ]) + }) + + it('sets the index of each chunk leaf', () => { + const editor = withChunking(withReact(createEditor())) + editor.children = blocks(9) + + const chunkTree = reconcileEditor(editor) + const chunks = chunkTree.children as Chunk[] + const leaves = chunks.map(chunk => chunk.children) + + expect(leaves).toMatchObject([ + [{ index: 0 }, { index: 1 }, { index: 2 }], + [{ index: 3 }, { index: 4 }, { index: 5 }], + [{ index: 6 }, { index: 7 }, { index: 8 }], + ]) + }) + }) + + describe('inserting nodes', () => { + describe('in empty editor', () => { + it('inserts a single node', () => { + const editor = createEditorWithShape([]) + Transforms.insertNodes(editor, block('x'), { at: [0] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual(['x']) + }) + + it('inserts 27 nodes with 2 layers of chunking', () => { + const editor = createEditorWithShape([]) + Transforms.insertNodes(editor, blocks(27), { at: [0] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + [ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7', '8'], + ], + [ + ['9', '10', '11'], + ['12', '13', '14'], + ['15', '16', '17'], + ], + [ + ['18', '19', '20'], + ['21', '22', '23'], + ['24', '25', '26'], + ], + ]) + }) + + it('inserts 28 nodes with 3 layers of chunking', () => { + const editor = createEditorWithShape([]) + Transforms.insertNodes(editor, blocks(28), { at: [0] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + [ + [ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7', '8'], + ], + [ + ['9', '10', '11'], + ['12', '13', '14'], + ['15', '16', '17'], + ], + [ + ['18', '19', '20'], + ['21', '22', '23'], + ['24', '25', '26'], + ], + ], + [[['27']]], + ]) + }) + + it('inserts nodes one by one', () => { + const editor = createEditorWithShape([]) + let chunkTree: ChunkTree + + blocks(31).forEach((node, i) => { + Transforms.insertNodes(editor, node, { at: [i] }) + chunkTree = reconcileEditor(editor) + }) + + expect(getTreeShape(chunkTree!)).toEqual([ + '0', + '1', + '2', + ['3', '4', '5'], + ['6', '7', '8'], + ['9', '10', '11'], + ['12', '13', '14'], + ['15', '16', '17'], + ['18', '19', '20'], + [ + ['21', '22', '23'], + ['24', '25', '26'], + ['27', '28', '29'], + ], + [['30']], + ]) + }) + + it('inserts nodes one by one in reverse order', () => { + const editor = createEditorWithShape([]) + let chunkTree: ChunkTree + + blocks(31) + .reverse() + .forEach(node => { + Transforms.insertNodes(editor, node, { at: [0] }) + chunkTree = reconcileEditor(editor) + }) + + expect(getTreeShape(chunkTree!)).toEqual([ + [['0']], + [ + ['1', '2', '3'], + ['4', '5', '6'], + ['7', '8', '9'], + ], + ['10', '11', '12'], + ['13', '14', '15'], + ['16', '17', '18'], + ['19', '20', '21'], + ['22', '23', '24'], + ['25', '26', '27'], + '28', + '29', + '30', + ]) + }) + }) + + describe('at end of editor', () => { + it('inserts a single node at the top level', () => { + const editor = createEditorWithShape(['0', ['1', '2', ['3', '4', '5']]]) + Transforms.insertNodes(editor, block('x'), { at: [6] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + '0', + ['1', '2', ['3', '4', '5']], + [['x']], + ]) + }) + + it('inserts a single node into a chunk', () => { + const editor = createEditorWithShape(['0', ['1', ['2', '3', '4']]]) + Transforms.insertNodes(editor, block('x'), { at: [5] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + '0', + ['1', ['2', '3', '4'], ['x']], + ]) + }) + + it('inserts a single node into a nested chunk', () => { + const editor = createEditorWithShape(['0', ['1', '2', ['3', '4']]]) + Transforms.insertNodes(editor, block('x'), { at: [5] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + '0', + ['1', '2', ['3', '4', 'x']], + ]) + }) + + it('inserts 25 nodes after 2 nodes with 2 layers of chunking', () => { + const editor = createEditorWithShape(['a', 'b']) + Transforms.insertNodes(editor, blocks(25), { at: [2] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + 'a', + 'b', + [ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7', '8'], + ], + [ + ['9', '10', '11'], + ['12', '13', '14'], + ['15', '16', '17'], + ], + [['18', '19', '20'], ['21', '22', '23'], ['24']], + ]) + }) + + it('inserts 25 nodes after 3 nodes with 3 layers of chunking', () => { + const editor = createEditorWithShape(['a', 'b', 'c']) + Transforms.insertNodes(editor, blocks(25), { at: [3] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + 'a', + 'b', + 'c', + [ + [ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7', '8'], + ], + [ + ['9', '10', '11'], + ['12', '13', '14'], + ['15', '16', '17'], + ], + [['18', '19', '20'], ['21', '22', '23'], ['24']], + ], + ]) + }) + + it('inserts many nodes at the ends of multiple nested chunks', () => { + const editor = createEditorWithShape(['a', ['b', ['c']]]) + Transforms.insertNodes(editor, blocks(12), { at: [3] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + 'a', + ['b', ['c', '0', '1'], ['2']], + [ + ['3', '4', '5'], + ['6', '7', '8'], + ['9', '10', '11'], + ], + ]) + }) + + it('calls onInsert for inserted nodes', () => { + const editor = createEditorWithShape(['a', 'b', 'c']) + Transforms.insertNodes(editor, blocks(2), { at: [3] }) + + const onInsert = jest.fn() + reconcileEditor(editor, { onInsert }) + + expect(onInsert.mock.calls).toEqual([ + [editor.children[3], 3], + [editor.children[4], 4], + ]) + }) + + it('sets the index of inserted leaves', () => { + const editor = createEditorWithShape(['a', 'b', 'c']) + Transforms.insertNodes(editor, blocks(2), { at: [3] }) + + const chunkTree = reconcileEditor(editor) + const chunk = chunkTree.children[3] as Chunk + + expect(chunk.children).toMatchObject([{ index: 3 }, { index: 4 }]) + }) + }) + + describe('at start of editor', () => { + it('inserts a single node at the top level', () => { + const editor = createEditorWithShape(['0', '1']) + Transforms.insertNodes(editor, block('x'), { at: [0] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual(['x', '0', '1']) + }) + + it('inserts many nodes at the starts of multiple nested chunks', () => { + const editor = createEditorWithShape([[['a'], 'b'], 'c']) + Transforms.insertNodes(editor, blocks(12), { at: [0] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + [ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7', '8'], + ], + [['9'], ['10', '11', 'a'], 'b'], + 'c', + ]) + }) + }) + + describe('in the middle of editor', () => { + describe('at the top level', () => { + it('inserts a single node', () => { + const editor = createEditorWithShape(['0', '1']) + Transforms.insertNodes(editor, block('x'), { at: [1] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual(['0', 'x', '1']) + }) + + it('inserts nodes at the start of subsequent sibling chunks', () => { + const editor = createEditorWithShape(['a', [['b', 'c'], 'd'], 'e']) + Transforms.insertNodes(editor, blocks(3), { at: [1] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + 'a', + [['0']], + [['1'], ['2', 'b', 'c'], 'd'], + 'e', + ]) + }) + + it('calls onInsert for inserted nodes', () => { + const editor = createEditorWithShape(['a', 'b', 'c']) + Transforms.insertNodes(editor, blocks(2), { at: [1] }) + + const onInsert = jest.fn() + reconcileEditor(editor, { onInsert }) + + expect(onInsert.mock.calls).toEqual([ + [editor.children[1], 1], + [editor.children[2], 2], + ]) + }) + + it('calls onIndexChange for subsequent nodes', () => { + const editor = createEditorWithShape(['a', 'b', 'c']) + Transforms.insertNodes(editor, blocks(2), { at: [1] }) + + const onIndexChange = jest.fn() + reconcileEditor(editor, { onIndexChange }) + + expect(onIndexChange.mock.calls).toEqual([ + [editor.children[3], 3], + [editor.children[4], 4], + ]) + }) + + it('updates the index of subsequent leaves', () => { + const editor = createEditorWithShape(['a', 'b', 'c']) + Transforms.insertNodes(editor, blocks(3), { at: [1] }) + + const chunkTree = reconcileEditor(editor) + const subsequentLeaves = chunkTree.children.slice(2) + + expect(subsequentLeaves).toMatchObject([{ index: 4 }, { index: 5 }]) + }) + }) + + describe('in the middle of a chunk', () => { + it('inserts a single node', () => { + const editor = createEditorWithShape([[['0', '1']]]) + Transforms.insertNodes(editor, block('x'), { at: [1] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual([[['0', 'x', '1']]]) + }) + + it('inserts 8 nodes between 2 nodes', () => { + const editor = createEditorWithShape([[['a', 'b']]]) + Transforms.insertNodes(editor, blocks(8), { at: [1] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + [ + [ + 'a', + [ + ['0', '1', '2'], + ['3', '4', '5'], + ['6', '7'], + ], + 'b', + ], + ], + ]) + }) + + it('inserts nodes at the start of subsequent sibling chunks', () => { + const editor = createEditorWithShape([['a', [['b', 'c'], 'd'], 'e']]) + Transforms.insertNodes(editor, blocks(3), { at: [1] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + ['a', [['0']], [['1'], ['2', 'b', 'c'], 'd'], 'e'], + ]) + }) + }) + + describe('at the end of a chunk', () => { + it('inserts 2 nodes in 2 adjacent shallow chunks', () => { + const editor = createEditorWithShape([['a', 'b'], ['c']]) + Transforms.insertNodes(editor, blocks(2), { at: [2] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + ['a', 'b', '0'], + ['1', 'c'], + ]) + }) + + it('inserts nodes in many adjacent nested chunks', () => { + const editor = createEditorWithShape([ + [ + ['a', ['b', ['c']]], + [[['d'], 'e'], 'f'], + ], + ]) + + Transforms.insertNodes(editor, blocks(17), { at: [3] }) + const chunkTree = reconcileEditor(editor) + + expect(getTreeShape(chunkTree)).toEqual([ + [ + ['a', ['b', ['c', '0', '1'], ['2']], [['3']]], + [ + [ + ['4', '5', '6'], + ['7', '8', '9'], + ['10', '11', '12'], + ], + ], + [[['13']], [['14'], ['15', '16', 'd'], 'e'], 'f'], + ], + ]) + }) + }) + }) + }) + + describe('removing nodes', () => { + it('removes a node', () => { + const editor = createEditorWithShape(['0', [['1']], '2']) + Transforms.removeNodes(editor, { at: [1] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual(['0', '2']) + }) + + it('removes multiple consecutive nodes', () => { + const editor = createEditorWithShape(['0', ['1', '2', '3'], '4']) + Transforms.removeNodes(editor, { at: [3] }) + Transforms.removeNodes(editor, { at: [2] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual(['0', ['1'], '4']) + }) + + it('removes multiple non-consecutive nodes', () => { + const editor = createEditorWithShape(['0', ['1', '2', '3'], '4']) + Transforms.removeNodes(editor, { at: [3] }) + Transforms.removeNodes(editor, { at: [1] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual(['0', ['2'], '4']) + }) + + it('calls onIndexChange for subsequent nodes', () => { + const editor = createEditorWithShape(['a', 'b', 'c', 'd']) + Transforms.removeNodes(editor, { at: [1] }) + + const onIndexChange = jest.fn() + reconcileEditor(editor, { onIndexChange }) + + expect(onIndexChange.mock.calls).toEqual([ + [editor.children[1], 1], + [editor.children[2], 2], + ]) + }) + + it('updates the index of subsequent leaves', () => { + const editor = createEditorWithShape(['a', 'b', 'c', 'd']) + Transforms.removeNodes(editor, { at: [1] }) + + const chunkTree = reconcileEditor(editor) + const subsequentLeaves = chunkTree.children.slice(1) + + expect(subsequentLeaves).toMatchObject([{ index: 1 }, { index: 2 }]) + }) + }) + + describe('removing and inserting nodes', () => { + it('removes and inserts a node from the start', () => { + const editor = createEditorWithShape(['0', [['1']], '2']) + Transforms.removeNodes(editor, { at: [0] }) + Transforms.insertNodes(editor, block('x'), { at: [0] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual([[['x', '1']], '2']) + }) + + it('removes and inserts a node from the middle', () => { + const editor = createEditorWithShape(['0', [['1']], '2']) + Transforms.removeNodes(editor, { at: [1] }) + Transforms.insertNodes(editor, block('x'), { at: [1] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual(['0', 'x', '2']) + }) + + it('removes and inserts a node from the end', () => { + const editor = createEditorWithShape(['0', [['1']], '2']) + Transforms.removeNodes(editor, { at: [2] }) + Transforms.insertNodes(editor, block('x'), { at: [2] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual(['0', [['1', 'x']]]) + }) + + it('removes 2 nodes and inserts 1 node', () => { + const editor = createEditorWithShape(['0', ['1', '2'], '2']) + Transforms.removeNodes(editor, { at: [2] }) + Transforms.removeNodes(editor, { at: [1] }) + Transforms.insertNodes(editor, block('x'), { at: [1] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual(['0', 'x', '2']) + }) + + it('removes 1 nodes and inserts 2 node', () => { + const editor = createEditorWithShape(['0', ['1'], '2']) + Transforms.removeNodes(editor, { at: [1] }) + Transforms.insertNodes(editor, block('x'), { at: [1] }) + Transforms.insertNodes(editor, block('y'), { at: [2] }) + const chunkTree = reconcileEditor(editor) + expect(getTreeShape(chunkTree)).toEqual(['0', ['x', 'y'], '2']) + }) + + it('calls onIndexChange for nodes until insertions equal removals', () => { + const editor = createEditorWithShape([ + 'a', + // Insert 2 here + 'b', + 'c', + 'd', // Remove + 'e', + 'f', + 'g', // Remove + 'h', + ]) + + Transforms.removeNodes(editor, { at: [6] }) + Transforms.removeNodes(editor, { at: [3] }) + Transforms.insertNodes(editor, blocks(2), { at: [1] }) + + const onIndexChange = jest.fn() + reconcileEditor(editor, { onIndexChange }) + + expect(onIndexChange.mock.calls).toEqual([ + [editor.children[3], 3], + [editor.children[4], 4], + [editor.children[5], 5], + [editor.children[6], 6], + ]) + }) + }) + + describe('updating nodes', () => { + it('replaces updated Slate nodes in the chunk tree', () => { + const editor = createEditorWithShape(['0', ['1'], '2']) + Transforms.setNodes(editor, { updated: true } as any, { at: [1] }) + + const chunkTree = reconcileEditor(editor) + const chunk = chunkTree.children[1] as Chunk + const leaf = chunk.children[0] as ChunkLeaf + + expect(leaf.node).toMatchObject({ updated: true }) + }) + + it('invalidates ancestor chunks of updated Slate nodes', () => { + const editor = createEditorWithShape(['0', [['1']], '2']) + Transforms.insertText(editor, 'x', { at: [1, 0] }) + + const chunkTree = reconcileEditor(editor) + const outerChunk = chunkTree.children[1] as Chunk + const innerChunk = outerChunk.children[0] + + expect(getTreeShape(chunkTree)).toEqual(['0', [['x']], '2']) + + expect(chunkTree.modifiedChunks).toEqual( + new Set([outerChunk, innerChunk]) + ) + }) + + it('calls onUpdate for updated Slate nodes', () => { + const editor = createEditorWithShape(['0', '1', '2', '3']) + Transforms.setNodes(editor, { updated: true } as any, { at: [1] }) + Transforms.setNodes(editor, { updated: true } as any, { at: [2] }) + + const onUpdate = jest.fn() + reconcileEditor(editor, { onUpdate }) + + expect(onUpdate.mock.calls).toEqual([ + [editor.children[1], 1], + [editor.children[2], 2], + ]) + }) + }) + + describe('moving nodes', () => { + it('moves a node down', () => { + const editor = createEditorWithShape([['0'], ['1'], ['2'], ['3'], ['4']]) + + // Move 1 to after 3 + Transforms.moveNodes(editor, { at: [1], to: [3] }) + + const onInsert = jest.fn() + const onIndexChange = jest.fn() + const chunkTree = reconcileEditor(editor, { onInsert, onIndexChange }) + + expect(getTreeShape(chunkTree)).toEqual([['0'], ['2'], ['3', '1'], ['4']]) + + expect(onInsert.mock.calls).toEqual([[editor.children[3], 3]]) + + expect(onIndexChange.mock.calls).toEqual([ + [editor.children[1], 1], + [editor.children[2], 2], + ]) + + expect(chunkTree.movedNodeKeys.size).toBe(0) + }) + + it('moves a node up', () => { + const editor = createEditorWithShape([['0'], ['1'], ['2'], ['3'], ['4']]) + + // Move 3 to after 0 + Transforms.moveNodes(editor, { at: [3], to: [1] }) + + const onInsert = jest.fn() + const onIndexChange = jest.fn() + const chunkTree = reconcileEditor(editor, { onInsert, onIndexChange }) + + expect(getTreeShape(chunkTree)).toEqual([['0', '3'], ['1'], ['2'], ['4']]) + + expect(onInsert.mock.calls).toEqual([[editor.children[1], 1]]) + + expect(onIndexChange.mock.calls).toEqual([ + [editor.children[2], 2], + [editor.children[3], 3], + ]) + + expect(chunkTree.movedNodeKeys.size).toBe(0) + }) + }) + + describe('manual rerendering', () => { + it('invalidates specific child indices', () => { + const editor = createEditorWithShape([ + ['0'], + ['1', ['2'], '3'], + ['4'], + '5', + ]) + + reconcileEditor(editor) + + const chunkTree = reconcileEditor(editor, { rerenderChildren: [2, 4] }) + const twoOuterChunk = chunkTree.children[1] as Chunk + const twoInnerChunk = twoOuterChunk.children[1] + const fourChunk = chunkTree.children[2] + + expect(chunkTree.modifiedChunks).toEqual( + new Set([twoOuterChunk, twoInnerChunk, fourChunk]) + ) + }) + }) + + describe('random testing', () => { + it('remains correct after random operations', () => { + // Hard code a value here to reproduce a test failure + const seed = Math.floor(10000000 * Math.random()) + const random = createPRNG(seed) + + const duration = 250 + const startTime = performance.now() + const endTime = startTime + duration + let iteration = 0 + + try { + while (performance.now() < endTime) { + iteration++ + + const editor = withChunking(withReact(createEditor())) + + const randomPosition = (includeEnd: boolean) => + Math.floor( + random() * (editor.children.length + (includeEnd ? 1 : 0)) + ) + + for (let i = 0; i < 30; i++) { + const randomValue = random() + + if (randomValue < 0.33) { + reconcileEditor(editor) + } else if (randomValue < 0.66) { + Transforms.insertNodes(editor, block(i.toString()), { + at: [randomPosition(true)], + }) + } else if (randomValue < 0.8) { + if (editor.children.length > 0) { + Transforms.removeNodes(editor, { at: [randomPosition(false)] }) + } + } else { + if (editor.children.length > 0) { + Transforms.setNodes(editor, { updated: i } as any, { + at: [randomPosition(false)], + }) + } + } + } + + const chunkTree = reconcileEditor(editor) + const chunkTreeSlateNodes: Descendant[] = [] + + const flattenTree = (node: ChunkNode) => { + if (node.type === 'leaf') { + chunkTreeSlateNodes.push(node.node) + } else { + node.children.forEach(flattenTree) + } + } + + flattenTree(chunkTree) + + expect(chunkTreeSlateNodes).toEqual(editor.children) + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `Random testing encountered an error or test failure on iteration ${iteration}. To reproduce this failure reliably, use the random seed: ${seed}` + ) + throw e + } + }) + }) +}) diff --git a/packages/slate-react/test/decorations.spec.tsx b/packages/slate-react/test/decorations.spec.tsx new file mode 100644 index 000000000..c68ecc467 --- /dev/null +++ b/packages/slate-react/test/decorations.spec.tsx @@ -0,0 +1,672 @@ +import React from 'react' +import { + DecoratedRange, + Node, + NodeEntry, + Path, + createEditor as slateCreateEditor, + Editor, + Text, + Transforms, +} from 'slate' +import { act, render } from '@testing-library/react' +import { + Slate, + withReact, + Editable, + RenderLeafProps, + ReactEditor, +} from '../src' + +const renderLeaf = ({ leaf, attributes, children }: RenderLeafProps) => { + const decorations = Object.keys(Node.extractProps(leaf)).sort() + + return ( + + {children} + + ) +} + +interface DecorateConfig { + path: Path + decorations: (node: Node) => (DecoratedRange & Record)[] +} + +const decoratePaths = + (editor: ReactEditor, configs: DecorateConfig[]) => + ([node, path]: NodeEntry): DecoratedRange[] => { + // Validate that decorate was called with a node matching the path + if (Node.get(editor, path) !== node) { + throw new Error('decorate was called with an incorrect node entry') + } + + const matchingConfig = configs.find(({ path: p }) => Path.equals(path, p)) + if (!matchingConfig) return [] + + return matchingConfig.decorations(node) + } + +const getDecoratedLeaves = ( + editor: ReactEditor, + path: Path +): { text: string; decorations: string[] }[] => { + const text = ReactEditor.toDOMNode(editor, Node.leaf(editor, path)) + const leaves = Array.from(text.children) as HTMLElement[] + + return leaves.map(leaf => ({ + text: leaf.textContent!, + decorations: JSON.parse(leaf.dataset.decorations!), + })) +} + +// Pad children arrays with additional nodes to test whether decorations work +// correctly on chunked children +const otherNodes = () => + Array.from({ length: 7 }, () => ({ children: [{ text: '' }] })) + +describe('decorations', () => { + const withChunking = (chunking: boolean) => { + const createEditor = () => { + const editor = withReact(slateCreateEditor()) + + if (chunking) { + editor.getChunkSize = () => 2 + } + + return editor + } + + describe('decorating initial value', () => { + it('decorates part of a single text node', () => { + const editor = createEditor() + + const initialValue = [ + { children: [{ text: 'Hello world!' }] }, + ...otherNodes(), + ] + + const decorate = decoratePaths(editor, [ + { + path: [0, 0], + decorations: () => [ + { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 0], offset: 11 }, + bold: true, + }, + ], + }, + ]) + + render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: 'Hello ', decorations: [] }, + { text: 'world', decorations: ['bold'] }, + { text: '!', decorations: [] }, + ]) + }) + + it('decorates an entire text node', () => { + const editor = createEditor() + + const initialValue = [ + { + children: [{ text: 'before' }, { text: 'bold' }, { text: 'after' }], + }, + ...otherNodes(), + ] + + const decorate = decoratePaths(editor, [ + { + path: [0, 1], + decorations: () => [ + { + ...Editor.range(editor, [0, 1]), + bold: true, + }, + ], + }, + ]) + + render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: 'before', decorations: [] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 1])).toEqual([ + { text: 'bold', decorations: ['bold'] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 2])).toEqual([ + { text: 'after', decorations: [] }, + ]) + }) + + it('applies multiple overlapping decorations in a single text node', () => { + const editor = createEditor() + + const initialValue = [ + { children: [{ text: 'Hello world!' }] }, + ...otherNodes(), + ] + + const decorate = decoratePaths(editor, [ + { + path: [0, 0], + decorations: () => [ + { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 11 }, + bold: true, + }, + { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 0], offset: 12 }, + italic: true, + }, + ], + }, + ]) + + render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: 'Hello ', decorations: ['bold'] }, + { text: 'world', decorations: ['bold', 'italic'] }, + { text: '!', decorations: ['italic'] }, + ]) + }) + + it('passes down decorations from the parent element', () => { + const editor = createEditor() + + const initialValue = [ + { + children: [ + { text: 'before' }, + { text: 'middle' }, + { text: 'after' }, + ], + }, + ...otherNodes(), + ] + + const decorate = decoratePaths(editor, [ + { + path: [0], + decorations: () => [ + { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 2], offset: 2 }, + bold: true, + }, + ], + }, + ]) + + render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: 'be', decorations: [] }, + { text: 'fore', decorations: ['bold'] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 1])).toEqual([ + { text: 'middle', decorations: ['bold'] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 2])).toEqual([ + { text: 'af', decorations: ['bold'] }, + { text: 'ter', decorations: [] }, + ]) + }) + + it('passes decorations down from the editor', () => { + const editor = createEditor() + + const initialValue = [ + { + children: [{ text: '0.0' }, { text: '0.1' }, { text: '0.2' }], + }, + { + children: [{ text: '1.0' }], + }, + { + children: [{ text: '2.0' }], + }, + ...otherNodes(), + ] + + const decorate = decoratePaths(editor, [ + { + path: [], + decorations: () => [ + { + anchor: { path: [0, 1], offset: 0 }, + focus: { path: [1, 0], offset: 3 }, + bold: true, + }, + ], + }, + { + path: [0], + decorations: () => [ + { + ...Editor.range(editor, [0, 2]), + italic: true, + }, + ], + }, + { + path: [1, 0], + decorations: () => [ + { + ...Editor.range(editor, [1, 0]), + underline: true, + }, + ], + }, + ]) + + render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: '0.0', decorations: [] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 1])).toEqual([ + { text: '0.1', decorations: ['bold'] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 2])).toEqual([ + { text: '0.2', decorations: ['bold', 'italic'] }, + ]) + + expect(getDecoratedLeaves(editor, [1, 0])).toEqual([ + { text: '1.0', decorations: ['bold', 'underline'] }, + ]) + + expect(getDecoratedLeaves(editor, [2, 0])).toEqual([ + { text: '2.0', decorations: [] }, + ]) + }) + }) + + describe('redecorating', () => { + it('redecorates all nodes when the decorate function changes', () => { + const editor = createEditor() + + const initialValue = [ + { + children: [{ text: '0.0' }, { text: '0.1' }, { text: '0.2' }], + }, + { + children: [{ text: '1.0' }, { text: '1.1' }, { text: '1.2' }], + }, + ...otherNodes(), + ] + + const decorate1 = decoratePaths(editor, [ + { + path: [], + decorations: () => [ + { + ...Editor.range(editor, [0, 0]), + bold: true, + }, + { + ...Editor.range(editor, [0, 1]), + italic: true, + }, + ], + }, + { + path: [1, 0], + decorations: () => [ + { + ...Editor.range(editor, [1, 0]), + bold: true, + }, + ], + }, + { + path: [1, 1], + decorations: () => [ + { + ...Editor.range(editor, [1, 1]), + italic: true, + }, + ], + }, + ]) + + const decorate2 = decoratePaths(editor, [ + { + path: [0], + decorations: () => [ + { + ...Editor.range(editor, [0, 1]), + underline: true, + }, + { + ...Editor.range(editor, [0, 2]), + bold: true, + }, + ], + }, + { + path: [1, 1], + decorations: () => [ + { + ...Editor.range(editor, [1, 1]), + underline: true, + }, + ], + }, + { + path: [1, 2], + decorations: () => [ + { + ...Editor.range(editor, [1, 2]), + bold: true, + }, + ], + }, + ]) + + const { rerender } = render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: '0.0', decorations: ['bold'] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 1])).toEqual([ + { text: '0.1', decorations: ['italic'] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 2])).toEqual([ + { text: '0.2', decorations: [] }, + ]) + + expect(getDecoratedLeaves(editor, [1, 0])).toEqual([ + { text: '1.0', decorations: ['bold'] }, + ]) + + expect(getDecoratedLeaves(editor, [1, 1])).toEqual([ + { text: '1.1', decorations: ['italic'] }, + ]) + + expect(getDecoratedLeaves(editor, [1, 2])).toEqual([ + { text: '1.2', decorations: [] }, + ]) + + rerender( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: '0.0', decorations: [] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 1])).toEqual([ + { text: '0.1', decorations: ['underline'] }, + ]) + + expect(getDecoratedLeaves(editor, [0, 2])).toEqual([ + { text: '0.2', decorations: ['bold'] }, + ]) + + expect(getDecoratedLeaves(editor, [1, 0])).toEqual([ + { text: '1.0', decorations: [] }, + ]) + + expect(getDecoratedLeaves(editor, [1, 1])).toEqual([ + { text: '1.1', decorations: ['underline'] }, + ]) + + expect(getDecoratedLeaves(editor, [1, 2])).toEqual([ + { text: '1.2', decorations: ['bold'] }, + ]) + }) + + it('redecorates undecorated nodes when they change', async () => { + const editor = createEditor() + + const initialValue = [ + { children: [{ text: 'The quick brown fox' }] }, + ...otherNodes(), + ] + + const decorate = decoratePaths(editor, [ + { + path: [0, 0], + decorations: node => + Text.isText(node) && node.text.includes('box') + ? [ + { + ...Editor.range(editor, [0, 0]), + bold: true, + }, + ] + : [], + }, + ]) + + render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: 'The quick brown fox', decorations: [] }, + ]) + + await act(async () => { + Transforms.insertText(editor, 'b', { + at: { + anchor: { path: [0, 0], offset: 16 }, + focus: { path: [0, 0], offset: 17 }, + }, + }) + }) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: 'The quick brown box', decorations: ['bold'] }, + ]) + }) + + it('redecorates decorated nodes when they change', async () => { + const editor = createEditor() + + const initialValue = [ + { children: [{ text: 'The quick brown box' }] }, + ...otherNodes(), + ] + + const decorate = decoratePaths(editor, [ + { + path: [0, 0], + decorations: node => + Text.isText(node) && node.text.includes('box') + ? [ + { + ...Editor.range(editor, [0, 0]), + bold: true, + }, + ] + : [], + }, + ]) + + render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: 'The quick brown box', decorations: ['bold'] }, + ]) + + await act(async () => { + Transforms.insertText(editor, 'f', { + at: { + anchor: { path: [0, 0], offset: 16 }, + focus: { path: [0, 0], offset: 17 }, + }, + }) + }) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: 'The quick brown fox', decorations: [] }, + ]) + }) + + it('passes down new decorations from changed ancestors', async () => { + const editor = createEditor() + + const initialValue = [ + { + children: [ + { children: [{ text: 'Hello world!' }] }, + ...otherNodes(), + ], + }, + ] + + const decorate = decoratePaths(editor, [ + { + path: [0], + decorations: node => + 'bold' in node + ? [ + { + ...Editor.range(editor, [0, 0, 0]), + bold: true, + }, + ] + : [], + }, + ]) + + render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0, 0])).toEqual([ + { text: 'Hello world!', decorations: [] }, + ]) + + await act(async () => { + Transforms.setNodes(editor, { bold: true } as any, { + at: [0], + }) + }) + + expect(getDecoratedLeaves(editor, [0, 0, 0])).toEqual([ + { text: 'Hello world!', decorations: ['bold'] }, + ]) + }) + + it('does not redecorate unchanged nodes when their paths change', async () => { + const editor = createEditor() + + const initialValue = [ + { children: [{ text: 'A' }] }, + { children: [{ text: 'B' }] }, + ...otherNodes(), + ] + + const decorate = decoratePaths(editor, [ + { + path: [1, 0], + decorations: () => [ + { + ...Editor.range(editor, [1, 0]), + bold: true, + }, + ], + }, + ]) + + render( + + + + ) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: 'A', decorations: [] }, + ]) + + expect(getDecoratedLeaves(editor, [1, 0])).toEqual([ + { text: 'B', decorations: ['bold'] }, + ]) + + await act(async () => { + Transforms.insertNodes( + editor, + { children: [{ text: '0' }] }, + { + at: [0], + } + ) + }) + + expect(getDecoratedLeaves(editor, [0, 0])).toEqual([ + { text: '0', decorations: [] }, + ]) + + // A does not become bold even though it now matches the decoration + expect(getDecoratedLeaves(editor, [1, 0])).toEqual([ + { text: 'A', decorations: [] }, + ]) + + // B remains bold even though it no longer matches the decoration + expect(getDecoratedLeaves(editor, [2, 0])).toEqual([ + { text: 'B', decorations: ['bold'] }, + ]) + }) + }) + } + + describe('without chunking', () => { + withChunking(false) + }) + + describe('with chunking', () => { + withChunking(true) + }) +}) diff --git a/packages/slate-react/test/react-editor.spec.tsx b/packages/slate-react/test/react-editor.spec.tsx index f7a2ede1b..8ffe5ab75 100644 --- a/packages/slate-react/test/react-editor.spec.tsx +++ b/packages/slate-react/test/react-editor.spec.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from 'react' -import { createEditor, Text, Transforms } from 'slate' +import React from 'react' +import { createEditor, Transforms } from 'slate' import { act, render } from '@testing-library/react' import { Slate, withReact, Editable, ReactEditor } from '../src' diff --git a/packages/slate-react/test/tsconfig.json b/packages/slate-react/test/tsconfig.json index 9e802d417..a9b230f88 100644 --- a/packages/slate-react/test/tsconfig.json +++ b/packages/slate-react/test/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../../config/typescript/tsconfig.json", + "compilerOptions": { + "types": ["@testing-library/jest-dom"] + }, "references": [{ "path": "../" }] } diff --git a/packages/slate-react/test/use-selected.spec.tsx b/packages/slate-react/test/use-selected.spec.tsx new file mode 100644 index 000000000..b59569e16 --- /dev/null +++ b/packages/slate-react/test/use-selected.spec.tsx @@ -0,0 +1,183 @@ +import React from 'react' +import { createEditor, Transforms } from 'slate' +import { render, act } from '@testing-library/react' +import { + Slate, + withReact, + Editable, + useSelected, + RenderElementProps, + ReactEditor, +} from '../src' + +let editor: ReactEditor +let elementSelectedRenders: Record + +const clearRenders = () => + Object.values(elementSelectedRenders).forEach(selectedRenders => { + if (selectedRenders) { + selectedRenders.length = 0 + } + }) + +const initialValue = () => [ + { + id: '0', + children: [ + { id: '0.0', children: [{ text: '' }] }, + { id: '0.1', children: [{ text: '' }] }, + { id: '0.2', children: [{ text: '' }] }, + ], + }, + { id: '1', children: [{ text: '' }] }, + { id: '2', children: [{ text: '' }] }, +] + +describe('useSelected', () => { + const withChunking = (chunking: boolean) => { + beforeEach(() => { + editor = withReact(createEditor()) + + if (chunking) { + editor.getChunkSize = () => 3 + } + + elementSelectedRenders = {} + + const renderElement = ({ + element, + attributes, + children, + }: RenderElementProps) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const selected = useSelected() + const { id } = element as any + + let selectedRenders = elementSelectedRenders[id] + + if (!selectedRenders) { + selectedRenders = [] + elementSelectedRenders[id] = selectedRenders + } + + selectedRenders.push(selected) + + return
{children}
+ } + + render( + + + + ) + }) + + it('returns false initially', () => { + expect(elementSelectedRenders).toEqual({ + '0': [false], + '0.0': [false], + '0.1': [false], + '0.2': [false], + '1': [false], + '2': [false], + }) + }) + + it('re-renders elements when it becomes true or false', async () => { + clearRenders() + + await act(async () => { + Transforms.select(editor, [0, 0]) + }) + + expect(elementSelectedRenders).toEqual({ + '0': [true], + '0.0': [true], + '0.1': [], + '0.2': [], + '1': [], + '2': [], + }) + + clearRenders() + + await act(async () => { + Transforms.select(editor, [2]) + }) + + expect(elementSelectedRenders).toEqual({ + '0': [false], + '0.0': [false], + '0.1': [], + '0.2': [], + '1': [], + '2': [true], + }) + }) + + it('returns true for elements in the middle of the selection', async () => { + clearRenders() + + await act(async () => { + Transforms.select(editor, { + anchor: { path: [2, 0], offset: 0 }, + focus: { path: [0, 1, 0], offset: 0 }, + }) + }) + + expect(elementSelectedRenders).toEqual({ + '0': [true], + '0.0': [], + '0.1': [true], + '0.2': [true], + '1': [true], + '2': [true], + }) + }) + + it('remains true when the path changes', async () => { + clearRenders() + + await act(async () => { + Transforms.select(editor, { path: [2, 0], offset: 0 }) + }) + + expect(elementSelectedRenders).toEqual({ + '0': [], + '0.0': [], + '0.1': [], + '0.2': [], + '1': [], + '2': [true], + }) + + clearRenders() + + await act(async () => { + Transforms.insertNodes( + editor, + { id: 'new', children: [{ text: '' }] } as any, + { at: [2] } + ) + }) + + expect(elementSelectedRenders).toEqual({ + '0': [], + '0.0': [], + '0.1': [], + '0.2': [], + '1': [], + new: [false], + '2': [], // Remains true, no rerender + }) + }) + } + + describe('without chunking', () => { + withChunking(false) + }) + + describe('with chunking', () => { + withChunking(true) + }) +}) diff --git a/packages/slate-react/test/use-slate-selector.test.tsx b/packages/slate-react/test/use-slate-selector.spec.tsx similarity index 84% rename from packages/slate-react/test/use-slate-selector.test.tsx rename to packages/slate-react/test/use-slate-selector.spec.tsx index 5cc1769ae..1b5a98db8 100644 --- a/packages/slate-react/test/use-slate-selector.test.tsx +++ b/packages/slate-react/test/use-slate-selector.spec.tsx @@ -1,14 +1,7 @@ -/* eslint-disable no-console */ -import React, { useEffect } from 'react' -import { createEditor, Editor, Text, Transforms } from 'slate' -import { act, render, renderHook } from '@testing-library/react' -import { - Slate, - withReact, - Editable, - ReactEditor, - useSlateSelector, -} from '../src' +import React from 'react' +import { createEditor, Transforms } from 'slate' +import { act, renderHook } from '@testing-library/react' +import { Slate, withReact, Editable, useSlateSelector } from '../src' import _ from 'lodash' describe('useSlateSelector', () => { diff --git a/packages/slate-react/test/use-slate.spec.tsx b/packages/slate-react/test/use-slate.spec.tsx new file mode 100644 index 000000000..1951849fa --- /dev/null +++ b/packages/slate-react/test/use-slate.spec.tsx @@ -0,0 +1,59 @@ +/* eslint-disable import/no-deprecated */ +import React from 'react' +import { Transforms, createEditor } from 'slate' +import { render, act } from '@testing-library/react' +import '@testing-library/jest-dom' +import { Slate, withReact, Editable, useSlateWithV } from '../src' + +describe('useSlateWithV', () => { + const ShowVersion = () => { + const { v } = useSlateWithV() + return <>V = {v} + } + + it('tracks a global `v` counter for the editor', async () => { + const editor = withReact(createEditor()) + const initialValue = [{ type: 'block', children: [{ text: 'test' }] }] + + const { getByText, rerender } = render( + + +

+ First: +

+

+ Second: +

+
+ ) + + expect(getByText('First: V = 0')).toBeInTheDocument() + expect(getByText('Second: V = 0')).toBeInTheDocument() + + await act(async () => { + Transforms.insertText(editor, '!', { at: { path: [0, 0], offset: 4 } }) + }) + + expect(getByText('First: V = 1')).toBeInTheDocument() + expect(getByText('Second: V = 1')).toBeInTheDocument() + + rerender( + + +

+ First: +

+

+ Second: +

+

+ Third: +

+
+ ) + + expect(getByText('First: V = 1')).toBeInTheDocument() + expect(getByText('Second: V = 1')).toBeInTheDocument() + expect(getByText('Third: V = 1')).toBeInTheDocument() + }) +}) diff --git a/packages/slate/src/interfaces/point.ts b/packages/slate/src/interfaces/point.ts index cb4acebbd..baccf3395 100644 --- a/packages/slate/src/interfaces/point.ts +++ b/packages/slate/src/interfaces/point.ts @@ -1,4 +1,3 @@ -import { produce } from 'immer' import { ExtendedType, Operation, Path, isObject } from '..' import { TextDirection } from '../types/types' @@ -99,81 +98,85 @@ export const Point: PointInterface = { op: Operation, options: PointTransformOptions = {} ): Point | null { - return produce(point, p => { - if (p === null) { - return null + if (point === null) { + return null + } + + const { affinity = 'forward' } = options + let { path, offset } = point + + switch (op.type) { + case 'insert_node': + case 'move_node': { + path = Path.transform(path, op, options)! + break } - const { affinity = 'forward' } = options - const { path, offset } = p - switch (op.type) { - case 'insert_node': - case 'move_node': { - p.path = Path.transform(path, op, options)! - break + case 'insert_text': { + if ( + Path.equals(op.path, path) && + (op.offset < offset || + (op.offset === offset && affinity === 'forward')) + ) { + offset += op.text.length } - case 'insert_text': { - if ( - Path.equals(op.path, path) && - (op.offset < offset || - (op.offset === offset && affinity === 'forward')) - ) { - p.offset += op.text.length - } + break + } - break + case 'merge_node': { + if (Path.equals(op.path, path)) { + offset += op.position } - case 'merge_node': { - if (Path.equals(op.path, path)) { - p.offset += op.position - } + path = Path.transform(path, op, options)! + break + } - p.path = Path.transform(path, op, options)! - break + case 'remove_text': { + if (Path.equals(op.path, path) && op.offset <= offset) { + offset -= Math.min(offset - op.offset, op.text.length) } - case 'remove_text': { - if (Path.equals(op.path, path) && op.offset <= offset) { - p.offset -= Math.min(offset - op.offset, op.text.length) - } + break + } - break + case 'remove_node': { + if (Path.equals(op.path, path) || Path.isAncestor(op.path, path)) { + return null } - case 'remove_node': { - if (Path.equals(op.path, path) || Path.isAncestor(op.path, path)) { + path = Path.transform(path, op, options)! + break + } + + case 'split_node': { + if (Path.equals(op.path, path)) { + if (op.position === offset && affinity == null) { return null - } + } else if ( + op.position < offset || + (op.position === offset && affinity === 'forward') + ) { + offset -= op.position - p.path = Path.transform(path, op, options)! - break + path = Path.transform(path, op, { + ...options, + affinity: 'forward', + })! + } + } else { + path = Path.transform(path, op, options)! } - case 'split_node': { - if (Path.equals(op.path, path)) { - if (op.position === offset && affinity == null) { - return null - } else if ( - op.position < offset || - (op.position === offset && affinity === 'forward') - ) { - p.offset -= op.position - - p.path = Path.transform(path, op, { - ...options, - affinity: 'forward', - })! - } - } else { - p.path = Path.transform(path, op, options)! - } - - break - } + break } - }) + + default: + return point + } + + return { path, offset } }, } diff --git a/packages/slate/src/interfaces/range.ts b/packages/slate/src/interfaces/range.ts index 4a6711cc9..034ef6e95 100644 --- a/packages/slate/src/interfaces/range.ts +++ b/packages/slate/src/interfaces/range.ts @@ -1,4 +1,3 @@ -import { produce } from 'immer' import { ExtendedType, Operation, Path, Point, PointEntry, isObject } from '..' import { RangeDirection } from '../types/types' @@ -220,47 +219,47 @@ export const Range: RangeInterface = { op: Operation, options: RangeTransformOptions = {} ): Range | null { - return produce(range, r => { - if (r === null) { - return null - } - const { affinity = 'inward' } = options - let affinityAnchor: 'forward' | 'backward' | null - let affinityFocus: 'forward' | 'backward' | null + if (range === null) { + return null + } - if (affinity === 'inward') { - // If the range is collapsed, make sure to use the same affinity to - // avoid the two points passing each other and expanding in the opposite - // direction - const isCollapsed = Range.isCollapsed(r) - if (Range.isForward(r)) { - affinityAnchor = 'forward' - affinityFocus = isCollapsed ? affinityAnchor : 'backward' - } else { - affinityAnchor = 'backward' - affinityFocus = isCollapsed ? affinityAnchor : 'forward' - } - } else if (affinity === 'outward') { - if (Range.isForward(r)) { - affinityAnchor = 'backward' - affinityFocus = 'forward' - } else { - affinityAnchor = 'forward' - affinityFocus = 'backward' - } + const { affinity = 'inward' } = options + let affinityAnchor: 'forward' | 'backward' | null + let affinityFocus: 'forward' | 'backward' | null + + if (affinity === 'inward') { + // If the range is collapsed, make sure to use the same affinity to + // avoid the two points passing each other and expanding in the opposite + // direction + const isCollapsed = Range.isCollapsed(range) + if (Range.isForward(range)) { + affinityAnchor = 'forward' + affinityFocus = isCollapsed ? affinityAnchor : 'backward' } else { - affinityAnchor = affinity - affinityFocus = affinity + affinityAnchor = 'backward' + affinityFocus = isCollapsed ? affinityAnchor : 'forward' } - const anchor = Point.transform(r.anchor, op, { affinity: affinityAnchor }) - const focus = Point.transform(r.focus, op, { affinity: affinityFocus }) - - if (!anchor || !focus) { - return null + } else if (affinity === 'outward') { + if (Range.isForward(range)) { + affinityAnchor = 'backward' + affinityFocus = 'forward' + } else { + affinityAnchor = 'forward' + affinityFocus = 'backward' } - - r.anchor = anchor - r.focus = focus + } else { + affinityAnchor = affinity + affinityFocus = affinity + } + const anchor = Point.transform(range.anchor, op, { + affinity: affinityAnchor, }) + const focus = Point.transform(range.focus, op, { affinity: affinityFocus }) + + if (!anchor || !focus) { + return null + } + + return { anchor, focus } }, } diff --git a/packages/slate/src/interfaces/transforms/general.ts b/packages/slate/src/interfaces/transforms/general.ts index 63f9d87e6..ad0fea4ee 100644 --- a/packages/slate/src/interfaces/transforms/general.ts +++ b/packages/slate/src/interfaces/transforms/general.ts @@ -1,4 +1,3 @@ -import { createDraft, finishDraft, isDraft } from 'immer' import { Ancestor, Descendant, @@ -22,224 +21,320 @@ export interface GeneralTransforms { transform: (editor: Editor, op: Operation) => void } -const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => { - switch (op.type) { - case 'insert_node': { - const { path, node } = op - const parent = Node.parent(editor, path) - const index = path[path.length - 1] +const insertChildren = (xs: T[], index: number, ...newValues: T[]) => [ + ...xs.slice(0, index), + ...newValues, + ...xs.slice(index), +] - if (index > parent.children.length) { - throw new Error( - `Cannot apply an "insert_node" operation at path [${path}] because the destination is past the end of the node.` - ) - } +const replaceChildren = ( + xs: T[], + index: number, + removeCount: number, + ...newValues: T[] +) => [...xs.slice(0, index), ...newValues, ...xs.slice(index + removeCount)] - parent.children.splice(index, 0, node) +const removeChildren = replaceChildren - if (selection) { - for (const [point, key] of Range.points(selection)) { - selection[key] = Point.transform(point, op)! - } - } +/** + * Replace a descendant with a new node, replacing all ancestors + */ +const modifyDescendant = ( + editor: Editor, + path: Path, + f: (node: N) => N +) => { + if (path.length === 0) { + throw new Error('Cannot modify the editor') + } - break + const node = Node.get(editor, path) as N + const slicedPath = path.slice() + let modifiedNode: Node = f(node) + + while (slicedPath.length > 1) { + const index = slicedPath.pop()! + const ancestorNode = Node.get(editor, slicedPath) as Ancestor + + modifiedNode = { + ...ancestorNode, + children: replaceChildren(ancestorNode.children, index, 1, modifiedNode), } + } - case 'insert_text': { - const { path, offset, text } = op - if (text.length === 0) break - const node = Node.leaf(editor, path) - const before = node.text.slice(0, offset) - const after = node.text.slice(offset) - node.text = before + text + after + const index = slicedPath.pop()! + editor.children = replaceChildren(editor.children, index, 1, modifiedNode) +} - if (selection) { - for (const [point, key] of Range.points(selection)) { - selection[key] = Point.transform(point, op)! - } - } - - break - } - - case 'merge_node': { - const { path } = op - const node = Node.get(editor, path) - const prevPath = Path.previous(path) - const prev = Node.get(editor, prevPath) - const parent = Node.parent(editor, path) - const index = path[path.length - 1] - - if (Text.isText(node) && Text.isText(prev)) { - prev.text += node.text - } else if (!Text.isText(node) && !Text.isText(prev)) { - prev.children.push(...node.children) - } else { +/** + * Replace the children of a node, replacing all ancestors + */ +const modifyChildren = ( + editor: Editor, + path: Path, + f: (children: Descendant[]) => Descendant[] +) => { + if (path.length === 0) { + editor.children = f(editor.children) + } else { + modifyDescendant(editor, path, node => { + if (Text.isText(node)) { throw new Error( - `Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${Scrubber.stringify( + `Cannot get the element at path [${path}] because it refers to a leaf node: ${Scrubber.stringify( node - )} ${Scrubber.stringify(prev)}` + )}` ) } - parent.children.splice(index, 1) + return { ...node, children: f(node.children) } + }) + } +} - if (selection) { - for (const [point, key] of Range.points(selection)) { - selection[key] = Point.transform(point, op)! - } - } - - break +/** + * Replace a leaf, replacing all ancestors + */ +const modifyLeaf = (editor: Editor, path: Path, f: (leaf: Text) => Text) => + modifyDescendant(editor, path, node => { + if (!Text.isText(node)) { + throw new Error( + `Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${Scrubber.stringify( + node + )}` + ) } - case 'move_node': { - const { path, newPath } = op + return f(node) + }) - if (Path.isAncestor(path, newPath)) { - throw new Error( - `Cannot move a path [${path}] to new path [${newPath}] because the destination is inside itself.` - ) +// eslint-disable-next-line no-redeclare +export const GeneralTransforms: GeneralTransforms = { + transform(editor: Editor, op: Operation): void { + let transformSelection = false + + switch (op.type) { + case 'insert_node': { + const { path, node } = op + + modifyChildren(editor, Path.parent(path), children => { + const index = path[path.length - 1] + + if (index > children.length) { + throw new Error( + `Cannot apply an "insert_node" operation at path [${path}] because the destination is past the end of the node.` + ) + } + + return insertChildren(children, index, node) + }) + + transformSelection = true + break } - const node = Node.get(editor, path) - const parent = Node.parent(editor, path) - const index = path[path.length - 1] + case 'insert_text': { + const { path, offset, text } = op + if (text.length === 0) break - // This is tricky, but since the `path` and `newPath` both refer to - // the same snapshot in time, there's a mismatch. After either - // removing the original position, the second step's path can be out - // of date. So instead of using the `op.newPath` directly, we - // transform `op.path` to ascertain what the `newPath` would be after - // the operation was applied. - parent.children.splice(index, 1) - const truePath = Path.transform(path, op)! - const newParent = Node.get(editor, Path.parent(truePath)) as Ancestor - const newIndex = truePath[truePath.length - 1] + modifyLeaf(editor, path, node => { + const before = node.text.slice(0, offset) + const after = node.text.slice(offset) - newParent.children.splice(newIndex, 0, node) + return { + ...node, + text: before + text + after, + } + }) - if (selection) { - for (const [point, key] of Range.points(selection)) { - selection[key] = Point.transform(point, op)! - } + transformSelection = true + break } - break - } + case 'merge_node': { + const { path } = op + const index = path[path.length - 1] + const prevPath = Path.previous(path) + const prevIndex = prevPath[prevPath.length - 1] - case 'remove_node': { - const { path } = op - const index = path[path.length - 1] - const parent = Node.parent(editor, path) - parent.children.splice(index, 1) + modifyChildren(editor, Path.parent(path), children => { + const node = children[index] + const prev = children[prevIndex] + let newNode: Descendant - // Transform all the points in the value, but if the point was in the - // node that was removed we need to update the range or remove it. - if (selection) { - for (const [point, key] of Range.points(selection)) { - const result = Point.transform(point, op) - - if (selection != null && result != null) { - selection[key] = result + if (Text.isText(node) && Text.isText(prev)) { + newNode = { ...prev, text: prev.text + node.text } + } else if (!Text.isText(node) && !Text.isText(prev)) { + newNode = { ...prev, children: prev.children.concat(node.children) } } else { - let prev: NodeEntry | undefined - let next: NodeEntry | undefined + throw new Error( + `Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${Scrubber.stringify( + node + )} ${Scrubber.stringify(prev)}` + ) + } - for (const [n, p] of Node.texts(editor)) { - if (Path.compare(p, path) === -1) { - prev = [n, p] - } else { - next = [n, p] - break - } - } + return replaceChildren(children, prevIndex, 2, newNode) + }) - let preferNext = false - if (prev && next) { - if (Path.equals(next[1], path)) { - preferNext = !Path.hasPrevious(next[1]) - } else { - preferNext = - Path.common(prev[1], path).length < - Path.common(next[1], path).length - } - } + transformSelection = true + break + } - if (prev && !preferNext) { - point.path = prev[1] - point.offset = prev[0].text.length - } else if (next) { - point.path = next[1] - point.offset = 0 + case 'move_node': { + const { path, newPath } = op + const index = path[path.length - 1] + + if (Path.isAncestor(path, newPath)) { + throw new Error( + `Cannot move a path [${path}] to new path [${newPath}] because the destination is inside itself.` + ) + } + + const node = Node.get(editor, path) + + modifyChildren(editor, Path.parent(path), children => + removeChildren(children, index, 1) + ) + + // This is tricky, but since the `path` and `newPath` both refer to + // the same snapshot in time, there's a mismatch. After either + // removing the original position, the second step's path can be out + // of date. So instead of using the `op.newPath` directly, we + // transform `op.path` to ascertain what the `newPath` would be after + // the operation was applied. + const truePath = Path.transform(path, op)! + const newIndex = truePath[truePath.length - 1] + + modifyChildren(editor, Path.parent(truePath), children => + insertChildren(children, newIndex, node) + ) + + transformSelection = true + break + } + + case 'remove_node': { + const { path } = op + const index = path[path.length - 1] + + modifyChildren(editor, Path.parent(path), children => + removeChildren(children, index, 1) + ) + + // Transform all the points in the value, but if the point was in the + // node that was removed we need to update the range or remove it. + if (editor.selection) { + let selection: Selection = { ...editor.selection } + + for (const [point, key] of Range.points(selection)) { + const result = Point.transform(point, op) + + if (selection != null && result != null) { + selection[key] = result } else { - selection = null + let prev: NodeEntry | undefined + let next: NodeEntry | undefined + + for (const [n, p] of Node.texts(editor)) { + if (Path.compare(p, path) === -1) { + prev = [n, p] + } else { + next = [n, p] + break + } + } + + let preferNext = false + if (prev && next) { + if (Path.equals(next[1], path)) { + preferNext = !Path.hasPrevious(next[1]) + } else { + preferNext = + Path.common(prev[1], path).length < + Path.common(next[1], path).length + } + } + + if (prev && !preferNext) { + selection![key] = { path: prev[1], offset: prev[0].text.length } + } else if (next) { + selection![key] = { path: next[1], offset: 0 } + } else { + selection = null + } } } - } - } - break - } - - case 'remove_text': { - const { path, offset, text } = op - if (text.length === 0) break - const node = Node.leaf(editor, path) - const before = node.text.slice(0, offset) - const after = node.text.slice(offset + text.length) - node.text = before + after - - if (selection) { - for (const [point, key] of Range.points(selection)) { - selection[key] = Point.transform(point, op)! - } - } - - break - } - - case 'set_node': { - const { path, properties, newProperties } = op - - if (path.length === 0) { - throw new Error(`Cannot set properties on the root node!`) - } - - const node = Node.get(editor, path) - - for (const key in newProperties) { - if (key === 'children' || key === 'text') { - throw new Error(`Cannot set the "${key}" property of nodes!`) + editor.selection = selection } - const value = newProperties[key] - - if (value == null) { - delete node[key] - } else { - node[key] = value - } + break } - // properties that were previously defined, but are now missing, must be deleted - for (const key in properties) { - if (!newProperties.hasOwnProperty(key)) { - delete node[key] - } + case 'remove_text': { + const { path, offset, text } = op + if (text.length === 0) break + + modifyLeaf(editor, path, node => { + const before = node.text.slice(0, offset) + const after = node.text.slice(offset + text.length) + + return { + ...node, + text: before + after, + } + }) + + transformSelection = true + break } - break - } + case 'set_node': { + const { path, properties, newProperties } = op - case 'set_selection': { - const { newProperties } = op + if (path.length === 0) { + throw new Error(`Cannot set properties on the root node!`) + } - if (newProperties == null) { - selection = newProperties - } else { - if (selection == null) { + modifyDescendant(editor, path, node => { + const newNode = { ...node } + + for (const key in newProperties) { + if (key === 'children' || key === 'text') { + throw new Error(`Cannot set the "${key}" property of nodes!`) + } + + const value = newProperties[key] + + if (value == null) { + delete newNode[key] + } else { + newNode[key] = value + } + } + + // properties that were previously defined, but are now missing, must be deleted + for (const key in properties) { + if (!newProperties.hasOwnProperty(key)) { + delete newNode[key] + } + } + + return newNode + }) + + break + } + + case 'set_selection': { + const { newProperties } = op + + if (newProperties == null) { + editor.selection = null + break + } + + if (editor.selection == null) { if (!Range.isRange(newProperties)) { throw new Error( `Cannot apply an incomplete "set_selection" operation properties ${Scrubber.stringify( @@ -248,9 +343,12 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => { ) } - selection = { ...newProperties } + editor.selection = { ...newProperties } + break } + const selection = { ...editor.selection } + for (const key in newProperties) { const value = newProperties[key] @@ -264,76 +362,67 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => { selection[key] = value } } + + editor.selection = selection + + break } - break + case 'split_node': { + const { path, position, properties } = op + const index = path[path.length - 1] + + if (path.length === 0) { + throw new Error( + `Cannot apply a "split_node" operation at path [${path}] because the root node cannot be split.` + ) + } + + modifyChildren(editor, Path.parent(path), children => { + const node = children[index] + let newNode: Descendant + let nextNode: Descendant + + if (Text.isText(node)) { + const before = node.text.slice(0, position) + const after = node.text.slice(position) + newNode = { + ...node, + text: before, + } + nextNode = { + ...(properties as Partial), + text: after, + } + } else { + const before = node.children.slice(0, position) + const after = node.children.slice(position) + newNode = { + ...node, + children: before, + } + nextNode = { + ...(properties as Partial), + children: after, + } + } + + return replaceChildren(children, index, 1, newNode, nextNode) + }) + + transformSelection = true + break + } } - case 'split_node': { - const { path, position, properties } = op + if (transformSelection && editor.selection) { + const selection = { ...editor.selection } - if (path.length === 0) { - throw new Error( - `Cannot apply a "split_node" operation at path [${path}] because the root node cannot be split.` - ) + for (const [point, key] of Range.points(selection)) { + selection[key] = Point.transform(point, op)! } - const node = Node.get(editor, path) - const parent = Node.parent(editor, path) - const index = path[path.length - 1] - let newNode: Descendant - - if (Text.isText(node)) { - const before = node.text.slice(0, position) - const after = node.text.slice(position) - node.text = before - newNode = { - ...(properties as Partial), - text: after, - } - } else { - const before = node.children.slice(0, position) - const after = node.children.slice(position) - node.children = before - - newNode = { - ...(properties as Partial), - children: after, - } - } - - parent.children.splice(index + 1, 0, newNode) - - if (selection) { - for (const [point, key] of Range.points(selection)) { - selection[key] = Point.transform(point, op)! - } - } - - break - } - } - return selection -} - -// eslint-disable-next-line no-redeclare -export const GeneralTransforms: GeneralTransforms = { - transform(editor: Editor, op: Operation): void { - editor.children = createDraft(editor.children) - let selection = editor.selection && createDraft(editor.selection) - - try { - selection = applyToDraft(editor, selection, op) - } finally { - editor.children = finishDraft(editor.children) - - if (selection) { - editor.selection = isDraft(selection) - ? (finishDraft(selection) as Range) - : selection - } else { - editor.selection = null - } + editor.selection = selection } }, } diff --git a/playwright.config.ts b/playwright.config.ts index fee9d0003..1dbd7156d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -76,8 +76,8 @@ const config: PlaywrightTestConfig = { /* Base URL to use in actions like `await page.goto('/')`. */ // baseURL: 'http://localhost:3000', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + /* Collect trace if the first attempt fails. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-first-failure', /* Name of attribute for selecting elements by page.getByTestId */ testIdAttribute: 'data-test-id', diff --git a/playwright/integration/examples/code-highlighting.test.ts b/playwright/integration/examples/code-highlighting.test.ts index e22d0abf1..0ff83c7fd 100644 --- a/playwright/integration/examples/code-highlighting.test.ts +++ b/playwright/integration/examples/code-highlighting.test.ts @@ -30,7 +30,8 @@ test.describe('code highlighting', () => { // it also tests if select and code block button works the right way async function setText(page: Page, text: string, language: string) { - await page.locator('[data-slate-editor]').fill('') // clear editor + await page.locator('[data-slate-editor]').selectText() + await page.keyboard.press('Backspace') // clear editor await page.getByTestId('code-block-button').click() // convert first and the only one paragraph to code block await page.getByTestId('language-select').selectOption({ value: language }) // select the language option diff --git a/playwright/integration/examples/huge-document.test.ts b/playwright/integration/examples/huge-document.test.ts index 1480b0eaf..321ba2563 100644 --- a/playwright/integration/examples/huge-document.test.ts +++ b/playwright/integration/examples/huge-document.test.ts @@ -1,18 +1,13 @@ import { test, expect } from '@playwright/test' test.describe('huge document example', () => { - const elements = [ - { tag: '#__next h1', count: 100 }, - { tag: '#__next p', count: 700 }, - ] - test.beforeEach(async ({ page }) => { await page.goto('http://localhost:3000/examples/huge-document') }) - test('contains image', async ({ page }) => { - for (const { tag, count } of elements) { - await expect(page.locator(tag)).toHaveCount(count) - } + test('uses chunking', async ({ page }) => { + await expect(page.getByLabel('Blocks')).toHaveValue('10000') + await expect(page.getByLabel('Chunk size')).toHaveValue('1000') + await expect(page.locator('[data-slate-chunk]')).toHaveCount(10) }) }) diff --git a/playwright/integration/examples/shadow-dom.test.ts b/playwright/integration/examples/shadow-dom.test.ts index 76ffb6fc6..110e7a425 100644 --- a/playwright/integration/examples/shadow-dom.test.ts +++ b/playwright/integration/examples/shadow-dom.test.ts @@ -24,7 +24,9 @@ test.describe('shadow-dom example', () => { await expect(textbox).toHaveCount(1) // Clear any existing text and type new text into the textbox - await textbox.fill('Hello, Playwright!') + await page.locator('[data-slate-editor]').selectText() + await page.keyboard.press('Backspace') + await page.keyboard.type('Hello, Playwright!') // Assert that the textbox contains the correct text await expect(textbox).toHaveText('Hello, Playwright!') diff --git a/site/examples/js/code-highlighting.jsx b/site/examples/js/code-highlighting.jsx index c56c0cc3d..ab3c661d8 100644 --- a/site/examples/js/code-highlighting.jsx +++ b/site/examples/js/code-highlighting.jsx @@ -17,7 +17,6 @@ import { Editable, ReactEditor, Slate, - useSlate, useSlateStatic, withReact, } from 'slate-react' @@ -29,12 +28,11 @@ const CodeBlockType = 'code-block' const CodeLineType = 'code-line' const CodeHighlightingExample = () => { const [editor] = useState(() => withHistory(withReact(createEditor()))) - const decorate = useDecorate(editor) + const decorate = useDecorate() const onKeyDown = useOnKeydown(editor) return ( - { ) } -const useDecorate = editor => { - return useCallback( - ([node, path]) => { - if (Element.isElement(node) && node.type === CodeLineType) { - const ranges = editor.nodeToDecorations?.get(node) || [] - return ranges - } - return [] - }, - [editor.nodeToDecorations] - ) +const useDecorate = () => { + return useCallback(([node, path]) => { + if (Element.isElement(node) && node.type === CodeBlockType) { + return decorateCodeBlock([node, path]) + } + return [] + }, []) } -const getChildNodeToDecorations = ([block, blockPath]) => { - const nodeToDecorations = new Map() +const decorateCodeBlock = ([block, blockPath]) => { const text = block.children.map(line => Node.string(line)).join('\n') - const language = block.language - const tokens = Prism.tokenize(text, Prism.languages[language]) + const tokens = Prism.tokenize(text, Prism.languages[block.language]) const normalizedTokens = normalizeTokens(tokens) // make tokens flat and grouped by line - const blockChildren = block.children + const decorations = [] for (let index = 0; index < normalizedTokens.length; index++) { const tokens = normalizedTokens[index] - const element = blockChildren[index] - if (!nodeToDecorations.has(element)) { - nodeToDecorations.set(element, []) - } let start = 0 for (const token of tokens) { const length = token.content.length @@ -168,33 +156,16 @@ const getChildNodeToDecorations = ([block, blockPath]) => { } const end = start + length const path = [...blockPath, index, 0] - const range = { + decorations.push({ anchor: { path, offset: start }, focus: { path, offset: end }, token: true, ...Object.fromEntries(token.types.map(type => [type, true])), - } - nodeToDecorations.get(element).push(range) + }) start = end } } - return nodeToDecorations -} -// precalculate editor.nodeToDecorations map to use it inside decorate function then -const SetNodeToDecorations = () => { - const editor = useSlate() - const blockEntries = Array.from( - Editor.nodes(editor, { - at: [], - mode: 'highest', - match: n => Element.isElement(n) && n.type === CodeBlockType, - }) - ) - const nodeToDecorations = mergeMaps( - ...blockEntries.map(getChildNodeToDecorations) - ) - editor.nodeToDecorations = nodeToDecorations - return null + return decorations } const useOnKeydown = editor => { const onKeyDown = useCallback( @@ -236,15 +207,6 @@ const LanguageSelect = props => { ) } -const mergeMaps = (...maps) => { - const map = new Map() - for (const m of maps) { - for (const item of m) { - map.set(...item) - } - } - return map -} const toChildren = content => [{ text: content }] const toCodeLines = content => content diff --git a/site/examples/js/embeds.jsx b/site/examples/js/embeds.jsx index ae4026914..f95456be7 100644 --- a/site/examples/js/embeds.jsx +++ b/site/examples/js/embeds.jsx @@ -90,6 +90,7 @@ const UrlInput = ({ url, onChange }) => { const [value, setValue] = React.useState(url) return ( e.stopPropagation()} style={{ diff --git a/site/examples/js/huge-document.jsx b/site/examples/js/huge-document.jsx index deae2380e..896764ad9 100644 --- a/site/examples/js/huge-document.jsx +++ b/site/examples/js/huge-document.jsx @@ -1,40 +1,430 @@ import { faker } from '@faker-js/faker' -import React, { useCallback, useMemo } from 'react' -import { createEditor } from 'slate' -import { Editable, Slate, withReact } from 'slate-react' +import React, { useCallback, useEffect, useState } from 'react' +import { createEditor as slateCreateEditor, Editor } from 'slate' +import { Editable, Slate, withReact, useSelected } from 'slate-react' -const HEADINGS = 100 -const PARAGRAPHS = 7 -const initialValue = [] -for (let h = 0; h < HEADINGS; h++) { - const heading = { - type: 'heading-one', - children: [{ text: faker.lorem.sentence() }], +const SUPPORTS_EVENT_TIMING = + typeof window !== 'undefined' && 'PerformanceEventTiming' in window +const SUPPORTS_LOAF_TIMING = + typeof window !== 'undefined' && + 'PerformanceLongAnimationFrameTiming' in window +const blocksOptions = [ + 2, 1000, 2500, 5000, 7500, 10000, 15000, 20000, 25000, 30000, 40000, 50000, + 100000, 200000, +] +const chunkSizeOptions = [3, 10, 100, 1000] +const searchParams = + typeof document === 'undefined' + ? null + : new URLSearchParams(document.location.search) +const parseNumber = (key, defaultValue) => + parseInt(searchParams?.get(key) ?? '', 10) || defaultValue +const parseBoolean = (key, defaultValue) => { + const value = searchParams?.get(key) + if (value) return value === 'true' + return defaultValue +} +const parseEnum = (key, options, defaultValue) => { + const value = searchParams?.get(key) + if (value && options.includes(value)) return value + return defaultValue +} +const initialConfig = { + blocks: parseNumber('blocks', 10000), + chunking: parseBoolean('chunking', true), + chunkSize: parseNumber('chunk_size', 1000), + chunkDivs: parseBoolean('chunk_divs', true), + chunkOutlines: parseBoolean('chunk_outlines', false), + contentVisibilityMode: parseEnum( + 'content_visibility', + ['none', 'element', 'chunk'], + 'chunk' + ), + showSelectedHeadings: parseBoolean('selected_headings', false), +} +const setSearchParams = config => { + if (searchParams) { + searchParams.set('blocks', config.blocks.toString()) + searchParams.set('chunking', config.chunking ? 'true' : 'false') + searchParams.set('chunk_size', config.chunkSize.toString()) + searchParams.set('chunk_divs', config.chunkDivs ? 'true' : 'false') + searchParams.set('chunk_outlines', config.chunkOutlines ? 'true' : 'false') + searchParams.set('content_visibility', config.contentVisibilityMode) + searchParams.set( + 'selected_headings', + config.showSelectedHeadings ? 'true' : 'false' + ) + history.replaceState({}, '', `?${searchParams.toString()}`) } - initialValue.push(heading) - for (let p = 0; p < PARAGRAPHS; p++) { - const paragraph = { - type: 'paragraph', - children: [{ text: faker.lorem.paragraph() }], +} +const cachedInitialValue = [] +const getInitialValue = blocks => { + if (cachedInitialValue.length >= blocks) { + return cachedInitialValue.slice(0, blocks) + } + faker.seed(1) + for (let i = cachedInitialValue.length; i < blocks; i++) { + if (i % 100 === 0) { + const heading = { + type: 'heading-one', + children: [{ text: faker.lorem.sentence() }], + } + cachedInitialValue.push(heading) + } else { + const paragraph = { + type: 'paragraph', + children: [{ text: faker.lorem.paragraph() }], + } + cachedInitialValue.push(paragraph) } - initialValue.push(paragraph) } + return cachedInitialValue.slice() +} +const initialInitialValue = + typeof window === 'undefined' ? [] : getInitialValue(initialConfig.blocks) +const createEditor = config => { + const editor = withReact(slateCreateEditor()) + editor.getChunkSize = node => + config.chunking && Editor.isEditor(node) ? config.chunkSize : null + return editor } const HugeDocumentExample = () => { - const renderElement = useCallback(props => , []) - const editor = useMemo(() => withReact(createEditor()), []) + const [rendering, setRendering] = useState(false) + const [config, baseSetConfig] = useState(initialConfig) + const [initialValue, setInitialValue] = useState(initialInitialValue) + const [editor, setEditor] = useState(() => createEditor(config)) + const [editorVersion, setEditorVersion] = useState(0) + const setConfig = useCallback( + partialConfig => { + const newConfig = { ...config, ...partialConfig } + setRendering(true) + baseSetConfig(newConfig) + setSearchParams(newConfig) + setTimeout(() => { + setRendering(false) + setInitialValue(getInitialValue(newConfig.blocks)) + setEditor(createEditor(newConfig)) + setEditorVersion(n => n + 1) + }) + }, + [config] + ) + const renderElement = useCallback( + props => ( + + ), + [config.contentVisibilityMode, config.showSelectedHeadings] + ) + const renderChunk = useCallback( + props => ( + + ), + [config.contentVisibilityMode, config.chunkOutlines] + ) return ( - - - + <> + + + {rendering ? ( +
Rendering…
+ ) : ( + + + + )} + ) } -const Element = ({ attributes, children, element }) => { +const Chunk = ({ + attributes, + children, + lowest, + contentVisibilityLowest, + outline, +}) => { + const style = { + contentVisibility: contentVisibilityLowest && lowest ? 'auto' : undefined, + border: outline ? '1px solid red' : undefined, + padding: outline ? 20 : undefined, + marginBottom: outline ? 20 : undefined, + } + return ( +
+ {children} +
+ ) +} +const Heading = React.forwardRef( + ({ style: styleProp, showSelectedHeadings = false, ...props }, ref) => { + // Fine since the editor is remounted if the config changes + // eslint-disable-next-line react-hooks/rules-of-hooks + const selected = showSelectedHeadings ? useSelected() : false + const style = { ...styleProp, color: selected ? 'green' : undefined } + return

+ } +) +const Paragraph = 'p' +const Element = ({ + attributes, + children, + element, + contentVisibility, + showSelectedHeadings, +}) => { + const style = { + contentVisibility: contentVisibility ? 'auto' : undefined, + } switch (element.type) { case 'heading-one': - return

{children}

+ return ( + + {children} + + ) default: - return

{children}

+ return ( + + {children} + + ) } } +const PerformanceControls = ({ editor, config, setConfig }) => { + const [configurationOpen, setConfigurationOpen] = useState(true) + const [keyPressDurations, setKeyPressDurations] = useState([]) + const [lastLongAnimationFrameDuration, setLastLongAnimationFrameDuration] = + useState(null) + const lastKeyPressDuration = keyPressDurations[0] ?? null + const averageKeyPressDuration = + keyPressDurations.length === 10 + ? Math.round(keyPressDurations.reduce((total, d) => total + d) / 10) + : null + useEffect(() => { + if (!SUPPORTS_EVENT_TIMING) return + const observer = new PerformanceObserver(list => { + list.getEntries().forEach(entry => { + if (entry.name === 'keypress') { + const duration = Math.round( + // @ts-ignore Entry type is missing processingStart and processingEnd + entry.processingEnd - entry.processingStart + ) + setKeyPressDurations(durations => [ + duration, + ...durations.slice(0, 9), + ]) + } + }) + }) + // @ts-ignore Options type is missing durationThreshold + observer.observe({ type: 'event', durationThreshold: 16 }) + return () => observer.disconnect() + }, []) + useEffect(() => { + if (!SUPPORTS_LOAF_TIMING) return + const { apply } = editor + let afterOperation = false + editor.apply = operation => { + apply(operation) + afterOperation = true + } + const observer = new PerformanceObserver(list => { + list.getEntries().forEach(entry => { + if (afterOperation) { + setLastLongAnimationFrameDuration(Math.round(entry.duration)) + afterOperation = false + } + }) + }) + // Register the observer for events + observer.observe({ type: 'long-animation-frame' }) + return () => observer.disconnect() + }, [editor]) + return ( +
+

+ +

+ +
setConfigurationOpen(event.currentTarget.open)} + > + Configuration + +

+ +

+ + {config.chunking && ( + <> +

+ +

+ + {config.chunkDivs && ( +

+ +

+ )} + +

+ +

+ + )} + +

+ +

+ +

+ +

+
+ +
+ Statistics + +

+ Last keypress (ms):{' '} + {SUPPORTS_EVENT_TIMING + ? lastKeyPressDuration ?? '-' + : 'Not supported'} +

+ +

+ Average of last 10 keypresses (ms):{' '} + {SUPPORTS_EVENT_TIMING + ? averageKeyPressDuration ?? '-' + : 'Not supported'} +

+ +

+ Last long animation frame (ms):{' '} + {SUPPORTS_LOAF_TIMING + ? lastLongAnimationFrameDuration ?? '-' + : 'Not supported'} +

+ + {SUPPORTS_EVENT_TIMING && lastKeyPressDuration === null && ( +

Events shorter than 16ms may not be detected.

+ )} +
+
+ ) +} export default HugeDocumentExample diff --git a/site/examples/js/search-highlighting.jsx b/site/examples/js/search-highlighting.jsx index d4bd3ec43..98757a863 100644 --- a/site/examples/js/search-highlighting.jsx +++ b/site/examples/js/search-highlighting.jsx @@ -83,7 +83,7 @@ const SearchHighlightingExample = () => { placeholder="Search the text..." onChange={e => setSearch(e.target.value)} className={css` - padding-left: 2.5em; + padding-left: 2.5em !important; width: 100%; `} /> diff --git a/site/examples/ts/code-highlighting.tsx b/site/examples/ts/code-highlighting.tsx index c663cb1e0..2bab94b5d 100644 --- a/site/examples/ts/code-highlighting.tsx +++ b/site/examples/ts/code-highlighting.tsx @@ -16,9 +16,9 @@ import { Element, Node, NodeEntry, - Range, Transforms, createEditor, + DecoratedRange, } from 'slate' import { withHistory } from 'slate-history' import { @@ -27,7 +27,6 @@ import { RenderElementProps, RenderLeafProps, Slate, - useSlate, useSlateStatic, withReact, } from 'slate-react' @@ -48,13 +47,12 @@ const CodeLineType = 'code-line' const CodeHighlightingExample = () => { const [editor] = useState(() => withHistory(withReact(createEditor()))) - const decorate = useDecorate(editor) + const decorate = useDecorate() const onKeyDown = useOnKeydown(editor) return ( - { ) } -const useDecorate = (editor: CustomEditor) => { - return useCallback( - ([node, path]: NodeEntry) => { - if (Element.isElement(node) && node.type === CodeLineType) { - const ranges = editor.nodeToDecorations?.get(node) || [] - return ranges - } +const useDecorate = () => { + return useCallback(([node, path]: NodeEntry) => { + if (Element.isElement(node) && node.type === CodeBlockType) { + return decorateCodeBlock([node, path]) + } - return [] - }, - [editor.nodeToDecorations] - ) + return [] + }, []) } -interface TokenRange extends Range { - token: boolean - [key: string]: unknown -} - -type EditorWithDecorations = CustomEditor & { - nodeToDecorations: Map -} - -const getChildNodeToDecorations = ([ +const decorateCodeBlock = ([ block, blockPath, -]: NodeEntry): Map => { - const nodeToDecorations = new Map() - +]: NodeEntry): DecoratedRange[] => { const text = block.children.map(line => Node.string(line)).join('\n') - const language = block.language - const tokens = Prism.tokenize(text, Prism.languages[language]) + const tokens = Prism.tokenize(text, Prism.languages[block.language]) const normalizedTokens = normalizeTokens(tokens) // make tokens flat and grouped by line - const blockChildren = block.children as Element[] + const decorations: DecoratedRange[] = [] for (let index = 0; index < normalizedTokens.length; index++) { const tokens = normalizedTokens[index] - const element = blockChildren[index] - - if (!nodeToDecorations.has(element)) { - nodeToDecorations.set(element, []) - } let start = 0 for (const token of tokens) { @@ -219,41 +196,19 @@ const getChildNodeToDecorations = ([ const end = start + length const path = [...blockPath, index, 0] - const range = { + + decorations.push({ anchor: { path, offset: start }, focus: { path, offset: end }, token: true, ...Object.fromEntries(token.types.map(type => [type, true])), - } - - nodeToDecorations.get(element)!.push(range) + }) start = end } } - return nodeToDecorations -} - -// precalculate editor.nodeToDecorations map to use it inside decorate function then -const SetNodeToDecorations = () => { - const editor = useSlate() as EditorWithDecorations - - const blockEntries = Array.from( - Editor.nodes(editor, { - at: [], - mode: 'highest', - match: n => Element.isElement(n) && n.type === CodeBlockType, - }) - ) - - const nodeToDecorations = mergeMaps( - ...blockEntries.map(getChildNodeToDecorations) - ) - - editor.nodeToDecorations = nodeToDecorations - - return null + return decorations } const useOnKeydown = (editor: CustomEditor) => { @@ -306,18 +261,6 @@ const LanguageSelect = (props: LanguageSelectProps) => { ) } -const mergeMaps = (...maps: Map[]) => { - const map = new Map() - - for (const m of maps) { - for (const item of m) { - map.set(...item) - } - } - - return map -} - const toChildren = (content: string): CustomText[] => [{ text: content }] const toCodeLines = (content: string): CodeLineElement[] => content diff --git a/site/examples/ts/embeds.tsx b/site/examples/ts/embeds.tsx index 2a4e5c618..dc43a4e8d 100644 --- a/site/examples/ts/embeds.tsx +++ b/site/examples/ts/embeds.tsx @@ -117,6 +117,7 @@ const UrlInput = ({ url, onChange }: UrlInputProps) => { const [value, setValue] = React.useState(url) return ( e.stopPropagation()} style={{ diff --git a/site/examples/ts/huge-document.tsx b/site/examples/ts/huge-document.tsx index 2aed98ba5..e8d2742b5 100644 --- a/site/examples/ts/huge-document.tsx +++ b/site/examples/ts/huge-document.tsx @@ -1,54 +1,520 @@ import { faker } from '@faker-js/faker' -import React, { useCallback, useMemo } from 'react' -import { createEditor, Descendant } from 'slate' -import { Editable, RenderElementProps, Slate, withReact } from 'slate-react' - +import React, { + CSSProperties, + Dispatch, + useCallback, + useEffect, + useState, +} from 'react' +import { createEditor as slateCreateEditor, Descendant, Editor } from 'slate' import { - CustomEditor, - HeadingElement, - ParagraphElement, -} from './custom-types.d' + Editable, + RenderElementProps, + RenderChunkProps, + Slate, + withReact, + useSelected, +} from 'slate-react' -const HEADINGS = 100 -const PARAGRAPHS = 7 -const initialValue: Descendant[] = [] +import { HeadingElement, ParagraphElement } from './custom-types.d' -for (let h = 0; h < HEADINGS; h++) { - const heading: HeadingElement = { - type: 'heading-one', - children: [{ text: faker.lorem.sentence() }], +const SUPPORTS_EVENT_TIMING = + typeof window !== 'undefined' && 'PerformanceEventTiming' in window + +const SUPPORTS_LOAF_TIMING = + typeof window !== 'undefined' && + 'PerformanceLongAnimationFrameTiming' in window + +interface Config { + blocks: number + chunking: boolean + chunkSize: number + chunkDivs: boolean + chunkOutlines: boolean + contentVisibilityMode: 'none' | 'element' | 'chunk' + showSelectedHeadings: boolean +} + +const blocksOptions = [ + 2, 1000, 2500, 5000, 7500, 10000, 15000, 20000, 25000, 30000, 40000, 50000, + 100000, 200000, +] + +const chunkSizeOptions = [3, 10, 100, 1000] + +const searchParams = + typeof document === 'undefined' + ? null + : new URLSearchParams(document.location.search) + +const parseNumber = (key: string, defaultValue: number) => + parseInt(searchParams?.get(key) ?? '', 10) || defaultValue + +const parseBoolean = (key: string, defaultValue: boolean) => { + const value = searchParams?.get(key) + if (value) return value === 'true' + return defaultValue +} + +const parseEnum = ( + key: string, + options: T[], + defaultValue: T +): T => { + const value = searchParams?.get(key) as T | null | undefined + if (value && options.includes(value)) return value + return defaultValue +} + +const initialConfig: Config = { + blocks: parseNumber('blocks', 10000), + chunking: parseBoolean('chunking', true), + chunkSize: parseNumber('chunk_size', 1000), + chunkDivs: parseBoolean('chunk_divs', true), + chunkOutlines: parseBoolean('chunk_outlines', false), + contentVisibilityMode: parseEnum( + 'content_visibility', + ['none', 'element', 'chunk'], + 'chunk' + ), + showSelectedHeadings: parseBoolean('selected_headings', false), +} + +const setSearchParams = (config: Config) => { + if (searchParams) { + searchParams.set('blocks', config.blocks.toString()) + searchParams.set('chunking', config.chunking ? 'true' : 'false') + searchParams.set('chunk_size', config.chunkSize.toString()) + searchParams.set('chunk_divs', config.chunkDivs ? 'true' : 'false') + searchParams.set('chunk_outlines', config.chunkOutlines ? 'true' : 'false') + searchParams.set('content_visibility', config.contentVisibilityMode) + searchParams.set( + 'selected_headings', + config.showSelectedHeadings ? 'true' : 'false' + ) + history.replaceState({}, '', `?${searchParams.toString()}`) } - initialValue.push(heading) +} - for (let p = 0; p < PARAGRAPHS; p++) { - const paragraph: ParagraphElement = { - type: 'paragraph', - children: [{ text: faker.lorem.paragraph() }], +const cachedInitialValue: Descendant[] = [] + +const getInitialValue = (blocks: number) => { + if (cachedInitialValue.length >= blocks) { + return cachedInitialValue.slice(0, blocks) + } + + faker.seed(1) + + for (let i = cachedInitialValue.length; i < blocks; i++) { + if (i % 100 === 0) { + const heading: HeadingElement = { + type: 'heading-one', + children: [{ text: faker.lorem.sentence() }], + } + cachedInitialValue.push(heading) + } else { + const paragraph: ParagraphElement = { + type: 'paragraph', + children: [{ text: faker.lorem.paragraph() }], + } + cachedInitialValue.push(paragraph) } - initialValue.push(paragraph) } + + return cachedInitialValue.slice() +} + +const initialInitialValue = + typeof window === 'undefined' ? [] : getInitialValue(initialConfig.blocks) + +const createEditor = (config: Config) => { + const editor = withReact(slateCreateEditor()) + + editor.getChunkSize = node => + config.chunking && Editor.isEditor(node) ? config.chunkSize : null + + return editor } const HugeDocumentExample = () => { - const renderElement = useCallback( - (props: RenderElementProps) => , - [] + const [rendering, setRendering] = useState(false) + const [config, baseSetConfig] = useState(initialConfig) + const [initialValue, setInitialValue] = useState(initialInitialValue) + const [editor, setEditor] = useState(() => createEditor(config)) + const [editorVersion, setEditorVersion] = useState(0) + + const setConfig = useCallback( + (partialConfig: Partial) => { + const newConfig = { ...config, ...partialConfig } + + setRendering(true) + baseSetConfig(newConfig) + setSearchParams(newConfig) + + setTimeout(() => { + setRendering(false) + setInitialValue(getInitialValue(newConfig.blocks)) + setEditor(createEditor(newConfig)) + setEditorVersion(n => n + 1) + }) + }, + [config] ) - const editor = useMemo(() => withReact(createEditor()) as CustomEditor, []) + + const renderElement = useCallback( + (props: RenderElementProps) => ( + + ), + [config.contentVisibilityMode, config.showSelectedHeadings] + ) + + const renderChunk = useCallback( + (props: RenderChunkProps) => ( + + ), + [config.contentVisibilityMode, config.chunkOutlines] + ) + return ( - - - + <> + + + {rendering ? ( +
Rendering…
+ ) : ( + + + + )} + ) } -const Element = ({ attributes, children, element }: RenderElementProps) => { +const Chunk = ({ + attributes, + children, + lowest, + contentVisibilityLowest, + outline, +}: RenderChunkProps & { + contentVisibilityLowest: boolean + outline: boolean +}) => { + const style: CSSProperties = { + contentVisibility: contentVisibilityLowest && lowest ? 'auto' : undefined, + border: outline ? '1px solid red' : undefined, + padding: outline ? 20 : undefined, + marginBottom: outline ? 20 : undefined, + } + + return ( +
+ {children} +
+ ) +} + +const Heading = React.forwardRef< + HTMLHeadingElement, + React.ComponentProps<'h1'> & { showSelectedHeadings: boolean } +>(({ style: styleProp, showSelectedHeadings = false, ...props }, ref) => { + // Fine since the editor is remounted if the config changes + // eslint-disable-next-line react-hooks/rules-of-hooks + const selected = showSelectedHeadings ? useSelected() : false + const style = { ...styleProp, color: selected ? 'green' : undefined } + return

+}) + +const Paragraph = 'p' + +const Element = ({ + attributes, + children, + element, + contentVisibility, + showSelectedHeadings, +}: RenderElementProps & { + contentVisibility: boolean + showSelectedHeadings: boolean +}) => { + const style: CSSProperties = { + contentVisibility: contentVisibility ? 'auto' : undefined, + } + switch (element.type) { case 'heading-one': - return

{children}

+ return ( + + {children} + + ) default: - return

{children}

+ return ( + + {children} + + ) } } +const PerformanceControls = ({ + editor, + config, + setConfig, +}: { + editor: Editor + config: Config + setConfig: Dispatch> +}) => { + const [configurationOpen, setConfigurationOpen] = useState(true) + const [keyPressDurations, setKeyPressDurations] = useState([]) + const [lastLongAnimationFrameDuration, setLastLongAnimationFrameDuration] = + useState(null) + + const lastKeyPressDuration: number | null = keyPressDurations[0] ?? null + + const averageKeyPressDuration = + keyPressDurations.length === 10 + ? Math.round(keyPressDurations.reduce((total, d) => total + d) / 10) + : null + + useEffect(() => { + if (!SUPPORTS_EVENT_TIMING) return + + const observer = new PerformanceObserver(list => { + list.getEntries().forEach(entry => { + if (entry.name === 'keypress') { + const duration = Math.round( + // @ts-ignore Entry type is missing processingStart and processingEnd + entry.processingEnd - entry.processingStart + ) + setKeyPressDurations(durations => [ + duration, + ...durations.slice(0, 9), + ]) + } + }) + }) + + // @ts-ignore Options type is missing durationThreshold + observer.observe({ type: 'event', durationThreshold: 16 }) + + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!SUPPORTS_LOAF_TIMING) return + + const { apply } = editor + let afterOperation = false + + editor.apply = operation => { + apply(operation) + afterOperation = true + } + + const observer = new PerformanceObserver(list => { + list.getEntries().forEach(entry => { + if (afterOperation) { + setLastLongAnimationFrameDuration(Math.round(entry.duration)) + afterOperation = false + } + }) + }) + + // Register the observer for events + observer.observe({ type: 'long-animation-frame' }) + + return () => observer.disconnect() + }, [editor]) + + return ( +
+

+ +

+ +
setConfigurationOpen(event.currentTarget.open)} + > + Configuration + +

+ +

+ + {config.chunking && ( + <> +

+ +

+ + {config.chunkDivs && ( +

+ +

+ )} + +

+ +

+ + )} + +

+ +

+ +

+ +

+
+ +
+ Statistics + +

+ Last keypress (ms):{' '} + {SUPPORTS_EVENT_TIMING + ? lastKeyPressDuration ?? '-' + : 'Not supported'} +

+ +

+ Average of last 10 keypresses (ms):{' '} + {SUPPORTS_EVENT_TIMING + ? averageKeyPressDuration ?? '-' + : 'Not supported'} +

+ +

+ Last long animation frame (ms):{' '} + {SUPPORTS_LOAF_TIMING + ? lastLongAnimationFrameDuration ?? '-' + : 'Not supported'} +

+ + {SUPPORTS_EVENT_TIMING && lastKeyPressDuration === null && ( +

Events shorter than 16ms may not be detected.

+ )} +
+
+ ) +} + export default HugeDocumentExample diff --git a/site/examples/ts/search-highlighting.tsx b/site/examples/ts/search-highlighting.tsx index bb17da99c..11ee20357 100644 --- a/site/examples/ts/search-highlighting.tsx +++ b/site/examples/ts/search-highlighting.tsx @@ -97,7 +97,7 @@ const SearchHighlightingExample = () => { placeholder="Search the text..." onChange={e => setSearch(e.target.value)} className={css` - padding-left: 2.5em; + padding-left: 2.5em !important; width: 100%; `} /> diff --git a/site/pages/examples/[example].tsx b/site/pages/examples/[example].tsx index eb0e44d7c..df81ee9ab 100644 --- a/site/pages/examples/[example].tsx +++ b/site/pages/examples/[example].tsx @@ -78,7 +78,7 @@ const Header = (props: React.HTMLAttributes) => ( display: flex; height: 42px; position: relative; - z-index: 1; /* To appear above the underlay */ + z-index: 3; /* To appear above the underlay */ `} /> ) @@ -147,7 +147,7 @@ const TabList = ({ width: ${isVisible ? '200px' : '0'}; white-space: nowrap; max-height: 70vh; - z-index: 1; /* To appear above the underlay */ + z-index: 3; /* To appear above the underlay */ `} /> ) @@ -165,6 +165,7 @@ const TabListUnderlay = ({ top: 0; position: fixed; width: 100%; + z-index: 2; `} /> ) @@ -248,7 +249,7 @@ const ExampleHeader = (props: React.HTMLAttributes) => ( display: flex; height: 42px; position: relative; - z-index: 1; /* To appear above the underlay */ + z-index: 3; /* To appear above the underlay */ `} /> ) diff --git a/site/public/index.css b/site/public/index.css index bcd5333f9..922005fb6 100644 --- a/site/public/index.css +++ b/site/public/index.css @@ -10,6 +10,10 @@ body { margin: 0; } +h1 { + margin-top: 0; +} + p { margin: 0; } @@ -56,7 +60,8 @@ td { border: 2px solid #ddd; } -input { +input[type='text'], +input[type='search'] { box-sizing: border-box; font-size: 0.85em; width: 100%; @@ -65,7 +70,8 @@ input { background: #fafafa; } -input:focus { +input[type='text']:focus, +input[type='search']:focus { outline: 0; border-color: blue; } @@ -75,7 +81,12 @@ iframe { border: 1px solid #eee; } -[data-slate-editor] > * + * { +details > summary { + user-select: none; +} + +[data-slate-editor] > * + *, +[data-slate-chunk] > * + * { margin-top: 1em; } @@ -89,3 +100,27 @@ iframe { outline-offset: -20px; white-space: pre-wrap; } + +.performance-controls { + padding: 20px; + margin: -20px -20px 20px -20px; + background-color: white; + position: sticky; + top: 0; + z-index: 1; + border-bottom: 1px solid lightgrey; + max-height: 50vh; + overflow-y: auto; +} + +.performance-controls > * { + margin-top: 10px; +} + +.performance-controls > details > :not(summary) { + margin-left: 10px; +} + +.performance-controls p { + margin-top: 5px; +} diff --git a/yarn.lock b/yarn.lock index b40e0a312..b493d8274 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,13 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.4.0": + version: 4.4.3 + resolution: "@adobe/css-tools@npm:4.4.3" + checksum: 701379c514b7a43ca6681705a93cd57ad79565cfef9591122e9499897550cf324a5e5bb1bc51df0e7433cf0e91b962c90f18ac459dcc98b2431daa04aa63cb20 + languageName: node + linkType: hard + "@ampproject/remapping@npm:^2.2.0": version: 2.2.1 resolution: "@ampproject/remapping@npm:2.2.1" @@ -3040,14 +3047,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:^1.39.0": - version: 1.39.0 - resolution: "@playwright/test@npm:1.39.0" +"@playwright/test@npm:^1.52.0": + version: 1.52.0 + resolution: "@playwright/test@npm:1.52.0" dependencies: - playwright: "npm:1.39.0" + playwright: "npm:1.52.0" bin: playwright: cli.js - checksum: 5d039234609395f3eab46b5954b259bdd80dacf79efe531369c1633647dcafce25a78d497e61e671d661274bf66076426a1bd46be585c44addf23bb5bfaa15a2 + checksum: e18a4eb626c7bc6cba212ff2e197cf9ae2e4da1c91bfdf08a744d62e27222751173e4b220fa27da72286a89a3b4dea7c09daf384d23708f284b64f98e9a63a88 languageName: node linkType: hard @@ -3239,6 +3246,21 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^6.6.3": + version: 6.6.3 + resolution: "@testing-library/jest-dom@npm:6.6.3" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + chalk: "npm:^3.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + lodash: "npm:^4.17.21" + redent: "npm:^3.0.0" + checksum: 1f3427e45870eab9dcc59d6504b780d4a595062fe1687762ae6e67d06a70bf439b40ab64cf58cbace6293a99e3764d4647fdc8300a633b721764f5ce39dade18 + languageName: node + linkType: hard + "@testing-library/react@npm:^14.0.0": version: 14.0.0 resolution: "@testing-library/react@npm:14.0.0" @@ -4065,6 +4087,13 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:^5.0.0": + version: 5.3.2 + resolution: "aria-query@npm:5.3.2" + checksum: b2fe9bc98bd401bc322ccb99717c1ae2aaf53ea0d468d6e7aebdc02fac736e4a99b46971ee05b783b08ade23c675b2d8b60e4a1222a95f6e27bc4d2a0bfdcc03 + languageName: node + linkType: hard + "array-buffer-byte-length@npm:^1.0.0": version: 1.0.0 resolution: "array-buffer-byte-length@npm:1.0.0" @@ -4875,6 +4904,16 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^3.0.0": + version: 3.0.0 + resolution: "chalk@npm:3.0.0" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 37f90b31fd655fb49c2bd8e2a68aebefddd64522655d001ef417e6f955def0ed9110a867ffc878a533f2dafea5f2032433a37c8a7614969baa7f8a1cd424ddfc + languageName: node + linkType: hard + "chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -5544,6 +5583,13 @@ __metadata: languageName: node linkType: hard +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: f6d38088d870a961794a2580b2b2af1027731bb43261cfdce14f19238a88664b351cc8978abc20f06cc6bbde725699dec8deb6fe9816b139fc3f2af28719e774 + languageName: node + linkType: hard + "cssom@npm:^0.5.0": version: 0.5.0 resolution: "cssom@npm:0.5.0" @@ -5981,6 +6027,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: 83d3371f8226487fbad36e160d44f1d9017fb26d46faba6a06fcad15f34633fc827b8c3e99d49f71d5f3253d866e2131826866fd0a3c86626f8eccfc361881ff + languageName: node + linkType: hard + "domexception@npm:^4.0.0": version: 4.0.0 resolution: "domexception@npm:4.0.0" @@ -11871,27 +11924,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.39.0": - version: 1.39.0 - resolution: "playwright-core@npm:1.39.0" +"playwright-core@npm:1.52.0": + version: 1.52.0 + resolution: "playwright-core@npm:1.52.0" bin: playwright-core: cli.js - checksum: e4e01ddea024c7564bbfacdba5f545b004a4017b466af08ae90e5184da4f5bc61e74c96e37cc5e30b7d4f97341c0883cfb2038c8cfbfab65316d714f65b82d83 + checksum: 42e13f5f98dc25ebc95525fb338a215b9097b2ba39d41e99972a190bf75d79979f163f5bc07b1ca06847ee07acb2c9b487d070fab67e9cd55e33310fc05aca3c languageName: node linkType: hard -"playwright@npm:1.39.0": - version: 1.39.0 - resolution: "playwright@npm:1.39.0" +"playwright@npm:1.52.0": + version: 1.52.0 + resolution: "playwright@npm:1.52.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.39.0" + playwright-core: "npm:1.52.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 6f6b2f4381fccfbc560c4cd25e164f5093fec4e2046990587282e18618151d723b299b56c16741ce08e44a759c22e38c3e705b716d31238320e08e6ffcfa7319 + checksum: 214175446089000c2ac997b925063b95f7d86d129c5d7c74caa5ddcb05bcad598dfd569d2133a10dc82d288bf67e7858877dcd099274b0b928b9c63db7d6ecec languageName: node linkType: hard @@ -13344,7 +13397,7 @@ __metadata: "@changesets/cli": "npm:^2.26.2" "@emotion/css": "npm:^11.11.2" "@faker-js/faker": "npm:^8.2.0" - "@playwright/test": "npm:^1.39.0" + "@playwright/test": "npm:^1.52.0" "@types/is-hotkey": "npm:^0.1.10" "@types/is-url": "npm:^1.2.32" "@types/jest": "npm:29.5.6" @@ -13415,6 +13468,7 @@ __metadata: dependencies: "@babel/runtime": "npm:^7.23.2" "@juggle/resize-observer": "npm:^3.4.0" + "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^14.0.0" "@types/is-hotkey": "npm:^0.1.8" "@types/jest": "npm:29.5.6" @@ -13438,7 +13492,7 @@ __metadata: react: ">=18.2.0" react-dom: ">=18.2.0" slate: ">=0.114.0" - slate-dom: ">=0.110.2" + slate-dom: ">=0.116.0" languageName: unknown linkType: soft