1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-09-01 11:12:42 +02:00

Expose transforms (#836)

* refactor to extract applyOperation util

* change handlers to receive transform instead of state

* change onChange to receive a transform, update rich-text example

* fix stack iterationg, convert check-list example

* convert code-highlighting, embeds, emojis examples

* change operations to use full paths, not indexes

* switch split and join to be recursive

* fix linter

* fix onChange calls

* make all operations invertable, add src/operations/* logic

* rename "join" to "merge"

* remove .length property of nodes

* fix node.getFragmentAtRange logic

* convert remaining examples, fix existing changes

* fix .apply() calls and tests

* change setSave and setIsNative transforms

* fix insert_text operations to include marks always

* cleanup and fixes

* fix node inheritance

* fix core onCut handler

* skip constructor in node inheritance

* cleanup

* change updateDescendant to updateNode

* add and update docs

* eliminate need for .apply(), change history to mutable

* add missing file

* add deprecation support to Transform objects

* rename "transform" to "change"

* update benchmark

* add deprecation util to logger

* update transform isNative attr

* fix remaining warn use

* simplify history checkpointing logic

* fix tests

* revert history to being immutable

* fix history

* fix normalize

* fix syntax error from merge
This commit is contained in:
Ian Storm Taylor
2017-09-05 18:03:41 -07:00
committed by GitHub
parent 786050f732
commit 7470a6dd53
1635 changed files with 5963 additions and 5968 deletions

View File

@@ -21,7 +21,6 @@
"arrow-parens": ["error", "as-needed", { "requireForBlockBody": true }],
"arrow-spacing": "error",
"block-spacing": "error",
"capitalized-comments": ["error", "always", { "ignoreConsecutiveComments": true, "ignoreInlineComments": true }],
"comma-dangle": ["error", "only-multiline"],
"comma-spacing": ["error", { "before": false, "after": true }],
"comma-style": ["error", "last"],

5
.gitignore vendored
View File

@@ -11,9 +11,10 @@ tmp
# Gitbook files.
_book
# NPM files.
# Package files.
node_modules
npm-debug.log
yarn-error.log
# Mac stuff.
# OS files.
.DS_Store

View File

@@ -2,7 +2,7 @@
import { __clear } from '../../../../lib/utils/memoize'
export default function ({ state, next }) {
state.document.updateDescendant(next)
state.document.updateNode(next)
}
export function before(state) {

View File

@@ -1,19 +1,18 @@
export default function (state) {
state
.transform()
.change()
.deleteBackward()
.apply()
}
export function before(state) {
return state
.transform()
.change()
.select({
anchorKey: '_cursor_',
anchorOffset: 10,
focusKey: '_cursor_',
focusOffset: 10
})
.apply()
.state
}

View File

@@ -1,19 +1,18 @@
export default function (state) {
state
.transform()
.change()
.insertText('a')
.apply()
}
export function before(state) {
return state
.transform()
.change()
.select({
anchorKey: '_cursor_',
anchorOffset: 10,
focusKey: '_cursor_',
focusOffset: 10
})
.apply()
.state
}

View File

@@ -3,7 +3,6 @@ import SCHEMA from '../../../../lib/schemas/core'
export default function (state) {
state
.transform({ normalized: false })
.change()
.normalize(SCHEMA)
.apply()
}

View File

@@ -1,19 +1,18 @@
export default function (state) {
state
.transform()
.change()
.splitBlock()
.apply()
}
export function before(state) {
return state
.transform()
.change()
.select({
anchorKey: '_cursor_',
anchorOffset: 10,
focusKey: '_cursor_',
focusOffset: 10
})
.apply()
.state
}

View File

@@ -20,9 +20,9 @@ Before creating Slate, I tried a lot of the other rich text libraries out there.
Here's how Slate compares to some of the existing editors out there:
- [**Draft.js**](https://facebook.github.io/draft-js/) — Slate borrowed a few concepts from Draft.js, namely its event system, its use of Immutable.js and React, and its goal of being a "framework" for creating editors. It also borrowed its plugin-centric design from the [Draft.js Plugins](https://github.com/draft-js-plugins/draft-js-plugins) project. But the issues I ran into while using Draft.js were: that lots of the logic around the schema is hardcoded in "core" and difficult to customize, that the transform API is complex to use and not suited to collaborative editing in the future, that serialization isn't considered by the core library in a nice way, that the flat document model made certain behaviors impossible, and that lots of the API feels very heavy to work with.
- [**Draft.js**](https://facebook.github.io/draft-js/) — Slate borrowed a few concepts from Draft.js, namely its event system, its use of Immutable.js and React, and its goal of being a "framework" for creating editors. It also borrowed its plugin-centric design from the [Draft.js Plugins](https://github.com/draft-js-plugins/draft-js-plugins) project. But the issues I ran into while using Draft.js were: that lots of the logic around the schema is hardcoded in "core" and difficult to customize, that the change API is complex to use and not suited to collaborative editing in the future, that serialization isn't considered by the core library in a nice way, that the flat document model made certain behaviors impossible, and that lots of the API feels very heavy to work with.
- [**Prosemirror**](http://prosemirror.net/) — Slate borrowed a few concepts from Prosemirror, namely its nested document tree, and its transform model. But the issues I ran into while using it were: that the API is hard to understand, that the codebase wasn't structured around common node module practices, that lots of magic was built into the core library that was hard to customize, that toolbars and buttons are too tied to the editor itself, and that the documentation isn't great. (It's still in beta though!)
- [**Prosemirror**](http://prosemirror.net/) — Slate borrowed a few concepts from Prosemirror, namely its nested document tree, and its change model. But the issues I ran into while using it were: that the API is hard to understand, that the codebase wasn't structured around common node module practices, that lots of magic was built into the core library that was hard to customize, that toolbars and buttons are too tied to the editor itself, and that the documentation isn't great. (It's still in beta though!)
- [**Quill**](http://quilljs.com/) — I never used Quill directly, so my hesitations about it are solely from considering it in early stages. The issues I see with it are: that the concept of "toolbars" is too coupled with the editor itself, that the configuration is too coupled to HTML classes and DOM nodes, that the idea of "formats" and "toolbars" being linked is limiting, and generally that too much "core" logic is given special privileges and is hard to customize.
@@ -51,9 +51,9 @@ Slate tries to solve the question of "[Why?](#why)" with a few principles:
4. **Stateless and immutable data.** By using React and Immutable.js, the Slate editor is built in a stateless fashion using immutable data structures, which leads to much easier to reason about code, and a much easier time writing plugins.
5. **Intuitive transforms.** Slate's content is edited using "transforms", that are designed to be high level and extremely intuitive to use, so that writing plugins and custom functionality is as simple as possible.
5. **Intuitive changes.** Slate's content is edited using "changes", that are designed to be high level and extremely intuitive to use, so that writing plugins and custom functionality is as simple as possible.
6. **Collaboration-ready data model.** The data model Slate uses—specifically how transforms are applied to the document—has been designed to allow for collaborative editing to be layered on top, so you won't need to rethink everything if you decide to make your editor collaborative. (More work is required on this!)
6. **Collaboration-ready data model.** The data model Slate uses—specifically how changes are applied to the document—has been designed to allow for collaborative editing to be layered on top, so you won't need to rethink everything if you decide to make your editor collaborative. (More work is required on this!)
7. **Clear "core" boundaries.** With a plugin-first architecture, and a schema-less core, it becomes a lot clearer where the boundary is between "core" and "custom", which means that the core experience doesn't get bogged down in edge cases.

View File

@@ -32,7 +32,7 @@
- [Schema](./reference/models/schema.md)
- [State](./reference/models/state.md)
- [Text](./reference//models/text.md)
- [Transform](./reference/models/transform.md)
- [Change](./reference/models/change.md)
## Serializer Reference

View File

@@ -15,10 +15,10 @@ Instead, the new state is propagated to the Slate editor's parent component, who
_To learn more, check out the [`<Editor>` component reference](../reference/components/editor.md)._
### Transforms
### Changes
All of the changes in Slate are applied via [`Transforms`](../reference/models/transform.md). This makes it possible to enforce some of the constraints that Slate needs to enforce, like requiring that [all leaf nodes be text nodes](./the-document-model.md#leaf-text-nodes). This also makes it possible to implement collaborative editing, where information about changes must be serialized and sent over the network to other editors.
All of the changes in Slate are applied via [`Changes`](../reference/models/change.md). This makes it possible to enforce some of the constraints that Slate needs to enforce, like requiring that [all leaf nodes be text nodes](./the-document-model.md#leaf-text-nodes). This also makes it possible to implement collaborative editing, where information about changes must be serialized and sent over the network to other editors.
You should never update the `selection` or `document` of an editor other than by using the [`transform()`](../reference/models/state.md#transform) method of a `State`.
You should never update the `selection` or `document` of an editor other than by using the [`change()`](../reference/models/state.md#change) method of a `State`.
_To learn more, check out the [`Transform` model reference](../reference/models/transform.md)._
_To learn more, check out the [`Change` model reference](../reference/models/change.md)._

View File

@@ -1,7 +1,7 @@
# The Selection Model
Slate keeps track of the user's selection in the editor in an immutable data store called a [`Selection`](../reference/models/selection.md). By doing this, it lets Slate manipulate the selection with transforms, but still update it in the DOM on `render`.
Slate keeps track of the user's selection in the editor in an immutable data store called a [`Selection`](../reference/models/selection.md). By doing this, it lets Slate manipulate the selection with changes, but still update it in the DOM on `render`.
### Always References Text
@@ -17,7 +17,7 @@ This makes selections easier to reason about, while still giving us the benefits
When a selection is used to compute a set of [`Block`](../reference/models/block.md) nodes, by convention those nodes are always the leaf-most `Block` nodes (ie. the lowest `Block` nodes in the tree at their location). This is important, because the nested document model allows for nested `Block` nodes.
This convention makes it much simpler to implement selection and transformation logic, since the user's actions are very often supposed to effect the leaf blocks.
This convention makes it much simpler to implement selection and changeation logic, since the user's actions are very often supposed to effect the leaf blocks.
### Trunk Inlines

View File

@@ -3,9 +3,9 @@
A series of comparisons with other rich text editors, in a highly-opinionated way, and some of which without actual use. If these things mesh with your own experiences with those editors, then you might understand some of the reasons behind why Slate was created. If not, I'm sorry, feel free to contribute edits.
- [**Draft.js**](https://facebook.github.io/draft-js/) — Slate borrowed a few concepts from Draft.js, namely its event system, its use of Immutable.js and React, and its goal of being a "framework" for creating editors. It also borrowed its plugin-centric design from the [Draft.js Plugins](https://github.com/draft-js-plugins/draft-js-plugins) project. But the issues I ran into while using Draft.js were: that lots of the logic around the schema is hardcoded in "core" and difficult to customize, that the transform API is complex to use and not suited to collaborative editing in the future, that serialization isn't considered by the core library in a nice way, that the flat document model made certain behaviors impossible, and that lots of the API feels very heavy to work with.
- [**Draft.js**](https://facebook.github.io/draft-js/) — Slate borrowed a few concepts from Draft.js, namely its event system, its use of Immutable.js and React, and its goal of being a "framework" for creating editors. It also borrowed its plugin-centric design from the [Draft.js Plugins](https://github.com/draft-js-plugins/draft-js-plugins) project. But the issues I ran into while using Draft.js were: that lots of the logic around the schema is hardcoded in "core" and difficult to customize, that the change API is complex to use and not suited to collaborative editing in the future, that serialization isn't considered by the core library in a nice way, that the flat document model made certain behaviors impossible, and that lots of the API feels very heavy to work with.
- [**Prosemirror**](http://prosemirror.net/) — Slate borrowed a few concepts from Prosemirror, namely its nested document tree, and its transform model. But the issues I ran into while using it were: that the API is hard to understand, that the codebase wasn't structured around common node module practices, that lots of magic was built into the core library that was hard to customize, that toolbars and buttons are too tied to the editor itself, and that the documentation isn't great. (It's still in beta though!)
- [**Prosemirror**](http://prosemirror.net/) — Slate borrowed a few concepts from Prosemirror, namely its nested document tree, and its change model. But the issues I ran into while using it were: that the API is hard to understand, that the codebase wasn't structured around common node module practices, that lots of magic was built into the core library that was hard to customize, that toolbars and buttons are too tied to the editor itself, and that the documentation isn't great. (It's still in beta though!)
- [**Quill**](http://quilljs.com/) — I never used Quill directly, so my hesitations about it are solely from considering it in early stages. The issues I see with it are: that the concept of "toolbars" is too coupled with the editor itself, that the configuration is too coupled to HTML classes and DOM nodes, that the idea of "formats" and "toolbars" being linked is limiting, and generally that too much "core" logic is given special privileges and is hard to customize.

View File

@@ -19,7 +19,7 @@ Slate schemas are built up of a set of rules. Every rule has a few properties:
render: Component || Function || Object || String,
decorate: Function,
validate: Function || Object,
transform: Function
change: Function
}
```

View File

@@ -17,7 +17,7 @@ This is the full reference documentation for all of the pieces of Slate, broken
- [Selection](./models/selection.md)
- [State](./models/state.md)
- [Text](./models/text.md)
- [Transform](./models/transform.md)
- [Change](./models/change.md)
- **Serializers**
- [Html](./serializers/html.md)
- [Plain](./serializers/plain.md)

View File

@@ -42,7 +42,8 @@ The top-level React component that renders the Slate editor itself.
- [`focus`](#focus)
- [`getSchema()`](#getschema)
- [`getState()`](#getstate)
- [`onChange(state)`](#onchange)
- [`onChange(change)`](#onchange)
- [`change`](#change)
## Properties
@@ -80,19 +81,19 @@ An optional attribute that, when set to true, attempts to give the content edita
An optional class name to apply to the content editable element.
### `onChange`
`Function onChange(state: State)`
`Function onChange(change: Change)`
A change handler that will be called with the newly-changed editor `state`. You should usually pass the newly changed `state` back into the editor through its `state` property. This hook allows you to add persistence logic to your editor.
A change handler that will be called with the `change` that applied the change. You should usually pass the newly changed `change.state` back into the editor through its `state` property. This hook allows you to add persistence logic to your editor.
### `onDocumentChange`
`Function onDocumentChange(document: Document, state: State)`
`Function onDocumentChange(document: Document, change: Change)`
A convenience handler property that will only be called for changes in state where the document has changed. It is called with the changed `document` and `state`.
A convenience handler property that will only be called for changes in state where the document has changed. It is called with the changed `document` and `change`.
### `onSelectionChange`
`Function onSelectionChange(selection: Selection, state: State)`
`Function onSelectionChange(selection: Selection, change: Change)`
A convenience handler property that will only be called for changes in state where the selection has changed. It is called with the changed `selection` and `state`.
A convenience handler property that will only be called for changes in state where the selection has changed. It is called with the changed `selection` and `change`.
### `plugins`
`Array`
@@ -229,6 +230,6 @@ Return the editor's current schema.
Return the editor's current state.
### `onChange`
`onChange(state: State) => Void`
`onChange(change: Change) => Void`
Effectively the same as `setState`. Invoking this method will update the state of the editor, running it through all of it's plugins, and passing it the parent component, before it cycles back down as the new `state` property of the editor.
Invoking this method will update the state of the editor with the `change`, running it through all of it's plugins, and passing it the parent component, before it cycles back down as the new `state` property of the editor.

View File

@@ -17,7 +17,6 @@ Block nodes may contain nested block nodes, inline nodes, and text nodes—just
- [`type`](#type)
- [Computed Properties](#computed-properties)
- [`kind`](#kind)
- [`length`](#length)
- [`text`](#text)
- [Static Methods](#static-methods)
- [`Block.create`](#blockcreate)
@@ -73,11 +72,6 @@ The custom type of the node (eg. `blockquote` or `list-item`).
An immutable string value of `'block'` for easily separating this node from [`Inline`](./inline.md) or [`Text`](./text.md) nodes.
### `length`
`Number`
The sum of the lengths of all of the descendant [`Text`](./text.md) nodes of this node.
### `text`
`String`

View File

@@ -16,7 +16,6 @@ In some places, you'll see mention of "fragments", which are also `Document` obj
- [`nodes`](#nodes)
- [Computed Properties](#computed-properties)
- [`kind`](#kind)
- [`length`](#length)
- [`text`](#text)
- [Static Methods](#static-methods)
- [`Document.create`](#documentcreate)
@@ -50,11 +49,6 @@ A list of child nodes.
An immutable string value of `'document'` for easily separating this node from [`Block`](./block.md), [`Inline`](./inline.md) or [`Text`](./text.md) nodes.
### `length`
`Number`
The sum of the lengths of all of the descendant [`Text`](./text.md) nodes of this node.
### `text`
`String`

View File

@@ -17,7 +17,6 @@ Inline nodes may contain nested inline nodes and text nodes—just like in the D
- [`type`](#type)
- [Computed Properties](#computed-properties)
- [`kind`](#kind)
- [`length`](#length)
- [`text`](#text)
- [Static Methods](#static-methods)
- [`Inline.create`](#inlinecreate)
@@ -73,11 +72,6 @@ The custom type of the node (eg. `link` or `hashtag`).
An immutable string value of `'inline'` for easily separating this node from [`Block`](./block.md) or [`Text`](./text.md) nodes.
### `length`
`Number`
The sum of the lengths of all of the descendant [`Text`](./text.md) nodes of this node.
### `text`
`String`

View File

@@ -7,7 +7,6 @@
- [`nodes`](#nodes)
- [Computed Properties](#computed-properties)
- [`kind`](#kind)
- [`length`](#length)
- [`text`](#text)
- [Methods](#methods)
- [`filterDescendants`](#filterdescendants)
@@ -68,11 +67,6 @@ A list of child nodes. Defaults to a list with a single text node child.
An immutable string value of `'block'` for easily separating this node from [`Inline`](./inline.md) or [`Text`](./text.md) nodes.
### `length`
`Number`
The sum of the lengths of all of the descendant [`Text`](./text.md) nodes of this node.
### `text`
`String`

View File

@@ -136,21 +136,21 @@ The `match` property is the only required property of a rule. It determines whic
The `decorate` property allows you define a function that will apply extra marks to all of the ranges of text inside a node. It is called with a [`Text`](./text.md) node and the matched node. It should return a list of characters with the desired marks, which will then be added to the text before rendering.
### `normalize`
`Function normalize(transform: Transform, object: Node, failure: Any) => Transform`
`Function normalize(change: Change, object: Node, failure: Any) => Change`
```js
{
normalize: (transform, node, invalidChildren) => {
normalize: (change, node, invalidChildren) => {
invalidChildren.forEach((child) => {
transform.removeNodeByKey(child.key)
change.removeNodeByKey(child.key)
})
return transform
return change
}
}
```
The `normalize` property is a function to run that recovers the editor's state after the `validate` property of a rule has determined that an object is invalid. It is passed a [`Transform`](./transform.md) that it can use to make modifications. It is also passed the return value of the `validate` function, which makes it easy to quickly determine the failure reason from the validation.
The `normalize` property is a function to run that recovers the editor's state after the `validate` property of a rule has determined that an object is invalid. It is passed a [`Change`](./change.md) that it can use to make modifications. It is also passed the return value of the `validate` function, which makes it easy to quickly determine the failure reason from the validation.
### `render`
`Component` <br/>

View File

@@ -7,9 +7,9 @@ import { State } from 'slate'
A `State` is the top-level representation of data in Slate, containing both a [`Document`](./document.md) and a [`Selection`](./selection.md). It's what you need to paste into the Slate [`<Editor>`](../components/editor.md) to render something onto the page.
All transforms to the document and selection are also performed through the state object, so that they can stay in sync, and be propagated to its internal history of undo/redo state.
All changes to the document and selection are also performed through the state object, so that they can stay in sync, and be propagated to its internal history of undo/redo state.
For convenience, in addition to transforms, many of the [`Selection`](./selection.md) and [`Document`](./document.md) properties are exposed as proxies on the `State` object.
For convenience, in addition to changes, many of the [`Selection`](./selection.md) and [`Document`](./document.md) properties are exposed as proxies on the `State` object.
- [Properties](#properties)
- [`document`](#document)
@@ -39,7 +39,7 @@ For convenience, in addition to transforms, many of the [`Selection`](./selectio
- [`State.create`](#statecreate)
- [`State.isState`](#stateisstate)
- [Methods](#methods)
- [`transform`](#transform)
- [`change`](#change)
## Properties
@@ -186,7 +186,7 @@ Returns a boolean if the passed in argument is a `State`.
## Methods
### `transform`
`transform() => Transform`
### `change`
`change() => Change`
Create a new [`Transform`](./transform.md) that acts on the current state.
Create a new [`Change`](./change.md) that acts on the current state.

View File

@@ -12,7 +12,6 @@ A text node in a Slate [`Document`](./document.md). Text nodes are always the bo
- [`key`](#key)
- [Computed Properties](#computed-properties)
- [`kind`](#kind)
- [`length`](#length)
- [`text`](#text)
- [Static Methods](#static-methods)
- [`Text.create`](#textcreate)
@@ -46,11 +45,6 @@ A unique identifier for the node.
An immutable string value of `'text'` for easily separating this node from [`Inline`](./inline.md) or [`Block`](./block.md) nodes.
### `length`
`Number`
The length of all of the characters in the text node.
### `text`
`String`

View File

@@ -1,22 +1,22 @@
# `Transform`
# `Change`
```js
import { Transform } from 'slate'
import { Change } from 'slate'
```
A transform allows you to define a series of changes you'd like to make to the current [`Document`](./document.md) or [`Selection`](./selection.md) in a [`State`](./state.md).
A change allows you to define a series of changes you'd like to make to the current [`Document`](./document.md) or [`Selection`](./selection.md) in a [`State`](./state.md).
All changes are performed through `Transform` objects, so that a history of changes can be preserved for use in undo/redo operations, and to make collaborative editing possible.
All changes are performed through `Change` objects, so that a history of changes can be preserved for use in undo/redo operations, and to make collaborative editing possible.
Transform methods can either operate on the [`Document`](./document.md), the [`Selection`](./selection.md), or both at once.
Change methods can either operate on the [`Document`](./document.md), the [`Selection`](./selection.md), or both at once.
- [Properties](#properties)
- [`state`](#state)
- [Methods](#methods)
- [`apply`](#apply)
- [`call`](#call)
- [Current State Transforms](#current-state-transforms)
- [Current State Changes](#current-state-changes)
- [`deleteBackward`](#deletebackward)
- [`deleteForward`](#deleteforward)
- [`delete`](#delete)
@@ -36,7 +36,7 @@ Transform methods can either operate on the [`Document`](./document.md), the [`S
- [`wrapBlock`](#wrapblock)
- [`wrapInline`](#wrapinline)
- [`wrapText`](#wraptext)
- [Selection Transforms](#selection-transforms)
- [Selection Changes](#selection-changes)
- [`blur`](#blur)
- [`collapseTo{Edge}Of`](#collapsetoedgeof)
- [`collapseTo{Edge}Of{Direction}Block`](#collapsetoedgeofdirectionblock)
@@ -53,7 +53,7 @@ Transform methods can either operate on the [`Document`](./document.md), the [`S
- [`select`](#select)
- [`selectAll`](#selectall)
- [`deselect`](#deselect)
- [Node Transforms](#node-transforms)
- [Node Changes](#node-changes)
- [`addMarkByKey`](#addmarkbykey)
- [`insertNodeByKey`](#insertnodebykey)
- [`insertFragmentByKey`](#insertfragmentbykey)
@@ -70,7 +70,7 @@ Transform methods can either operate on the [`Document`](./document.md), the [`S
- [`unwrapNodeByKey`](#unwrapnodebykey)
- [`wrapBlockByKey`](#wrapblockbykey)
- [`wrapInlineByKey`](#wrapinlinebykey)
- [Document Transforms](#document-transforms)
- [Document Changes](#document-changes)
- [`deleteAtRange`](#deleteatrange)
- [`deleteBackwardAtRange`](#deletebackwardatrange)
- [`deleteForwardAtRange`](#deleteforwardatrange)
@@ -90,7 +90,7 @@ Transform methods can either operate on the [`Document`](./document.md), the [`S
- [`wrapBlockAtRange`](#wrapblockatrange)
- [`wrapInlineAtRange`](#wrapinlineatrange)
- [`wrapTextAtRange`](#wraptextatrange)
- [History Transforms](#history-transforms)
- [History Changes](#history-changes)
- [`redo`](#redo)
- [`undo`](#undo)
@@ -99,237 +99,235 @@ Transform methods can either operate on the [`Document`](./document.md), the [`S
### `state`
A [`State`](./state.md) with the transform's current operations applied. Each time you run a new transform function this property will be updated.
A [`State`](./state.md) with the change's current operations applied. Each time you run a new change function this property will be updated.
## Methods
### `apply`
`apply(options: Object) => State`
`apply(options: Object) => Change`
Applies all of the current transform steps, returning the newly transformed [`State`](./state.md). An `options` object is optional, containing values of:
- `save: Boolean` — override the editor's built-in logic of whether to create a new snapshot in the history, that can be reverted to later.
Applies current change steps, saving them to the history if needed.
### `call`
`call(customTransform: Function, ...arguments) => Transform`
`call(customChange: Function, ...arguments) => Change`
This method calls the provided function argument `customTransform` with the current instance of the `Transform` object as the first argument and passes through the remaining arguments.
This method calls the provided function argument `customChange` with the current instance of the `Change` object as the first argument and passes through the remaining arguments.
The function signature for `customTransform` is:
The function signature for `customChange` is:
`customTransform(transform: Transform, ...arguments)`
`customChange(change: Change, ...arguments)`
The purpose of `call` is to enable custom transform methods to exist and called in a chain. For example:
The purpose of `call` is to enable custom change methods to exist and called in a chain. For example:
```
return state.transform()
.call(myCustomInsertTableTransform, columns, rows)
return state.change()
.call(myCustomInsertTableChange, columns, rows)
.focus()
.apply()
```
## Current State Transforms
## Current State Changes
### `deleteBackward`
`deleteBackward(n: Number) => Transform`
`deleteBackward(n: Number) => Change`
Delete backward `n` characters at the current cursor. If the selection is expanded, this method is equivalent to a regular [`delete()`](#delete). `n` defaults to `1`.
### `deleteForward`
`deleteForward(n: Number) => Transform`
`deleteForward(n: Number) => Change`
Delete forward `n` characters at the current cursor. If the selection is expanded, this method is equivalent to a regular [`delete()`](#delete). `n` defaults to `1`.
### `delete`
`delete() => Transform`
`delete() => Change`
Delete everything in the current selection.
### `insertBlock`
`insertBlock(block: Block) => Transform` <br/>
`insertBlock(properties: Object) => Transform` <br/>
`insertBlock(type: String) => Transform`
`insertBlock(block: Block) => Change` <br/>
`insertBlock(properties: Object) => Change` <br/>
`insertBlock(type: String) => Change`
Insert a new block at the same level as the current block, splitting the current block to make room if it is non-empty. If the selection is expanded, it will be deleted first.
### `insertFragment`
`insertFragment(fragment: Document) => Transform`
`insertFragment(fragment: Document) => Change`
Insert a [`fragment`](./document.md) at the current selection. If the selection is expanded, it will be deleted first.
### `insertInline`
`insertInline(inline: Inline) => Transform` <br/>
`insertInline(properties: Object) => Transform`
`insertInline(inline: Inline) => Change` <br/>
`insertInline(properties: Object) => Change`
Insert a new inline at the current cursor position, splitting the text to make room if it is non-empty. If the selection is expanded, it will be deleted first.
### `insertText`
`insertText(text: String) => Transform`
`insertText(text: String) => Change`
Insert a string of `text` at the current selection. If the selection is expanded, it will be deleted first.
### `addMark`
`addMark(mark: Mark) => Transform` <br/>
`addMark(properties: Object) => Transform` <br/>
`addMark(type: String) => Transform`
`addMark(mark: Mark) => Change` <br/>
`addMark(properties: Object) => Change` <br/>
`addMark(type: String) => Change`
Add a [`mark`](./mark.md) to the characters in the current selection. For convenience, you can pass a `type` string or `properties` object to implicitly create a [`Mark`](./mark.md) of that type.
### `setBlock`
`setBlock(properties: Object) => Transform` <br/>
`setBlock(type: String) => Transform`
`setBlock(properties: Object) => Change` <br/>
`setBlock(type: String) => Change`
Set the `properties` of the [`Block`](./block.md) in the current selection. For convenience, you can pass a `type` string to set the blocks's type only.
### `setInline`
`setInline(properties: Object) => Transform` <br/>
`setInline(type: String) => Transform`
`setInline(properties: Object) => Change` <br/>
`setInline(type: String) => Change`
Set the `properties` of the [`Inline`](./inline.md) nodes in the current selection. For convenience, you can pass a `type` string to set the inline's type only.
### `splitBlock`
`splitBlock(depth: Number) => Transform`
`splitBlock(depth: Number) => Change`
Split the [`Block`](./block.md) in the current selection by `depth` levels. If the selection is expanded, it will be deleted first. `depth` defaults to `1`.
### `splitInline`
`splitInline(depth: Number) => Transform`
`splitInline(depth: Number) => Change`
Split the [`Inline`](./inline.md) node in the current selection by `depth` levels. If the selection is expanded, it will be deleted first. `depth` defaults to `Infinity`.
### `removeMark`
`removeMark(mark: Mark) => Transform` <br/>
`removeMark(properties: Object) => Transform` <br/>
`removeMark(type: String) => Transform`
`removeMark(mark: Mark) => Change` <br/>
`removeMark(properties: Object) => Change` <br/>
`removeMark(type: String) => Change`
Remove a [`mark`](./mark.md) from the characters in the current selection. For convenience, you can pass a `type` string or `properties` object to implicitly create a [`Mark`](./mark.md) of that type.
### `toggleMark`
`toggleMark(mark: Mark) => Transform` <br/>
`toggleMark(properties: Object) => Transform` <br/>
`toggleMark(type: String) => Transform`
`toggleMark(mark: Mark) => Change` <br/>
`toggleMark(properties: Object) => Change` <br/>
`toggleMark(type: String) => Change`
Add or remove a [`mark`](./mark.md) from the characters in the current selection, depending on it already exists on any or not. For convenience, you can pass a `type` string or `properties` object to implicitly create a [`Mark`](./mark.md) of that type.
### `unwrapBlock`
`unwrapBlock([type: String], [data: Data]) => Transform`
`unwrapBlock([type: String], [data: Data]) => Change`
Unwrap all [`Block`](./block.md) nodes in the current selection that match a `type` and/or `data`.
### `unwrapInline`
`unwrapInline([type: String], [data: Data]) => Transform`
`unwrapInline([type: String], [data: Data]) => Change`
Unwrap all [`Inline`](./inline.md) nodes in the current selection that match a `type` and/or `data`.
### `wrapBlock`
`wrapBlock(type: String, [data: Data]) => Transform`
`wrapBlock(type: String, [data: Data]) => Change`
Wrap the [`Block`](./block.md) nodes in the current selection with a new [`Block`](./block.md) node of `type`, with optional `data`.
### `wrapInline`
`wrapInline(type: String, [data: Data]) => Transform`
`wrapInline(type: String, [data: Data]) => Change`
Wrap the [`Inline`](./inline.md) nodes in the current selection with a new [`Inline`](./inline.md) node of `type`, with optional `data`.
### `wrapText`
`wrapText(prefix: String, [suffix: String]) => Transform`
`wrapText(prefix: String, [suffix: String]) => Change`
Surround the text in the current selection with `prefix` and `suffix` strings. If the `suffix` is ommitted, the `prefix` will be used instead.
## Selection Transforms
## Selection Changes
### `blur`
`blur() => Transform`
`blur() => Change`
Blur the current selection.
### `collapseTo{Edge}`
`collapseTo{Edge}() => Transform`
`collapseTo{Edge}() => Change`
Collapse the current selection to its `{Edge}`. Where `{Edge}` is either `Anchor`, `Focus`, `Start` or `End`.
### `collapseTo{Edge}Of`
`collapseTo{Edge}Of(node: Node) => Transform`
`collapseTo{Edge}Of(node: Node) => Change`
Collapse the current selection to the `{Edge}` of `node`. Where `{Edge}` is either `Start` or `End`.
### `collapseTo{Edge}Of{Direction}Block`
`collapseTo{Edge}Of{Direction}Block() => Transform`
`collapseTo{Edge}Of{Direction}Block() => Change`
Collapse the current selection to the `{Edge}` of the next [`Block`](./block.md) node in `{Direction}`. Where `{Edge}` is either `{Start}` or `{End}` and `{Direction}` is either `Next` or `Previous`.
### `collapseTo{Edge}Of{Direction}Text`
`collapseTo{Edge}Of{Direction}Text() => Transform`
`collapseTo{Edge}Of{Direction}Text() => Change`
Collapse the current selection to the `{Edge}` of the next [`Text`](./text.md) node in `{Direction}`. Where `{Edge}` is either `{Start}` or `{End}` and `{Direction}` is either `Next` or `Previous`.
### `extend`
`extend(n: Number) => Transform`
`extend(n: Number) => Change`
Extend the current selection's points by `n` characters. `n` can be positive or negative to indicate direction.
### `extendTo{Edge}Of`
`extendTo{Edge}Of(node: Node) => Transform`
`extendTo{Edge}Of(node: Node) => Change`
Extend the current selection to the `{Edge}` of a `node`. Where `{Edge}` is either `Start` or `End`.
### `flip`
`flip() => Transform`
`flip() => Change`
Flip the selection.
### `focus`
`focus() => Transform`
`focus() => Change`
Focus the current selection.
### `move`
`move(n: Number) => Transform`
`move(n: Number) => Change`
Move the current selection's offsets by `n`.
### `move{Edge}`
`move{Edge}(n: Number) => Transform`
`move{Edge}(n: Number) => Change`
Move the current selection's `edge` offset by `n`. `edge` can be one of `Start`, `End`.
### `moveOffsetsTo`
`moveOffsetsTo(anchorOffset: Number, focusOffset: Number) => Transform`
`moveOffsetsTo(anchorOffset: Number, focusOffset: Number) => Change`
Move the current selection's offsets to a new `anchorOffset` and `focusOffset`.
### `moveToRangeOf`
`moveToRangeOf(node: Node) => Transform`
`moveToRangeOf(node: Node) => Change`
Move the current selection's anchor point to the start of a `node` and its focus point to the end of the `node`.
### `select`
`select(properties: Selection || Object) => Transform`
`select(properties: Selection || Object) => Change`
Set the current selection to a selection with merged `properties`. The `properties` can either be a [`Selection`](./selection.md) object or a plain Javascript object of selection properties.
### `selectAll`
`selectAll() => Transform`
`selectAll() => Change`
Select the entire document and focus the selection.
### `deselect`
`deselect() => Transform`
`deselect() => Change`
Unset the selection.
## Node Transforms
## Node Changes
### `addMarkByKey`
`addMarkByKey(key: String, offset: Number, length: Number, mark: Mark) => Transform`
`addMarkByKey(key: String, offset: Number, length: Number, mark: Mark) => Change`
Add a `mark` to `length` characters starting at an `offset` in a [`Node`](./node.md) by its `key`.
### `insertNodeByKey`
`insertNodeByKey(key: String, index: Number, node: Node) => Transform`
`insertNodeByKey(key: String, index: Number, node: Node) => Change`
Insert a `node` at `index` inside a parent [`Node`](./node.md) by its `key`.
@@ -339,196 +337,196 @@ Insert a `node` at `index` inside a parent [`Node`](./node.md) by its `key`.
Insert a [`Fragment`](./fragment.md) at `index` inside a parent [`Node`](./node.md) by its `key`.
### `insertTextByKey`
`insertTextByKey(key: String, offset: Number, text: String, [marks: Set]) => Transform`
`insertTextByKey(key: String, offset: Number, text: String, [marks: Set]) => Change`
Insert `text` at an `offset` in a [`Text Node`](./text.md) with optional `marks`.
### `moveNodeByKey`
`moveNodeByKey(key: String, newKey: String, newIndex: Number) => Transform`
`moveNodeByKey(key: String, newKey: String, newIndex: Number) => Change`
Move a [`Node`](./node.md) by its `key` to a new parent node with its `newKey` and at a `newIndex`.
### `removeMarkByKey`
`removeMarkByKey(key: String, offset: Number, length: Number, mark: Mark) => Transform`
`removeMarkByKey(key: String, offset: Number, length: Number, mark: Mark) => Change`
Remove a `mark` from `length` characters starting at an `offset` in a [`Node`](./node.md) by its `key`.
### `removeNodeByKey`
`removeNodeByKey(key: String) => Transform`
`removeNodeByKey(key: String) => Change`
Remove a [`Node`](./node.md) from the document by its `key`.
### `removeTextByKey`
`removeTextByKey(key: String, offset: Number, length: Number) => Transform`
`removeTextByKey(key: String, offset: Number, length: Number) => Change`
Remove `length` characters of text starting at an `offset` in a [`Node`](./node.md) by its `key`.
### `setMarkByKey`
`setMarkByKey(key: String, offset: Number, length: Number, mark: Mark, properties: Object) => Transform`
`setMarkByKey(key: String, offset: Number, length: Number, mark: Mark, properties: Object) => Change`
Set a dictionary of `properties` on a [`mark`](./mark.md) on a [`Node`](./node.md) by its `key`.
### `setNodeByKey`
`setNodeByKey(key: String, properties: Object) => Transform` <br/>
`setNodeByKey(key: String, type: String) => Transform`
`setNodeByKey(key: String, properties: Object) => Change` <br/>
`setNodeByKey(key: String, type: String) => Change`
Set a dictionary of `properties` on a [`Node`](./node.md) by its `key`. For convenience, you can pass a `type` string or `properties` object.
### `splitNodeByKey`
`splitNodeByKey(key: String, offset: Number) => Transform`
`splitNodeByKey(key: String, offset: Number) => Change`
Split a node by its `key` at an `offset`.
### `unwrapInlineByKey`
`unwrapInlineByKey(key: String, properties: Object) => Transform` <br/>
`unwrapInlineByKey(key: String, type: String) => Transform`
`unwrapInlineByKey(key: String, properties: Object) => Change` <br/>
`unwrapInlineByKey(key: String, type: String) => Change`
Unwrap all inner content of an [`Inline`](./inline.md) node that match `properties`. For convenience, you can pass a `type` string or `properties` object.
### `unwrapBlockByKey`
`unwrapBlockByKey(key: String, properties: Object) => Transform` <br/>
`unwrapBlockByKey(key: String, type: String) => Transform`
`unwrapBlockByKey(key: String, properties: Object) => Change` <br/>
`unwrapBlockByKey(key: String, type: String) => Change`
Unwrap all inner content of a [`Block`](./block.md) node that match `properties`. For convenience, you can pass a `type` string or `properties` object.
### `unwrapNodeByKey`
`unwrapNodeByKey(key: String) => Transform`
`unwrapNodeByKey(key: String) => Change`
Unwrap a single node from its parent. If the node is surrounded with siblings, its parent will be split. If the node is the only child, the parent is removed, and simply replaced by the node itself. Cannot unwrap a root node.
### `wrapBlockByKey`
`wrapBlockByKey(key: String, properties: Object) => Transform` <br/>
`wrapBlockByKey(key: String, type: String) => Transform`
`wrapBlockByKey(key: String, properties: Object) => Change` <br/>
`wrapBlockByKey(key: String, type: String) => Change`
Wrap the given node in a [`Block`](./block.md) node that match `properties`. For convenience, you can pass a `type` string or `properties` object.
### `wrapInlineByKey`
`wrapInlineByKey(key: String, properties: Object) => Transform` <br/>
`wrapInlineByKey(key: String, type: String) => Transform`
`wrapInlineByKey(key: String, properties: Object) => Change` <br/>
`wrapInlineByKey(key: String, type: String) => Change`
Wrap the given node in a [`Inline`](./inline.md) node that match `properties`. For convenience, you can pass a `type` string or `properties` object.
## Document Transforms
## Document Changes
### `deleteBackwardAtRange`
`deleteBackwardAtRange(range: Selection, n: Number) => Transform`
`deleteBackwardAtRange(range: Selection, n: Number) => Change`
Delete backward `n` characters at a `range`. If the `range` is expanded, this method is equivalent to a regular [`delete()`](#delete). `n` defaults to `1`.
### `deleteForwardAtRange`
`deleteForwardAtRange(range: Selection, n: Number) => Transform`
`deleteForwardAtRange(range: Selection, n: Number) => Change`
Delete forward `n` characters at a `range`. If the `range` is expanded, this method is equivalent to a regular [`delete()`](#delete). `n` defaults to `1`.
### `deleteAtRange`
`deleteAtRange(range: Selection, ) => Transform`
`deleteAtRange(range: Selection, ) => Change`
Delete everything in a `range`.
### `insertBlockAtRange`
`insertBlockAtRange(range: Selection, block: Block) => Transform` <br/>
`insertBlockAtRange(range: Selection, properties: Object) => Transform` <br/>
`insertBlockAtRange(range: Selection, type: String) => Transform`
`insertBlockAtRange(range: Selection, block: Block) => Change` <br/>
`insertBlockAtRange(range: Selection, properties: Object) => Change` <br/>
`insertBlockAtRange(range: Selection, type: String) => Change`
Insert a new block at the same level as the leaf block at a `range`, splitting the current block to make room if it is non-empty. If the selection is expanded, it will be deleted first.
### `insertFragmentAtRange`
`insertFragmentAtRange(range: Selection, fragment: Document) => Transform`
`insertFragmentAtRange(range: Selection, fragment: Document) => Change`
Insert a [`fragment`](./document.md) at a `range`. If the selection is expanded, it will be deleted first.
### `insertInlineAtRange`
`insertInlineAtRange(range: Selection, inline: Inline) => Transform` <br/>
`insertInlineAtRange(range: Selection, properties: Object) => Transform`
`insertInlineAtRange(range: Selection, inline: Inline) => Change` <br/>
`insertInlineAtRange(range: Selection, properties: Object) => Change`
Insert a new inline at a `range`, splitting the text to make room if it is non-empty. If the selection is expanded, it will be deleted first.
### `insertTextAtRange`
`insertTextAtRange(range: Selection, text: String) => Transform`
`insertTextAtRange(range: Selection, text: String) => Change`
Insert a string of `text` at a `range`. If the selection is expanded, it will be deleted first.
### `addMarkAtRange`
`addMarkAtRange(range: Selection, mark: Mark) => Transform` <br/>
`addMarkAtRange(range: Selection, properties: Object) => Transform` <br/>
`addMarkAtRange(range: Selection, type: String) => Transform`
`addMarkAtRange(range: Selection, mark: Mark) => Change` <br/>
`addMarkAtRange(range: Selection, properties: Object) => Change` <br/>
`addMarkAtRange(range: Selection, type: String) => Change`
Add a [`mark`](./mark.md) to the characters in a `range`. For convenience, you can pass a `type` string or `properties` object to implicitly create a [`Mark`](./mark.md) of that type.
### `setBlockAtRange`
`setBlockAtRange(range: Selection, properties: Object) => Transform` <br/>
`setBlock(range: Selection, type: String) => Transform`
`setBlockAtRange(range: Selection, properties: Object) => Change` <br/>
`setBlock(range: Selection, type: String) => Change`
Set the `properties` of the [`Block`](./block.md) in a `range`. For convenience, you can pass a `type` string to set the blocks's type only.
### `setInlineAtRange`
`setInlineAtRange(range: Selection, properties: Object) => Transform` <br/>
`setInline(range: Selection, type: String) => Transform`
`setInlineAtRange(range: Selection, properties: Object) => Change` <br/>
`setInline(range: Selection, type: String) => Change`
Set the `properties` of the [`Inline`](./inline.md) nodes in a `range`. For convenience, you can pass a `type` string to set the inline's type only.
### `splitBlockAtRange`
`splitBlockAtRange(range: Selection, depth: Number) => Transform`
`splitBlockAtRange(range: Selection, depth: Number) => Change`
Split the [`Block`](./block.md) in a `range` by `depth` levels. If the selection is expanded, it will be deleted first. `depth` defaults to `1`.
### `splitInlineAtRange`
`splitInlineAtRange(range: Selection, depth: Number) => Transform`
`splitInlineAtRange(range: Selection, depth: Number) => Change`
Split the [`Inline`](./inline.md) node in a `range` by `depth` levels. If the selection is expanded, it will be deleted first. `depth` defaults to `Infinity`.
### `removeMarkAtRange`
`removeMarkAtRange(range: Selection, mark: Mark) => Transform` <br/>
`removeMarkAtRange(range: Selection, properties: Object) => Transform` <br/>
`removeMarkAtRange(range: Selection, type: String) => Transform`
`removeMarkAtRange(range: Selection, mark: Mark) => Change` <br/>
`removeMarkAtRange(range: Selection, properties: Object) => Change` <br/>
`removeMarkAtRange(range: Selection, type: String) => Change`
Remove a [`mark`](./mark.md) from the characters in a `range`. For convenience, you can pass a `type` string or `properties` object to implicitly create a [`Mark`](./mark.md) of that type.
### `toggleMarkAtRange`
`toggleMarkAtRange(range: Selection, mark: Mark) => Transform` <br/>
`toggleMarkAtRange(range: Selection, properties: Object) => Transform` <br/>
`toggleMarkAtRange(range: Selection, type: String) => Transform`
`toggleMarkAtRange(range: Selection, mark: Mark) => Change` <br/>
`toggleMarkAtRange(range: Selection, properties: Object) => Change` <br/>
`toggleMarkAtRange(range: Selection, type: String) => Change`
Add or remove a [`mark`](./mark.md) from the characters in a `range`, depending on whether any of them already have the mark. For convenience, you can pass a `type` string or `properties` object to implicitly create a [`Mark`](./mark.md) of that type.
### `unwrapBlockAtRange`
`unwrapBlockAtRange(range: Selection, properties: Object) => Transform` <br/>
`unwrapBlockAtRange(range: Selection, type: String) => Transform`
`unwrapBlockAtRange(range: Selection, properties: Object) => Change` <br/>
`unwrapBlockAtRange(range: Selection, type: String) => Change`
Unwrap all [`Block`](./block.md) nodes in a `range` that match `properties`. For convenience, you can pass a `type` string or `properties` object.
### `unwrapInlineAtRange`
`unwrapInlineAtRange(range: Selection, properties: Object) => Transform` <br/>
`unwrapInlineAtRange(range: Selection, type: String) => Transform`
`unwrapInlineAtRange(range: Selection, properties: Object) => Change` <br/>
`unwrapInlineAtRange(range: Selection, type: String) => Change`
Unwrap all [`Inline`](./inline.md) nodes in a `range` that match `properties`. For convenience, you can pass a `type` string or `properties` object.
### `wrapBlockAtRange`
`wrapBlockAtRange(range: Selection, properties: Object) => Transform` <br/>
`wrapBlockAtRange(range: Selection, type: String) => Transform`
`wrapBlockAtRange(range: Selection, properties: Object) => Change` <br/>
`wrapBlockAtRange(range: Selection, type: String) => Change`
Wrap the [`Block`](./block.md) nodes in a `range` with a new [`Block`](./block.md) node with `properties`. For convenience, you can pass a `type` string or `properties` object.
### `wrapInlineAtRange`
`wrapInlineAtRange(range: Selection, properties: Object) => Transform` <br/>
`wrapInlineAtRange(range: Selection, type: String) => Transform`
`wrapInlineAtRange(range: Selection, properties: Object) => Change` <br/>
`wrapInlineAtRange(range: Selection, type: String) => Change`
Wrap the [`Inline`](./inline.md) nodes in a `range` with a new [`Inline`](./inline.md) node with `properties`. For convenience, you can pass a `type` string or `properties` object.
### `wrapTextAtRange`
`wrapTextAtRange(range: Selection, prefix: String, [suffix: String]) => Transform`
`wrapTextAtRange(range: Selection, prefix: String, [suffix: String]) => Change`
Surround the text in a `range` with `prefix` and `suffix` strings. If the `suffix` is ommitted, the `prefix` will be used instead.
## History Transforms
## History Changes
### `redo`
`redo() => Transform`
`redo() => Change`
Move forward one step in the history.
### `undo`
`undo() => Transform`
`undo() => Change`
Move backward one step in the history.

View File

@@ -5,7 +5,7 @@ Plugins can be attached to an editor to alter its behavior in different ways. Pl
Each editor has a "middleware stack" of plugins, which has a specific order.
When the editor needs to resolve a plugin-related handler, it will loop through its plugin stack, searching for the first plugin that successfully returns a value. After receiving that value, the editor will **not** continue to search the remaining plugins; it returns early.
When the editor needs to resolve a plugin-related handler, it will loop through its plugin stack, searching for the first plugin that successfully returns a value. After receiving that value, the editor will **not** continue to search the remaining plugins; it returns early. If you'd like for the stack to continue, a plugin handler should return `undefined`.
- [Conventions](#conventions)
- [Event Handler Properties](#event-handle-properties)
@@ -65,21 +65,17 @@ Each event handler can choose to return a new `state` object, in which case the
This handler is called right before a string of text is inserted into the `contenteditable` element.
Make sure to `event.preventDefault()` (and return `state`) if you do not want the default insertion behavior to occur! If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
Make sure to `event.preventDefault()` if you do not want the default insertion behavior to occur! If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
### `onBlur`
`Function onBlur(event: Event, data: Object, state: State, editor: Editor) => State || Void`
This handler is called when the editor's `contenteditable` element is blurred.
If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
This handler is called when the editor's `contenteditable` element is blurred. If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
### `onFocus`
`Function onFocus(event: Event, data: Object, state: State, editor: Editor) => State || Void`
This handler is called when the editor's `contenteditable` element is focused.
If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
This handler is called when the editor's `contenteditable` element is focused. If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
### `onCopy`
`Function onCopy(event: Event, data: Object, state: State, editor: Editor) => State || Void`
@@ -100,9 +96,7 @@ If no other plugin handles this event, it will be handled by the [Core plugin](.
### `onCut`
`Function onCut(event: Event, data: Object, state: State, editor: Editor) => State || Void`
This handler is equivalent to the `onCopy` handler.
If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
This handler is equivalent to the `onCopy` handler. If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
### `onDrop`
`Function onDrop(event: Event, data: Object, state: State, editor: Editor) => State || Void`
@@ -163,7 +157,7 @@ The `isModAlt` boolean is `true` if the `control` key was pressed on Windows or
The `isLine` and `isWord` booleans represent whether the "line modifier" or "word modifier" hotkeys are pressed when deleting or moving the cursor. For example, on a Mac `option + right` moves the cursor to the right one word at a time.
Make sure to `event.preventDefault()` (and return `state`) if you do not want the default insertion behavior to occur! If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
Make sure to `event.preventDefault()` if you do not want the default insertion behavior to occur! If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
### `onKeyUp`
`Function onKeUp(event: Event, data: Object, state: State, editor: Editor) => State || Void`
@@ -208,7 +202,7 @@ The `data` object contains a State [`Selection`](../models/selection.md) object
If no other plugin handles this event, it will be handled by the [Core plugin](./core.md).
_Note: This is **not** Slate's internal selection representation (although it mirrors it). If you want to get notified when Slate's selection changes, use the [`onSelectionChange`](../components/editor.md#onselectionchange) property of the `<Editor>`. This handler is instead meant to give you lower-level access to the DOM selection handling._
_Note: This is **not** Slate's internal selection representation (although it mirrors it). If you want to get notified when Slate's selection changes, use the [`onSelectionChange`](../components/editor.md#onselectionchange) property of the `<Editor>`. This handler is instead meant to give you lower-level access to the DOM selection handling, which **is not always triggered** as you'd expect._
## Other Properties
@@ -220,20 +214,14 @@ _Note: This is **not** Slate's internal selection representation (although it mi
```
### `onChange`
`Function onChange(state: State) => State || Void`
`Function onChange(change: Change) => Any || Void`
The `onChange` handler isn't a native browser event handler. Instead, it is invoked whenever the editor state changes. Returning a new state will update the editor's state, continuing down the plugin stack.
Unlike the native event handlers, results from the `onChange` handler **are cumulative**! This means that every plugin in the stack that defines an `onChange` handler will have its handler resolved for every change the editor makes; the editor will not return early after the first plugin's handler is called.
This allows you to stack up changes across the entire plugin stack.
The `onChange` handler isn't a native browser event handler. Instead, it is invoked whenever the editor state changes. This allows plugins to augment a change however they want.
### `onBeforeChange`
`Function onBeforeChange(state: State) => State || Void`
The `onBeforeChange` handler isn't a native browser event handler. Instead, it is invoked whenever the editor receives a new state and before propagating a new state to `onChange`. Returning a new state will update the editor's state before rendering, continuing down the plugin stack.
Like `onChange`, `onBeforeChange` is cumulative.
The `onBeforeChange` handler isn't a native browser event handler. Instead, it is invoked whenever the editor receives a new state and before propagating a new state to `onChange`.
### `render`
`Function render(props: Object, state: State, editor: Editor) => Object || Void`

View File

@@ -91,9 +91,9 @@ class App extends React.Component {
// Prevent the ampersand character from being inserted.
event.preventDefault()
// Transform the state by inserting "and" at the cursor's position.
// Change the state by inserting "and" at the cursor's position.
const newState = state
.transform()
.change()
.insertText('and')
.apply()

View File

@@ -33,7 +33,7 @@ class App extends React.Component {
const isCode = state.blocks.some(block => block.type == 'code')
return state
.transform()
.change()
.setBlock(isCode ? 'paragraph' : 'code')
.apply()
}
@@ -79,7 +79,7 @@ class App extends React.Component {
case 66: {
event.preventDefault()
return state
.transform()
.change()
.addMark('bold')
.apply()
}
@@ -89,7 +89,7 @@ class App extends React.Component {
const isCode = state.blocks.some(block => block.type == 'code')
event.preventDefault()
return state
.transform()
.change()
.setBlock(isCode ? 'paragraph' : 'code')
.apply()
}
@@ -158,7 +158,7 @@ class App extends React.Component {
case 66: {
event.preventDefault()
return state
.transform()
.change()
.toggleMark('bold')
.apply()
}
@@ -167,7 +167,7 @@ class App extends React.Component {
const isCode = state.blocks.some(block => block.type == 'code')
event.preventDefault()
return state
.transform()
.change()
.setBlock(isCode ? 'paragraph' : 'code')
.apply()
}

View File

@@ -28,7 +28,7 @@ class App extends React.Component {
event.preventDefault()
const newState = state
.transform()
.change()
.insertText('and')
.apply()
@@ -96,7 +96,7 @@ class App extends React.Component {
event.preventDefault()
const newState = state
.transform()
.change()
.insertText('and')
.apply()
@@ -149,7 +149,7 @@ class App extends React.Component {
// Otherwise, set the currently selected blocks type to "code".
return state
.transform()
.change()
.setBlock('code')
.apply()
}
@@ -202,7 +202,7 @@ class App extends React.Component {
// Toggle the block type depending on `isCode`.
return state
.transform()
.change()
.setBlock(isCode ? 'paragraph' : 'code')
.apply()

View File

@@ -33,7 +33,7 @@ class App extends React.Component {
if (!event.metaKey || event.which != 66) return
event.preventDefault()
return state
.transform()
.change()
.toggleMark('bold')
.apply()
}
@@ -82,7 +82,7 @@ function MarkHotkey(options) {
// Toggle the mark `type`.
return state
.transform()
.change()
.toggleMark(type)
.apply()
}
@@ -220,7 +220,7 @@ function MarkHotkey(options) {
if (!event.metaKey || keycode(event.which) != key || event.altKey != isAltKey) return
event.preventDefault()
return state
.transform()
.change()
.toggleMark(type)
.apply()
}

View File

@@ -20,13 +20,7 @@ class CheckListItem extends React.Component {
onChange = (e) => {
const checked = e.target.checked
const { editor, node } = this.props
const state = editor
.getState()
.transform()
.setNodeByKey(node.key, { data: { checked }})
.apply()
editor.onChange(state)
editor.change(c => c.setNodeByKey(node.key, { data: { checked }}))
}
/**
@@ -41,7 +35,7 @@ class CheckListItem extends React.Component {
const checked = node.data.get('checked')
return (
<div
className="check-list-item"
className={`check-list-item ${checked ? 'checked' : ''}`}
contentEditable={false}
{...attributes}
>
@@ -94,10 +88,10 @@ class CheckLists extends React.Component {
/**
* On change, save the new state.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -112,20 +106,20 @@ class CheckLists extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
* @return {State|Void}
*/
onKeyDown = (e, data, state) => {
onKeyDown = (e, data, change) => {
const { state } = change
if (
data.key == 'enter' &&
state.startBlock.type == 'check-list-item'
) {
return state
.transform()
return change
.splitBlock()
.setBlock({ data: { checked: false }})
.apply()
}
if (
@@ -134,10 +128,8 @@ class CheckLists extends React.Component {
state.startBlock.type == 'check-list-item' &&
state.selection.startOffset == 0
) {
return state
.transform()
return change
.setBlock('paragraph')
.apply()
}
}

View File

@@ -16,16 +16,7 @@ function CodeBlock(props) {
const language = node.data.get('language')
function onChange(e) {
const state = editor.getState()
const next = state
.transform()
.setNodeByKey(node.key, {
data: {
language: e.target.value
}
})
.apply()
editor.onChange(next)
editor.change(c => c.setNodeByKey(node.key, { data: { language: e.target.value }}))
}
return (
@@ -128,15 +119,15 @@ class CodeHighlighting extends React.Component {
state = {
state: Raw.deserialize(initialState, { terse: true })
};
}
/**
* On change, save the new state.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -145,20 +136,17 @@ class CodeHighlighting extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
* @return {Change}
*/
onKeyDown = (e, data, state) => {
if (data.key != 'enter') return
onKeyDown = (e, data, change) => {
const { state } = change
const { startBlock } = state
if (data.key != 'enter') return
if (startBlock.type != 'code') return
const transform = state.transform()
if (state.isExpanded) transform.delete()
transform.insertText('\n')
return transform.apply()
if (state.isExpanded) change.delete()
return change.insertText('\n')
}
/**

View File

@@ -79,10 +79,10 @@ class LargeDocument extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -91,11 +91,10 @@ class LargeDocument extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
onKeyDown = (e, data, state) => {
onKeyDown = (e, data, change) => {
if (!data.isMod) return
let mark
@@ -116,13 +115,9 @@ class LargeDocument extends React.Component {
return
}
state = state
.transform()
.toggleMark(mark)
.apply()
e.preventDefault()
return state
change.toggleMark(mark)
return true
}
/**

View File

@@ -24,10 +24,10 @@ class PlainText extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}

View File

@@ -88,10 +88,10 @@ class RichText extends React.Component {
/**
* On change, save the new state.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -100,11 +100,11 @@ class RichText extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
* @return {State}
*/
onKeyDown = (e, data, state) => {
onKeyDown = (e, data, change) => {
if (!data.isMod) return
let mark
@@ -125,13 +125,9 @@ class RichText extends React.Component {
return
}
state = state
.transform()
[this.hasMark(mark) ? 'removeMark' : 'addMark'](mark)
.apply()
change[this.hasMark(mark) ? 'removeMark' : 'addMark'](mark)
e.preventDefault()
return state
return true
}
/**
@@ -144,14 +140,12 @@ class RichText extends React.Component {
onClickMark = (e, type) => {
e.preventDefault()
const isActive = this.hasMark(type)
let { state } = this.state
state = state
.transform()
const change = this.state.state
.change()
[isActive ? 'removeMark' : 'addMark'](type)
.apply()
this.setState({ state })
this.onChange(change)
}
/**
@@ -164,20 +158,18 @@ class RichText extends React.Component {
onClickBlock = (e, type) => {
e.preventDefault()
const isActive = this.hasBlock(type)
let { state } = this.state
const transform = state
.transform()
const change = this.state.state
.change()
.setBlock(isActive ? 'paragraph' : type)
// Handle the extra wrapping required for list buttons.
if (type == 'bulleted-list' || type == 'numbered-list') {
if (this.hasBlock('list-item')) {
transform
change
.setBlock(DEFAULT_NODE)
.unwrapBlock(type)
} else {
transform
change
.setBlock('list-item')
.wrapBlock(type)
}
@@ -185,11 +177,10 @@ class RichText extends React.Component {
// Handle everything but list buttons.
else {
transform.setBlock(isActive ? DEFAULT_NODE : type)
change.setBlock(isActive ? DEFAULT_NODE : type)
}
state = transform.apply()
this.setState({ state })
this.onChange(change)
}
/**

View File

@@ -32,15 +32,15 @@ class Embeds extends React.Component {
state = {
state: Raw.deserialize(initialState, { terse: true })
};
}
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}

View File

@@ -30,17 +30,7 @@ class Video extends React.Component {
onChange = (e) => {
const video = e.target.value
const { node, editor } = this.props
const properties = {
data: { video }
}
const next = editor
.getState()
.transform()
.setNodeByKey(node.key, properties)
.apply()
editor.onChange(next)
editor.transform(t => t.setNodeByKey(node.key, { data: { video }}))
}
/**

View File

@@ -3,10 +3,17 @@ import { Editor, Raw } from '../..'
import React from 'react'
import initialState from './state.json'
const EMOJIS = [
'😃', '😬', '😂', '😅', '😆', '😍', '😱', '👋', '👏', '👍', '🙌', '👌', '🙏', '👻', '🍔', '🍑', '🍆', '🔑'
]
/**
* Emojis.
*
* @type {Array}
*/
const EMOJIS = [
'😃', '😬', '😂', '😅', '😆', '😍',
'😱', '👋', '👏', '👍', '🙌', '👌',
'🙏', '👻', '🍔', '🍑', '🍆', '🔑',
]
/**
* Define a schema.
@@ -43,15 +50,15 @@ class Emojis extends React.Component {
state = {
state: Raw.deserialize(initialState, { terse: true })
};
}
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -63,18 +70,15 @@ class Emojis extends React.Component {
onClickEmoji = (e, code) => {
e.preventDefault()
let { state } = this.state
state = state
.transform()
const change = this.state.state
.change()
.insertInline({
type: 'emoji',
isVoid: true,
data: { code }
})
.apply()
this.setState({ state })
this.onChange(change)
}
/**

View File

@@ -36,15 +36,15 @@ class FocusBlur extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
/**
* Apply a focus or blur transform by `name` after a `timeout`.
* Apply a focus or blur change by `name` after a `timeout`.
*
* @param {String} name
* @param {Number} timeout
@@ -54,12 +54,11 @@ class FocusBlur extends React.Component {
e.preventDefault()
setTimeout(() => {
const state = this.state.state
.transform()
const change = this.state.state
.change()
[name]()
.apply()
this.setState({ state })
this.onChange(change)
}, timeout)
}

View File

@@ -30,13 +30,13 @@ class ForcedLayout extends React.Component {
{
match: node => node.kind === 'document',
validate: document => !document.nodes.size || document.nodes.first().type !== 'title' ? document.nodes : null,
normalize: (transform, document, nodes) => {
normalize: (change, document, nodes) => {
if (!nodes.size) {
const title = Block.create({ type: 'title', data: {}})
return transform.insertNodeByKey(document.key, 0, title)
return change.insertNodeByKey(document.key, 0, title)
}
return transform.setNodeByKey(nodes.first().key, 'title')
return change.setNodeByKey(nodes.first().key, 'title')
}
},
@@ -48,10 +48,10 @@ class ForcedLayout extends React.Component {
const invalidChildren = document.nodes.filter((child, index) => child.type === 'title' && index !== 0)
return invalidChildren.size ? invalidChildren : null
},
normalize: (transform, document, invalidChildren) => {
let updatedTransform = transform
normalize: (change, document, invalidChildren) => {
let updatedTransform = change
invalidChildren.forEach((child) => {
updatedTransform = transform.setNodeByKey(child.key, 'paragraph')
updatedTransform = change.setNodeByKey(child.key, 'paragraph')
})
return updatedTransform
@@ -63,9 +63,9 @@ class ForcedLayout extends React.Component {
{
match: node => node.kind === 'document',
validate: document => document.nodes.size < 2 ? true : null,
normalize: (transform, document) => {
normalize: (change, document) => {
const paragraph = Block.create({ type: 'paragraph', data: {}})
return transform.insertNodeByKey(document.key, 1, paragraph)
return change.insertNodeByKey(document.key, 1, paragraph)
}
}
]
@@ -75,10 +75,10 @@ class ForcedLayout extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}

View File

@@ -62,12 +62,12 @@ class HoveringMenu extends React.Component {
}
/**
* On change, save the new state.
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -80,14 +80,10 @@ class HoveringMenu extends React.Component {
onClickMark = (e, type) => {
e.preventDefault()
let { state } = this.state
state = state
.transform()
const change = this.state.state
.change()
.toggleMark(type)
.apply()
this.setState({ state })
this.onChange(change)
}
/**

View File

@@ -81,12 +81,12 @@ class Iframes extends React.Component {
}
/**
* On change, save the new state.
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -95,11 +95,11 @@ class Iframes extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
* @return {State}
*/
onKeyDown = (e, data, state) => {
onKeyDown = (e, data, change) => {
if (!data.isMod) return
let mark
@@ -114,13 +114,8 @@ class Iframes extends React.Component {
return
}
state = state
.transform()
.toggleMark(mark)
.apply()
e.preventDefault()
return state
return change.toggleMark(mark)
}
/**
@@ -132,14 +127,10 @@ class Iframes extends React.Component {
onClickMark = (e, type) => {
e.preventDefault()
let { state } = this.state
state = state
.transform()
const change = this.state.state
.change()
.toggleMark(type)
.apply()
this.setState({ state })
this.onChange(change)
}
/**
@@ -151,15 +142,11 @@ class Iframes extends React.Component {
onClickBlock = (e, type) => {
e.preventDefault()
let { state } = this.state
const isActive = this.hasBlock(type)
state = state
.transform()
const change = this.state.state
.change()
.setBlock(isActive ? DEFAULT_NODE : type)
.apply()
this.setState({ state })
this.onChange(change)
}
/**

View File

@@ -48,9 +48,9 @@ const schema = {
validate: (document) => {
return document.nodes.size ? null : true
},
normalize: (transform, document) => {
normalize: (change, document) => {
const block = Block.create(defaultBlock)
transform.insertNodeByKey(document.key, 0, block)
change.insertNodeByKey(document.key, 0, block)
}
},
// Rule to insert a paragraph below a void node (the image) if that node is
@@ -63,14 +63,34 @@ const schema = {
const lastNode = document.nodes.last()
return lastNode && lastNode.isVoid ? true : null
},
normalize: (transform, document) => {
normalize: (change, document) => {
const block = Block.create(defaultBlock)
transform.insertNodeByKey(document.key, document.nodes.size, block)
change.insertNodeByKey(document.key, document.nodes.size, block)
}
}
]
}
/**
* A change function to standardize inserting images.
*
* @param {Change} change
* @param {String} src
* @param {Selection} target
*/
function insertImage(change, src, target) {
if (target) {
change.select(target)
}
change.insertBlock({
type: 'image',
isVoid: true,
data: { src }
})
}
/**
* The images example.
*
@@ -143,10 +163,10 @@ class Images extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -160,9 +180,12 @@ class Images extends React.Component {
e.preventDefault()
const src = window.prompt('Enter the URL of the image:')
if (!src) return
let { state } = this.state
state = this.insertImage(state, null, src)
this.onChange(state)
const change = this.state.state
.change()
.call(insertImage, src)
this.onChange(change)
}
/**
@@ -170,14 +193,13 @@ class Images extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
* @param {Editor} editor
* @return {State}
*/
onDrop = (e, data, state, editor) => {
onDrop = (e, data, change, editor) => {
switch (data.type) {
case 'files': return this.onDropOrPasteFiles(e, data, state, editor)
case 'files': return this.onDropOrPasteFiles(e, data, change, editor)
}
}
@@ -186,21 +208,20 @@ class Images extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
* @param {Editor} editor
* @return {State}
*/
onDropOrPasteFiles = (e, data, state, editor) => {
onDropOrPasteFiles = (e, data, change, editor) => {
for (const file of data.files) {
const reader = new FileReader()
const [ type ] = file.type.split('/')
if (type != 'image') continue
reader.addEventListener('load', () => {
state = editor.getState()
state = this.insertImage(state, data.target, reader.result)
editor.onChange(state)
editor.change((t) => {
t.call(insertImage, reader.result, data.target)
})
})
reader.readAsDataURL(file)
@@ -212,15 +233,14 @@ class Images extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
* @param {Editor} editor
* @return {State}
*/
onPaste = (e, data, state, editor) => {
onPaste = (e, data, change, editor) => {
switch (data.type) {
case 'files': return this.onDropOrPasteFiles(e, data, state, editor)
case 'text': return this.onPasteText(e, data, state)
case 'files': return this.onDropOrPasteFiles(e, data, change, editor)
case 'text': return this.onPasteText(e, data, change)
}
}
@@ -229,36 +249,13 @@ class Images extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
onPasteText = (e, data, state) => {
onPasteText = (e, data, change) => {
if (!isUrl(data.text)) return
if (!isImage(data.text)) return
return this.insertImage(state, data.target, data.text)
}
/**
* Insert an image with `src` at the current selection.
*
* @param {State} state
* @param {String} src
* @return {State}
*/
insertImage = (state, target, src) => {
const transform = state.transform()
if (target) transform.select(target)
return transform
.insertBlock({
type: 'image',
isVoid: true,
data: { src }
})
.apply()
change.call(insertImage, data.text, data.target)
}
}

View File

@@ -214,6 +214,11 @@ input:focus {
align-items: center;
}
.check-list-item.checked {
opacity: 0.666;
text-decoration: line-through;
}
.check-list-item > span:first-child {
margin-right: 0.75em;
}

View File

@@ -21,6 +21,32 @@ const schema = {
}
}
/**
* A change helper to standardize wrapping links.
*
* @param {Change} change
* @param {String} href
*/
function wrapLink(change, href) {
change.wrapInline({
type: 'link',
data: { href }
})
change.collapseToEnd()
}
/**
* A change helper to standardize unwrapping links.
*
* @param {Change} change
*/
function unwrapLink(change) {
change.unwrapInline('link')
}
/**
* The links example.
*
@@ -53,10 +79,10 @@ class Links extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -69,44 +95,29 @@ class Links extends React.Component {
onClickLink = (e) => {
e.preventDefault()
let { state } = this.state
const { state } = this.state
const hasLinks = this.hasLinks()
const change = state.change()
if (hasLinks) {
state = state
.transform()
.unwrapInline('link')
.apply()
change.call(unwrapLink)
}
else if (state.isExpanded) {
const href = window.prompt('Enter the URL of the link:')
state = state
.transform()
.wrapInline({
type: 'link',
data: { href }
})
.collapseToEnd()
.apply()
change.call(wrapLink, href)
}
else {
const href = window.prompt('Enter the URL of the link:')
const text = window.prompt('Enter the text for the link:')
state = state
.transform()
change
.insertText(text)
.extend(0 - text.length)
.wrapInline({
type: 'link',
data: { href }
})
.collapseToEnd()
.apply()
.call(wrapLink, href)
}
this.setState({ state })
this.onChange(change)
}
/**
@@ -114,29 +125,20 @@ class Links extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
*/
onPaste = (e, data, state) => {
if (state.isCollapsed) return
onPaste = (e, data, change) => {
if (change.state.isCollapsed) return
if (data.type != 'text' && data.type != 'html') return
if (!isUrl(data.text)) return
const transform = state.transform()
if (this.hasLinks()) {
transform.unwrapInline('link')
change.call(unwrapLink)
}
return transform
.wrapInline({
type: 'link',
data: {
href: data.text
}
})
.collapseToEnd()
.apply()
change.call(wrapLink, data.text)
return true
}
/**

View File

@@ -140,10 +140,10 @@ class MarkdownPreview extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}

View File

@@ -87,10 +87,10 @@ class MarkdownShortcuts extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -99,15 +99,14 @@ class MarkdownShortcuts extends React.Component {
*
* @param {Event} e
* @param {Data} data
* @param {State} state
* @return {State or Null} state
* @param {Change} change
*/
onKeyDown = (e, data, state) => {
onKeyDown = (e, data, change) => {
switch (data.key) {
case 'space': return this.onSpace(e, state)
case 'backspace': return this.onBackspace(e, state)
case 'enter': return this.onEnter(e, state)
case 'space': return this.onSpace(e, change)
case 'backspace': return this.onBackspace(e, change)
case 'enter': return this.onEnter(e, change)
}
}
@@ -116,12 +115,14 @@ class MarkdownShortcuts extends React.Component {
* node into the shortcut's corresponding type.
*
* @param {Event} e
* @param {State} state
* @param {State} change
* @return {State or Null} state
*/
onSpace = (e, state) => {
onSpace = (e, change) => {
const { state } = change
if (state.isExpanded) return
const { startBlock, startOffset } = state
const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '')
const type = this.getType(chars)
@@ -130,18 +131,17 @@ class MarkdownShortcuts extends React.Component {
if (type == 'list-item' && startBlock.type == 'list-item') return
e.preventDefault()
const transform = state
.transform()
.setBlock(type)
change.setBlock(type)
if (type == 'list-item') transform.wrapBlock('bulleted-list')
if (type == 'list-item') {
change.wrapBlock('bulleted-list')
}
state = transform
change
.extendToStartOf(startBlock)
.delete()
.apply()
return state
return true
}
/**
@@ -149,26 +149,26 @@ class MarkdownShortcuts extends React.Component {
* paragraph node.
*
* @param {Event} e
* @param {State} state
* @param {State} change
* @return {State or Null} state
*/
onBackspace = (e, state) => {
onBackspace = (e, change) => {
const { state } = change
if (state.isExpanded) return
if (state.startOffset != 0) return
const { startBlock } = state
if (startBlock.type == 'paragraph') return
e.preventDefault()
change.setBlock('paragraph')
const transform = state
.transform()
.setBlock('paragraph')
if (startBlock.type == 'list-item') {
change.unwrapBlock('bulleted-list')
}
if (startBlock.type == 'list-item') transform.unwrapBlock('bulleted-list')
state = transform.apply()
return state
return true
}
/**
@@ -176,15 +176,17 @@ class MarkdownShortcuts extends React.Component {
* create a new paragraph below it.
*
* @param {Event} e
* @param {State} state
* @param {State} change
* @return {State or Null} state
*/
onEnter = (e, state) => {
onEnter = (e, change) => {
const { state } = change
if (state.isExpanded) return
const { startBlock, startOffset, endOffset } = state
if (startOffset == 0 && startBlock.length == 0) return this.onBackspace(e, state)
if (endOffset != startBlock.length) return
if (startOffset == 0 && startBlock.text.length == 0) return this.onBackspace(e, change)
if (endOffset != startBlock.text.length) return
if (
startBlock.type != 'heading-one' &&
@@ -199,11 +201,12 @@ class MarkdownShortcuts extends React.Component {
}
e.preventDefault()
return state
.transform()
change
.splitBlock()
.setBlock('paragraph')
.apply()
return true
}
}

View File

@@ -161,10 +161,10 @@ class PasteHtml extends React.Component {
/**
* On change, save the new state.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -173,19 +173,15 @@ class PasteHtml extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
*/
onPaste = (e, data, state) => {
onPaste = (e, data, change) => {
if (data.type != 'html') return
if (data.isShift) return
const { document } = serializer.deserialize(data.html)
return state
.transform()
.insertFragment(document)
.apply()
change.insertFragment(document)
return true
}
/**

View File

@@ -18,15 +18,15 @@ class PlainText extends React.Component {
state = {
state: Plain.deserialize('This is editable plain text, just like a <textarea>!')
};
}
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}

View File

@@ -72,10 +72,10 @@ The fourth is an example of using the plugin.render property to create a higher-
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}

View File

@@ -23,10 +23,10 @@ class ReadOnly extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}

View File

@@ -86,12 +86,12 @@ class RichText extends React.Component {
}
/**
* On change, save the new state.
* On change, save the new `state`.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -100,11 +100,11 @@ class RichText extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
* @return {Change}
*/
onKeyDown = (e, data, state) => {
onKeyDown = (e, data, change) => {
if (!data.isMod) return
let mark
@@ -125,13 +125,9 @@ class RichText extends React.Component {
return
}
state = state
.transform()
.toggleMark(mark)
.apply()
e.preventDefault()
return state
change.toggleMark(mark)
return true
}
/**
@@ -143,14 +139,9 @@ class RichText extends React.Component {
onClickMark = (e, type) => {
e.preventDefault()
let { state } = this.state
state = state
.transform()
.toggleMark(type)
.apply()
this.setState({ state })
const { state } = this.state
const change = state.change().toggleMark(type)
this.onChange(change)
}
/**
@@ -162,8 +153,8 @@ class RichText extends React.Component {
onClickBlock = (e, type) => {
e.preventDefault()
let { state } = this.state
const transform = state.transform()
const { state } = this.state
const change = state.change()
const { document } = state
// Handle everything but list buttons.
@@ -172,14 +163,14 @@ class RichText extends React.Component {
const isList = this.hasBlock('list-item')
if (isList) {
transform
change
.setBlock(isActive ? DEFAULT_NODE : type)
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list')
}
else {
transform
change
.setBlock(isActive ? DEFAULT_NODE : type)
}
}
@@ -192,23 +183,22 @@ class RichText extends React.Component {
})
if (isList && isType) {
transform
change
.setBlock(DEFAULT_NODE)
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list')
} else if (isList) {
transform
change
.unwrapBlock(type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
.wrapBlock(type)
} else {
transform
change
.setBlock('list-item')
.wrapBlock(type)
}
}
state = transform.apply()
this.setState({ state })
this.onChange(change)
}
/**
@@ -296,12 +286,12 @@ class RichText extends React.Component {
return (
<div className="editor">
<Editor
spellCheck
placeholder={'Enter some rich text...'}
schema={schema}
state={this.state.state}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
schema={schema}
placeholder={'Enter some rich text...'}
spellCheck
/>
</div>
)

View File

@@ -36,10 +36,10 @@ class PlainText extends React.Component {
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -48,15 +48,14 @@ class PlainText extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
*/
onKeyDown = (e, data, state) => {
onKeyDown = (e, data, change) => {
if (data.key == 'enter' && data.isShift) {
return state
.transform()
.insertText('\n')
.apply()
e.preventDefault()
change.insertText('\n')
return true
}
}

View File

@@ -42,23 +42,23 @@ class Tables extends React.Component {
* On backspace, do nothing if at the start of a table cell.
*
* @param {Event} e
* @param {State} state
* @return {State or Null} state
* @param {Change} change
*/
onBackspace = (e, state) => {
onBackspace = (e, change) => {
const { state } = change
if (state.startOffset != 0) return
e.preventDefault()
return state
return true
}
/**
* On change.
*
* @param {State} state
* @param {Change} change
*/
onChange = (state) => {
onChange = ({ state }) => {
this.setState({ state })
}
@@ -66,27 +66,26 @@ class Tables extends React.Component {
* On delete, do nothing if at the end of a table cell.
*
* @param {Event} e
* @param {State} state
* @return {State or Null} state
* @param {Change} change
*/
onDelete = (e, state) => {
if (state.endOffset != state.startText.length) return
onDelete = (e, change) => {
const { state } = change
if (state.endOffset != state.startText.text.length) return
e.preventDefault()
return state
return true
}
/**
* On return, do nothing if inside a table cell.
*
* @param {Event} e
* @param {State} state
* @return {State or Null} state
* @param {Change} change
*/
onEnter = (e, state) => {
onEnter = (e, change) => {
e.preventDefault()
return state
return true
}
/**
@@ -94,11 +93,11 @@ class Tables extends React.Component {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State or Null} state
* @param {Change} change
*/
onKeyDown = (e, data, state) => {
onKeyDown = (e, data, change) => {
const { state } = change
const { document, selection } = state
const { startKey } = selection
const startNode = document.getDescendant(startKey)
@@ -109,11 +108,14 @@ class Tables extends React.Component {
if (prevBlock.type == 'table-cell') {
e.preventDefault()
return state
return true
}
}
if (state.startBlock.type != 'table-cell') return
if (state.startBlock.type != 'table-cell') {
return
}
switch (data.key) {
case 'backspace': return this.onBackspace(e, state)
case 'delete': return this.onDelete(e, state)

View File

@@ -1,13 +1,14 @@
This directory contains the core logic of Slate. It's separated further into a series of directories:
- [**Changes**](./changes) — containing the changes that are used to alter a Slate document.
- [**Components**](./components) — containing the React components Slate renders.
- [**Constants**](./constants) — containing constants that are used in Slate's codebase.
- [**Models**](./models) — containing the models that define Slate's internal data structure.
- [**Operations**](./operations) — containing the low-level operations that create Slate changes.
- [**Plugins**](./plugins) — containing the plugins that ship with Slate by default.
- [**Schemas**](./schemas) - containing the schemas that ship with Slate by default.
- [**Serializers**](./serializers) — containing the serializers that ship with Slate by default.
- [**Transforms**](./transforms) — containing the transforms that are used to alter a Slate document.
- [**Utils**](./utils) — containing a few private convenience modules.
Feel free to poke around in each of them to learn more!

2
src/changes/Readme.md Normal file
View File

@@ -0,0 +1,2 @@
This directory contains all of the core change functions that ship with Slate by default. For example, changes like `insertText` or `addMarkAtRange`.

View File

@@ -0,0 +1,457 @@
import Normalize from '../utils/normalize'
/**
* Changes.
*
* @type {Object}
*/
const Changes = {}
/**
* Add a `mark` to the characters in the current selection.
*
* @param {Change} change
* @param {Mark} mark
*/
Changes.addMark = (change, mark) => {
mark = Normalize.mark(mark)
const { state } = change
const { document, selection } = state
if (selection.isExpanded) {
change.addMarkAtRange(selection, mark)
return
}
if (selection.marks) {
const marks = selection.marks.add(mark)
const sel = selection.set('marks', marks)
change.select(sel)
return
}
const marks = document.getActiveMarksAtRange(selection).add(mark)
const sel = selection.set('marks', marks)
change.select(sel)
}
/**
* Delete at the current selection.
*
* @param {Change} change
*/
Changes.delete = (change) => {
const { state } = change
const { selection } = state
if (selection.isCollapsed) return
change
.deleteAtRange(selection)
// Ensure that the selection is collapsed to the start, because in certain
// cases when deleting across inline nodes this isn't guaranteed.
.collapseToStart()
}
/**
* Delete backward `n` characters at the current selection.
*
* @param {Change} change
* @param {Number} n (optional)
*/
Changes.deleteBackward = (change, n = 1) => {
const { state } = change
const { selection } = state
change.deleteBackwardAtRange(selection, n)
}
/**
* Delete backward until the character boundary at the current selection.
*
* @param {Change} change
*/
Changes.deleteCharBackward = (change) => {
const { state } = change
const { selection } = state
change.deleteCharBackwardAtRange(selection)
}
/**
* Delete backward until the line boundary at the current selection.
*
* @param {Change} change
*/
Changes.deleteLineBackward = (change) => {
const { state } = change
const { selection } = state
change.deleteLineBackwardAtRange(selection)
}
/**
* Delete backward until the word boundary at the current selection.
*
* @param {Change} change
*/
Changes.deleteWordBackward = (change) => {
const { state } = change
const { selection } = state
change.deleteWordBackwardAtRange(selection)
}
/**
* Delete forward `n` characters at the current selection.
*
* @param {Change} change
* @param {Number} n (optional)
*/
Changes.deleteForward = (change, n = 1) => {
const { state } = change
const { selection } = state
change.deleteForwardAtRange(selection, n)
}
/**
* Delete forward until the character boundary at the current selection.
*
* @param {Change} change
*/
Changes.deleteCharForward = (change) => {
const { state } = change
const { selection } = state
change.deleteCharForwardAtRange(selection)
}
/**
* Delete forward until the line boundary at the current selection.
*
* @param {Change} change
*/
Changes.deleteLineForward = (change) => {
const { state } = change
const { selection } = state
change.deleteLineForwardAtRange(selection)
}
/**
* Delete forward until the word boundary at the current selection.
*
* @param {Change} change
*/
Changes.deleteWordForward = (change) => {
const { state } = change
const { selection } = state
change.deleteWordForwardAtRange(selection)
}
/**
* Insert a `block` at the current selection.
*
* @param {Change} change
* @param {String|Object|Block} block
*/
Changes.insertBlock = (change, block) => {
block = Normalize.block(block)
const { state } = change
const { selection } = state
change.insertBlockAtRange(selection, block)
// If the node was successfully inserted, update the selection.
const node = change.state.document.getNode(block.key)
if (node) change.collapseToEndOf(node)
}
/**
* Insert a `fragment` at the current selection.
*
* @param {Change} change
* @param {Document} fragment
*/
Changes.insertFragment = (change, fragment) => {
let { state } = change
let { document, selection } = state
if (!fragment.nodes.size) return
const { startText, endText } = state
const lastText = fragment.getLastText()
const lastInline = fragment.getClosestInline(lastText.key)
const keys = document.getTexts().map(text => text.key)
const isAppending = (
selection.hasEdgeAtEndOf(endText) ||
selection.hasEdgeAtStartOf(startText)
)
change.insertFragmentAtRange(selection, fragment)
state = change.state
document = state.document
const newTexts = document.getTexts().filter(n => !keys.includes(n.key))
const newText = isAppending ? newTexts.last() : newTexts.takeLast(2).first()
let after
if (newText && lastInline) {
after = selection.collapseToEndOf(newText)
}
else if (newText) {
after = selection
.collapseToStartOf(newText)
.move(lastText.text.length)
}
else {
after = selection
.collapseToStart()
.move(lastText.text.length)
}
change.select(after)
}
/**
* Insert a `inline` at the current selection.
*
* @param {Change} change
* @param {String|Object|Block} inline
*/
Changes.insertInline = (change, inline) => {
inline = Normalize.inline(inline)
const { state } = change
const { selection } = state
change.insertInlineAtRange(selection, inline)
// If the node was successfully inserted, update the selection.
const node = change.state.document.getNode(inline.key)
if (node) change.collapseToEndOf(node)
}
/**
* Insert a `text` string at the current selection.
*
* @param {Change} change
* @param {String} text
* @param {Set<Mark>} marks (optional)
*/
Changes.insertText = (change, text, marks) => {
const { state } = change
const { document, selection } = state
marks = marks || selection.marks
change.insertTextAtRange(selection, text, marks)
// If the text was successfully inserted, and the selection had marks on it,
// unset the selection's marks.
if (selection.marks && document != change.state.document) {
change.select({ marks: null })
}
}
/**
* Set `properties` of the block nodes in the current selection.
*
* @param {Change} change
* @param {Object} properties
*/
Changes.setBlock = (change, properties) => {
const { state } = change
const { selection } = state
change.setBlockAtRange(selection, properties)
}
/**
* Set `properties` of the inline nodes in the current selection.
*
* @param {Change} change
* @param {Object} properties
*/
Changes.setInline = (change, properties) => {
const { state } = change
const { selection } = state
change.setInlineAtRange(selection, properties)
}
/**
* Split the block node at the current selection, to optional `depth`.
*
* @param {Change} change
* @param {Number} depth (optional)
*/
Changes.splitBlock = (change, depth = 1) => {
const { state } = change
const { selection } = state
change
.splitBlockAtRange(selection, depth)
.collapseToEnd()
}
/**
* Split the inline nodes at the current selection, to optional `depth`.
*
* @param {Change} change
* @param {Number} depth (optional)
*/
Changes.splitInline = (change, depth = Infinity) => {
const { state } = change
const { selection } = state
change
.splitInlineAtRange(selection, depth)
}
/**
* Remove a `mark` from the characters in the current selection.
*
* @param {Change} change
* @param {Mark} mark
*/
Changes.removeMark = (change, mark) => {
mark = Normalize.mark(mark)
const { state } = change
const { document, selection } = state
if (selection.isExpanded) {
change.removeMarkAtRange(selection, mark)
return
}
if (selection.marks) {
const marks = selection.marks.remove(mark)
const sel = selection.set('marks', marks)
change.select(sel)
return
}
const marks = document.getActiveMarksAtRange(selection).remove(mark)
const sel = selection.set('marks', marks)
change.select(sel)
}
/**
* Add or remove a `mark` from the characters in the current selection,
* depending on whether it's already there.
*
* @param {Change} change
* @param {Mark} mark
*/
Changes.toggleMark = (change, mark) => {
mark = Normalize.mark(mark)
const { state } = change
const exists = state.activeMarks.some(m => m.equals(mark))
if (exists) {
change.removeMark(mark)
} else {
change.addMark(mark)
}
}
/**
* Unwrap the current selection from a block parent with `properties`.
*
* @param {Change} change
* @param {Object|String} properties
*/
Changes.unwrapBlock = (change, properties) => {
const { state } = change
const { selection } = state
change.unwrapBlockAtRange(selection, properties)
}
/**
* Unwrap the current selection from an inline parent with `properties`.
*
* @param {Change} change
* @param {Object|String} properties
*/
Changes.unwrapInline = (change, properties) => {
const { state } = change
const { selection } = state
change.unwrapInlineAtRange(selection, properties)
}
/**
* Wrap the block nodes in the current selection with a new block node with
* `properties`.
*
* @param {Change} change
* @param {Object|String} properties
*/
Changes.wrapBlock = (change, properties) => {
const { state } = change
const { selection } = state
change.wrapBlockAtRange(selection, properties)
}
/**
* Wrap the current selection in new inline nodes with `properties`.
*
* @param {Change} change
* @param {Object|String} properties
*/
Changes.wrapInline = (change, properties) => {
const { state } = change
const { selection } = state
change.wrapInlineAtRange(selection, properties)
}
/**
* Wrap the current selection with prefix/suffix.
*
* @param {Change} change
* @param {String} prefix
* @param {String} suffix
*/
Changes.wrapText = (change, prefix, suffix = prefix) => {
const { state } = change
const { selection } = state
change.wrapTextAtRange(selection, prefix, suffix)
// If the selection was collapsed, it will have moved the start offset too.
if (selection.isCollapsed) {
change.moveStart(0 - prefix.length)
}
// Adding the suffix will have pushed the end of the selection further on, so
// we need to move it back to account for this.
change.moveEnd(0 - suffix.length)
// There's a chance that the selection points moved "through" each other,
// resulting in a now-incorrect selection direction.
if (selection.isForward != change.state.selection.isForward) {
change.flip()
}
}
/**
* Export.
*
* @type {Object}
*/
export default Changes

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,17 @@ import Normalize from '../utils/normalize'
import SCHEMA from '../schemas/core'
/**
* Transforms.
* Changes.
*
* @type {Object}
*/
const Transforms = {}
const Changes = {}
/**
* Add mark to text at `offset` and `length` in node by `key`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Number} offset
* @param {Number} length
@@ -22,25 +22,31 @@ const Transforms = {}
* @property {Boolean} normalize
*/
Transforms.addMarkByKey = (transform, key, offset, length, mark, options = {}) => {
Changes.addMarkByKey = (change, key, offset, length, mark, options = {}) => {
mark = Normalize.mark(mark)
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
transform.addMarkOperation(path, offset, length, mark)
change.applyOperation({
type: 'add_mark',
path,
offset,
length,
mark,
})
if (normalize) {
const parent = document.getParent(key)
transform.normalizeNodeByKey(parent.key, SCHEMA)
change.normalizeNodeByKey(parent.key, SCHEMA)
}
}
/**
* Insert a `fragment` at `index` in a node by `key`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Number} index
* @param {Fragment} fragment
@@ -48,20 +54,22 @@ Transforms.addMarkByKey = (transform, key, offset, length, mark, options = {}) =
* @property {Boolean} normalize
*/
Transforms.insertFragmentByKey = (transform, key, index, fragment, options = {}) => {
Changes.insertFragmentByKey = (change, key, index, fragment, options = {}) => {
const { normalize = true } = options
fragment.nodes.forEach((node, i) => transform.insertNodeByKey(key, index + i, node))
fragment.nodes.forEach((node, i) => {
change.insertNodeByKey(key, index + i, node)
})
if (normalize) {
transform.normalizeNodeByKey(key, SCHEMA)
change.normalizeNodeByKey(key, SCHEMA)
}
}
/**
* Insert a `node` at `index` in a node by `key`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Number} index
* @param {Node} node
@@ -69,23 +77,27 @@ Transforms.insertFragmentByKey = (transform, key, index, fragment, options = {})
* @property {Boolean} normalize
*/
Transforms.insertNodeByKey = (transform, key, index, node, options = {}) => {
Changes.insertNodeByKey = (change, key, index, node, options = {}) => {
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
transform.insertNodeOperation(path, index, node)
change.applyOperation({
type: 'insert_node',
path: [...path, index],
node,
})
if (normalize) {
transform.normalizeNodeByKey(key, SCHEMA)
change.normalizeNodeByKey(key, SCHEMA)
}
}
/**
* Insert `text` at `offset` in node by `key`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Number} offset
* @param {String} text
@@ -94,42 +106,59 @@ Transforms.insertNodeByKey = (transform, key, index, node, options = {}) => {
* @property {Boolean} normalize
*/
Transforms.insertTextByKey = (transform, key, offset, text, marks, options = {}) => {
Changes.insertTextByKey = (change, key, offset, text, marks, options = {}) => {
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
const node = document.getNode(key)
marks = marks || node.getMarksAtIndex(offset)
transform.insertTextOperation(path, offset, text, marks)
change.applyOperation({
type: 'insert_text',
path,
offset,
text,
marks,
})
if (normalize) {
const parent = document.getParent(key)
transform.normalizeNodeByKey(parent.key, SCHEMA)
change.normalizeNodeByKey(parent.key, SCHEMA)
}
}
/**
* Join a node by `key` with a node `withKey`.
* Merge a node by `key` with the previous node.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {String} withKey
* @param {Object} options
* @property {Boolean} normalize
*/
Transforms.joinNodeByKey = (transform, key, withKey, options = {}) => {
Changes.mergeNodeByKey = (change, key, options = {}) => {
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
const withPath = document.getPath(withKey)
const previous = document.getPreviousSibling(key)
transform.joinNodeOperation(path, withPath)
if (!previous) {
throw new Error(`Unable to merge node with key "${key}", no previous key.`)
}
const position = previous.kind == 'text' ? previous.text.length : previous.nodes.size
change.applyOperation({
type: 'merge_node',
path,
position,
})
if (normalize) {
const parent = document.getCommonAncestor(key, withKey)
transform.normalizeNodeByKey(parent.key, SCHEMA)
const parent = document.getParent(key)
change.normalizeNodeByKey(parent.key, SCHEMA)
}
}
@@ -137,7 +166,7 @@ Transforms.joinNodeByKey = (transform, key, withKey, options = {}) => {
* Move a node by `key` to a new parent by `newKey` and `index`.
* `newKey` is the key of the container (it can be the document itself)
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {String} newKey
* @param {Number} index
@@ -145,25 +174,29 @@ Transforms.joinNodeByKey = (transform, key, withKey, options = {}) => {
* @property {Boolean} normalize
*/
Transforms.moveNodeByKey = (transform, key, newKey, newIndex, options = {}) => {
Changes.moveNodeByKey = (change, key, newKey, newIndex, options = {}) => {
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
const newPath = document.getPath(newKey)
transform.moveNodeOperation(path, newPath, newIndex)
change.applyOperation({
type: 'move_node',
path,
newPath: [...newPath, newIndex],
})
if (normalize) {
const parent = document.getCommonAncestor(key, newKey)
transform.normalizeNodeByKey(parent.key, SCHEMA)
change.normalizeNodeByKey(parent.key, SCHEMA)
}
}
/**
* Remove mark from text at `offset` and `length` in node by `key`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Number} offset
* @param {Number} length
@@ -172,48 +205,59 @@ Transforms.moveNodeByKey = (transform, key, newKey, newIndex, options = {}) => {
* @property {Boolean} normalize
*/
Transforms.removeMarkByKey = (transform, key, offset, length, mark, options = {}) => {
Changes.removeMarkByKey = (change, key, offset, length, mark, options = {}) => {
mark = Normalize.mark(mark)
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
transform.removeMarkOperation(path, offset, length, mark)
change.applyOperation({
type: 'remove_mark',
path,
offset,
length,
mark,
})
if (normalize) {
const parent = document.getParent(key)
transform.normalizeNodeByKey(parent.key, SCHEMA)
change.normalizeNodeByKey(parent.key, SCHEMA)
}
}
/**
* Remove a node by `key`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Object} options
* @property {Boolean} normalize
*/
Transforms.removeNodeByKey = (transform, key, options = {}) => {
Changes.removeNodeByKey = (change, key, options = {}) => {
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
const node = document.getNode(key)
transform.removeNodeOperation(path)
change.applyOperation({
type: 'remove_node',
path,
node,
})
if (normalize) {
const parent = document.getParent(key)
transform.normalizeNodeByKey(parent.key, SCHEMA)
change.normalizeNodeByKey(parent.key, SCHEMA)
}
}
/**
* Remove text at `offset` and `length` in node by `key`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Number} offset
* @param {Number} length
@@ -221,24 +265,60 @@ Transforms.removeNodeByKey = (transform, key, options = {}) => {
* @property {Boolean} normalize
*/
Transforms.removeTextByKey = (transform, key, offset, length, options = {}) => {
Changes.removeTextByKey = (change, key, offset, length, options = {}) => {
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
const node = document.getNode(key)
const ranges = node.getRanges()
const { text } = node
transform.removeTextOperation(path, offset, length)
const removals = []
const bx = offset
const by = offset + length
let o = 0
ranges.forEach((range) => {
const { marks } = range
const ax = o
const ay = ax + range.text.length
o += range.text.length
// If the range doesn't overlap with the removal, continue on.
if (ay < bx || by < ax) return
// Otherwise, determine which offset and characters overlap.
const start = Math.max(ax, bx)
const end = Math.min(ay, by)
const string = text.slice(start, end)
removals.push({
type: 'remove_text',
path,
offset: start,
text: string,
marks,
})
})
// Apply the removals in reverse order, so that subsequent removals aren't
// impacted by previous ones.
removals.reverse().forEach((op) => {
change.applyOperation(op)
})
if (normalize) {
const block = document.getClosestBlock(key)
transform.normalizeNodeByKey(block.key, SCHEMA)
change.normalizeNodeByKey(block.key, SCHEMA)
}
}
/**
* Set `properties` on mark on text at `offset` and `length` in node by `key`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Number} offset
* @param {Number} length
@@ -247,110 +327,162 @@ Transforms.removeTextByKey = (transform, key, offset, length, options = {}) => {
* @property {Boolean} normalize
*/
Transforms.setMarkByKey = (transform, key, offset, length, mark, properties, options = {}) => {
Changes.setMarkByKey = (change, key, offset, length, mark, properties, options = {}) => {
mark = Normalize.mark(mark)
properties = Normalize.markProperties(properties)
const { normalize = true } = options
const newMark = mark.merge(properties)
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
transform.setMarkOperation(path, offset, length, mark, newMark)
change.applyOperation({
type: 'set_mark',
path,
offset,
length,
mark,
properties,
})
if (normalize) {
const parent = document.getParent(key)
transform.normalizeNodeByKey(parent.key, SCHEMA)
change.normalizeNodeByKey(parent.key, SCHEMA)
}
}
/**
* Set `properties` on a node by `key`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Object|String} properties
* @param {Object} options
* @property {Boolean} normalize
*/
Transforms.setNodeByKey = (transform, key, properties, options = {}) => {
Changes.setNodeByKey = (change, key, properties, options = {}) => {
properties = Normalize.nodeProperties(properties)
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
const node = document.getNode(key)
transform.setNodeOperation(path, properties)
change.applyOperation({
type: 'set_node',
path,
node,
properties,
})
if (normalize) {
const node = key === document.key ? document : document.getParent(key)
transform.normalizeNodeByKey(node.key, SCHEMA)
change.normalizeNodeByKey(node.key, SCHEMA)
}
}
/**
* Split a node by `key` at `offset`.
* Split a node by `key` at `position`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Number} offset
* @param {Number} position
* @param {Object} options
* @property {Boolean} normalize
*/
Transforms.splitNodeByKey = (transform, key, offset, options = {}) => {
Changes.splitNodeByKey = (change, key, position, options = {}) => {
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const path = document.getPath(key)
transform.splitNodeAtOffsetOperation(path, offset)
change.applyOperation({
type: 'split_node',
path,
position,
})
if (normalize) {
const parent = document.getParent(key)
transform.normalizeNodeByKey(parent.key, SCHEMA)
change.normalizeNodeByKey(parent.key, SCHEMA)
}
}
/**
* Split a node deeply down the tree by `key`, `textKey` and `textOffset`.
*
* @param {Change} change
* @param {String} key
* @param {Number} position
* @param {Object} options
* @property {Boolean} normalize
*/
Changes.splitDescendantsByKey = (change, key, textKey, textOffset, options = {}) => {
if (key == textKey) {
change.splitNodeByKey(textKey, textOffset, options)
return
}
const { normalize = true } = options
const { state } = change
const { document } = state
const text = document.getNode(textKey)
const ancestors = document.getAncestors(textKey)
const nodes = ancestors.skipUntil(a => a.key == key).reverse().unshift(text)
let previous
nodes.forEach((node) => {
const index = previous ? node.nodes.indexOf(previous) + 1 : textOffset
previous = node
change.splitNodeByKey(node.key, index, { normalize: false })
})
if (normalize) {
const parent = document.getParent(key)
change.normalizeNodeByKey(parent.key, SCHEMA)
}
}
/**
* Unwrap content from an inline parent with `properties`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Object|String} properties
* @param {Object} options
* @property {Boolean} normalize
*/
Transforms.unwrapInlineByKey = (transform, key, properties, options) => {
const { state } = transform
Changes.unwrapInlineByKey = (change, key, properties, options) => {
const { state } = change
const { document, selection } = state
const node = document.assertDescendant(key)
const first = node.getFirstText()
const last = node.getLastText()
const range = selection.moveToRangeOf(first, last)
transform.unwrapInlineAtRange(range, properties, options)
change.unwrapInlineAtRange(range, properties, options)
}
/**
* Unwrap content from a block parent with `properties`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Object|String} properties
* @param {Object} options
* @property {Boolean} normalize
*/
Transforms.unwrapBlockByKey = (transform, key, properties, options) => {
const { state } = transform
Changes.unwrapBlockByKey = (change, key, properties, options) => {
const { state } = change
const { document, selection } = state
const node = document.assertDescendant(key)
const first = node.getFirstText()
const last = node.getLastText()
const range = selection.moveToRangeOf(first, last)
transform.unwrapBlockAtRange(range, properties, options)
change.unwrapBlockAtRange(range, properties, options)
}
/**
@@ -360,15 +492,15 @@ Transforms.unwrapBlockByKey = (transform, key, properties, options) => {
* split. If the node is the only child, the parent is removed, and
* simply replaced by the node itself. Cannot unwrap a root node.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key
* @param {Object} options
* @property {Boolean} normalize
*/
Transforms.unwrapNodeByKey = (transform, key, options = {}) => {
Changes.unwrapNodeByKey = (change, key, options = {}) => {
const { normalize = true } = options
const { state } = transform
const { state } = change
const { document } = state
const parent = document.getParent(key)
const node = parent.getChild(key)
@@ -380,31 +512,30 @@ Transforms.unwrapNodeByKey = (transform, key, options = {}) => {
const parentParent = document.getParent(parent.key)
const parentIndex = parentParent.nodes.indexOf(parent)
if (parent.nodes.size === 1) {
transform.moveNodeByKey(key, parentParent.key, parentIndex, { normalize: false })
transform.removeNodeByKey(parent.key, options)
change.moveNodeByKey(key, parentParent.key, parentIndex, { normalize: false })
change.removeNodeByKey(parent.key, options)
}
else if (isFirst) {
// Just move the node before its parent.
transform.moveNodeByKey(key, parentParent.key, parentIndex, options)
change.moveNodeByKey(key, parentParent.key, parentIndex, options)
}
else if (isLast) {
// Just move the node after its parent.
transform.moveNodeByKey(key, parentParent.key, parentIndex + 1, options)
change.moveNodeByKey(key, parentParent.key, parentIndex + 1, options)
}
else {
const parentPath = document.getPath(parent.key)
// Split the parent.
transform.splitNodeOperation(parentPath, index)
change.splitNodeByKey(parent.key, index, { normalize: false })
// Extract the node in between the splitted parent.
transform.moveNodeByKey(key, parentParent.key, parentIndex + 1, { normalize: false })
change.moveNodeByKey(key, parentParent.key, parentIndex + 1, { normalize: false })
if (normalize) {
transform.normalizeNodeByKey(parentParent.key, SCHEMA)
change.normalizeNodeByKey(parentParent.key, SCHEMA)
}
}
}
@@ -412,47 +543,47 @@ Transforms.unwrapNodeByKey = (transform, key, options = {}) => {
/**
* Wrap a node in an inline with `properties`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key The node to wrap
* @param {Block|Object|String} inline The wrapping inline (its children are discarded)
* @param {Object} options
* @property {Boolean} normalize
*/
Transforms.wrapInlineByKey = (transform, key, inline, options) => {
Changes.wrapInlineByKey = (change, key, inline, options) => {
inline = Normalize.inline(inline)
inline = inline.set('nodes', inline.nodes.clear())
const { document } = transform.state
const { document } = change.state
const node = document.assertDescendant(key)
const parent = document.getParent(node.key)
const index = parent.nodes.indexOf(node)
transform.insertNodeByKey(parent.key, index, inline, { normalize: false })
transform.moveNodeByKey(node.key, inline.key, 0, options)
change.insertNodeByKey(parent.key, index, inline, { normalize: false })
change.moveNodeByKey(node.key, inline.key, 0, options)
}
/**
* Wrap a node in a block with `properties`.
*
* @param {Transform} transform
* @param {Change} change
* @param {String} key The node to wrap
* @param {Block|Object|String} block The wrapping block (its children are discarded)
* @param {Object} options
* @property {Boolean} normalize
*/
Transforms.wrapBlockByKey = (transform, key, block, options) => {
Changes.wrapBlockByKey = (change, key, block, options) => {
block = Normalize.block(block)
block = block.set('nodes', block.nodes.clear())
const { document } = transform.state
const { document } = change.state
const node = document.assertDescendant(key)
const parent = document.getParent(node.key)
const index = parent.nodes.indexOf(node)
transform.insertNodeByKey(parent.key, index, block, { normalize: false })
transform.moveNodeByKey(node.key, block.key, 0, options)
change.insertNodeByKey(parent.key, index, block, { normalize: false })
change.moveNodeByKey(node.key, block.key, 0, options)
}
/**
@@ -461,4 +592,4 @@ Transforms.wrapBlockByKey = (transform, key, block, options) => {
* @type {Object}
*/
export default Transforms
export default Changes

View File

@@ -1,13 +1,11 @@
import ApplyOperation from './apply-operation'
import AtCurrentRange from './at-current-range'
import AtRange from './at-range'
import ByKey from './by-key'
import Call from './call'
import Normalize from './normalize'
import OnHistory from './on-history'
import OnSelection from './on-selection'
import Operations from './operations'
import OnState from './on-state'
/**
* Export.
@@ -16,13 +14,11 @@ import Operations from './operations'
*/
export default {
...ApplyOperation,
...AtCurrentRange,
...AtRange,
...ByKey,
...Call,
...Normalize,
...OnHistory,
...OnSelection,
...Operations,
...OnState,
}

View File

@@ -1,117 +1,73 @@
import Normalize from '../utils/normalize'
import Schema from '../models/schema'
import warn from '../utils/warn'
import { Set } from 'immutable'
/**
* Transforms.
* Changes.
*
* @type {Object}
*/
const Transforms = {}
const Changes = {}
/**
* Normalize the document and selection with a `schema`.
*
* @param {Transform} transform
* @param {Change} change
* @param {Schema} schema
*/
Transforms.normalize = (transform, schema) => {
transform.normalizeDocument(schema)
transform.normalizeSelection(schema)
Changes.normalize = (change, schema) => {
change.normalizeDocument(schema)
}
/**
* Normalize the document with a `schema`.
*
* @param {Transform} transform
* @param {Change} change
* @param {Schema} schema
*/
Transforms.normalizeDocument = (transform, schema) => {
const { state } = transform
Changes.normalizeDocument = (change, schema) => {
const { state } = change
const { document } = state
transform.normalizeNodeByKey(document.key, schema)
change.normalizeNodeByKey(document.key, schema)
}
/**
* Normalize a `node` and its children with a `schema`.
*
* @param {Transform} transform
* @param {Change} change
* @param {Node|String} key
* @param {Schema} schema
*/
Transforms.normalizeNodeByKey = (transform, key, schema) => {
Changes.normalizeNodeByKey = (change, key, schema) => {
assertSchema(schema)
// If the schema has no validation rules, there's nothing to normalize.
if (!schema.hasValidators) return
key = Normalize.key(key)
const { state } = transform
const { state } = change
const { document } = state
const node = document.assertNode(key)
normalizeNodeAndChildren(transform, node, schema)
}
/**
* Normalize the selection.
*
* @param {Transform} transform
*/
Transforms.normalizeSelection = (transform) => {
let { state } = transform
let { document, selection } = state
// If document is empty, return
if (document.nodes.size === 0) {
return
}
selection = selection.normalize(document)
// If the selection is unset, or the anchor or focus key in the selection are
// pointing to nodes that no longer exist, warn (if not unset) and reset the selection.
if (
selection.isUnset ||
!document.hasDescendant(selection.anchorKey) ||
!document.hasDescendant(selection.focusKey)
) {
if (!selection.isUnset) {
warn('The selection was invalid and was reset to start of the document. The selection in question was:', selection)
}
const firstText = document.getFirstText()
selection = selection.merge({
anchorKey: firstText.key,
anchorOffset: 0,
focusKey: firstText.key,
focusOffset: 0,
isBackward: false
})
}
state = state.set('selection', selection)
transform.state = state
normalizeNodeAndChildren(change, node, schema)
}
/**
* Normalize a `node` and its children with a `schema`.
*
* @param {Transform} transform
* @param {Change} change
* @param {Node} node
* @param {Schema} schema
*/
function normalizeNodeAndChildren(transform, node, schema) {
function normalizeNodeAndChildren(change, node, schema) {
if (node.kind == 'text') {
normalizeNode(transform, node, schema)
normalizeNode(change, node, schema)
return
}
@@ -123,7 +79,7 @@ function normalizeNodeAndChildren(transform, node, schema) {
// While there is still a child key that hasn't been normalized yet...
while (keys.length) {
const ops = transform.operations.length
const ops = change.operations.length
let key
// PERF: use a mutable set here since we'll be add to it a lot.
@@ -132,7 +88,7 @@ function normalizeNodeAndChildren(transform, node, schema) {
// Unwind the stack, normalizing every child and adding it to the set.
while (key = keys[0]) {
const child = node.getChild(key)
normalizeNodeAndChildren(transform, child, schema)
normalizeNodeAndChildren(change, child, schema)
set.add(key)
keys.shift()
}
@@ -142,8 +98,8 @@ function normalizeNodeAndChildren(transform, node, schema) {
// PERF: Only re-find the node and re-normalize any new children if
// operations occured that might have changed it.
if (transform.operations.length != ops) {
node = refindNode(transform, node)
if (change.operations.length != ops) {
node = refindNode(change, node)
// Add any new children back onto the stack.
node.nodes.forEach((n) => {
@@ -155,21 +111,21 @@ function normalizeNodeAndChildren(transform, node, schema) {
// Normalize the node itself if it still exists.
if (node) {
normalizeNode(transform, node, schema)
normalizeNode(change, node, schema)
}
}
/**
* Re-find a reference to a node that may have been modified or removed
* entirely by a transform.
* entirely by a change.
*
* @param {Transform} transform
* @param {Change} change
* @param {Node} node
* @return {Node}
*/
function refindNode(transform, node) {
const { state } = transform
function refindNode(change, node) {
const { state } = change
const { document } = state
return node.kind == 'document'
? document
@@ -179,12 +135,12 @@ function refindNode(transform, node) {
/**
* Normalize a `node` with a `schema`, but not its children.
*
* @param {Transform} transform
* @param {Change} change
* @param {Node} node
* @param {Schema} schema
*/
function normalizeNode(transform, node, schema) {
function normalizeNode(change, node, schema) {
const max = schema.rules.length
let iterations = 0
@@ -215,7 +171,7 @@ function normalizeNode(transform, node, schema) {
iterate(t, n)
}
iterate(transform, node)
iterate(change, node)
}
/**
@@ -240,4 +196,4 @@ function assertSchema(schema) {
* @type {Object}
*/
export default Transforms
export default Changes

80
src/changes/on-history.js Normal file
View File

@@ -0,0 +1,80 @@
import invert from '../operations/invert'
/**
* Changes.
*
* @type {Object}
*/
const Changes = {}
/**
* Redo to the next state in the history.
*
* @param {Change} change
*/
Changes.redo = (change) => {
let { state } = change
let { history } = state
if (!history) return
let { undos, redos } = history
const next = redos.peek()
if (!next) return
// Shift the next state into the undo stack.
redos = redos.pop()
undos = undos.push(next)
// Replay the next operations.
next.forEach((op) => {
change.applyOperation(op, { save: false })
})
// Update the history.
state = change.state
history = history.set('undos', undos).set('redos', redos)
state = state.set('history', history)
change.state = state
}
/**
* Undo the previous operations in the history.
*
* @param {Change} change
*/
Changes.undo = (change) => {
let { state } = change
let { history } = state
if (!history) return
let { undos, redos } = history
const previous = undos.peek()
if (!previous) return
// Shift the previous operations into the redo stack.
undos = undos.pop()
redos = redos.push(previous)
// Replay the inverse of the previous operations.
previous.slice().reverse().map(invert).forEach((inverse) => {
change.applyOperation(inverse, { save: false })
})
// Update the history.
state = change.state
history = history.set('undos', undos).set('redos', redos)
state = state.set('history', history)
change.state = state
}
/**
* Export.
*
* @type {Object}
*/
export default Changes

293
src/changes/on-selection.js Normal file
View File

@@ -0,0 +1,293 @@
import Normalize from '../utils/normalize'
import isEmpty from 'is-empty'
import logger from '../utils/logger'
import pick from 'lodash/pick'
/**
* Changes.
*
* @type {Object}
*/
const Changes = {}
/**
* Set `properties` on the selection.
*
* @param {Change} change
* @param {Object} properties
*/
Changes.select = (change, properties, options = {}) => {
properties = Normalize.selectionProperties(properties)
const { snapshot = false } = options
const { state } = change
const { document, selection } = state
const props = {}
const sel = selection.toJSON()
const next = selection.merge(properties).normalize(document)
properties = pick(next, Object.keys(properties))
// Remove any properties that are already equal to the current selection. And
// create a dictionary of the previous values for all of the properties that
// are being changed, for the inverse operation.
for (const k in properties) {
if (snapshot == false && properties[k] == sel[k]) continue
props[k] = properties[k]
}
// Resolve the selection keys into paths.
sel.anchorPath = sel.anchorKey == null ? null : document.getPath(sel.anchorKey)
delete sel.anchorKey
if (props.anchorKey) {
props.anchorPath = props.anchorKey == null ? null : document.getPath(props.anchorKey)
delete props.anchorKey
}
sel.focusPath = sel.focusKey == null ? null : document.getPath(sel.focusKey)
delete sel.focusKey
if (props.focusKey) {
props.focusPath = props.focusKey == null ? null : document.getPath(props.focusKey)
delete props.focusKey
}
// If the selection moves, clear any marks, unless the new selection
// properties change the marks in some way.
const moved = [
'anchorPath',
'anchorOffset',
'focusPath',
'focusOffset',
].some(p => props.hasOwnProperty(p))
if (sel.marks && properties.marks == sel.marks && moved) {
props.marks = null
}
// If there are no new properties to set, abort.
if (isEmpty(props)) {
return
}
// Apply the operation.
change.applyOperation({
type: 'set_selection',
properties: props,
selection: sel,
}, snapshot ? { skip: false, merge: false } : {})
}
/**
* Select the whole document.
*
* @param {Change} change
*/
Changes.selectAll = (change) => {
const { state } = change
const { document, selection } = state
const next = selection.moveToRangeOf(document)
change.select(next)
}
/**
* Snapshot the current selection.
*
* @param {Change} change
*/
Changes.snapshotSelection = (change) => {
const { state } = change
const { selection } = state
change.select(selection, { snapshot: true })
}
/**
* Set `properties` on the selection.
*
* @param {Mixed} ...args
* @param {Change} change
*/
Changes.moveTo = (change, properties) => {
logger.deprecate('0.17.0', 'The `moveTo()` change is deprecated, please use `select()` instead.')
change.select(properties)
}
/**
* Unset the selection's marks.
*
* @param {Change} change
*/
Changes.unsetMarks = (change) => {
logger.deprecate('0.17.0', 'The `unsetMarks()` change is deprecated.')
change.select({ marks: null })
}
/**
* Unset the selection, removing an association to a node.
*
* @param {Change} change
*/
Changes.unsetSelection = (change) => {
logger.deprecate('0.17.0', 'The `unsetSelection()` change is deprecated, please use `deselect()` instead.')
change.select({
anchorKey: null,
anchorOffset: 0,
focusKey: null,
focusOffset: 0,
isFocused: false,
isBackward: false
})
}
/**
* Mix in selection changes that are just a proxy for the selection method.
*/
const PROXY_TRANSFORMS = [
'blur',
'collapseTo',
'collapseToAnchor',
'collapseToEnd',
'collapseToEndOf',
'collapseToFocus',
'collapseToStart',
'collapseToStartOf',
'extend',
'extendTo',
'extendToEndOf',
'extendToStartOf',
'flip',
'focus',
'move',
'moveAnchor',
'moveAnchorOffsetTo',
'moveAnchorTo',
'moveAnchorToEndOf',
'moveAnchorToStartOf',
'moveEnd',
'moveEndOffsetTo',
'moveEndTo',
'moveFocus',
'moveFocusOffsetTo',
'moveFocusTo',
'moveFocusToEndOf',
'moveFocusToStartOf',
'moveOffsetsTo',
'moveStart',
'moveStartOffsetTo',
'moveStartTo',
// 'moveTo', Commented out for now, since it conflicts with a deprecated one.
'moveToEnd',
'moveToEndOf',
'moveToRangeOf',
'moveToStart',
'moveToStartOf',
'deselect',
]
PROXY_TRANSFORMS.forEach((method) => {
Changes[method] = (change, ...args) => {
const normalize = method != 'deselect'
const { state } = change
const { document, selection } = state
let next = selection[method](...args)
if (normalize) next = next.normalize(document)
change.select(next)
}
})
/**
* Mix in node-related changes.
*/
const PREFIXES = [
'moveTo',
'collapseTo',
'extendTo',
]
const DIRECTIONS = [
'Next',
'Previous',
]
const KINDS = [
'Block',
'Inline',
'Text',
]
PREFIXES.forEach((prefix) => {
const edges = [
'Start',
'End',
]
if (prefix == 'moveTo') {
edges.push('Range')
}
edges.forEach((edge) => {
DIRECTIONS.forEach((direction) => {
KINDS.forEach((kind) => {
const get = `get${direction}${kind}`
const getAtRange = `get${kind}sAtRange`
const index = direction == 'Next' ? 'last' : 'first'
const method = `${prefix}${edge}Of`
const name = `${method}${direction}${kind}`
Changes[name] = (change) => {
const { state } = change
const { document, selection } = state
const nodes = document[getAtRange](selection)
const node = nodes[index]()
const target = document[get](node.key)
if (!target) return
const next = selection[method](target)
change.select(next)
}
})
})
})
})
/**
* Mix in deprecated changes with a warning.
*/
const DEPRECATED_TRANSFORMS = [
['extendBackward', 'extend', 'The `extendBackward(n)` change is deprecated, please use `extend(n)` instead with a negative offset.'],
['extendForward', 'extend', 'The `extendForward(n)` change is deprecated, please use `extend(n)` instead.'],
['moveBackward', 'move', 'The `moveBackward(n)` change is deprecated, please use `move(n)` instead with a negative offset.'],
['moveForward', 'move', 'The `moveForward(n)` change is deprecated, please use `move(n)` instead.'],
['moveStartOffset', 'moveStart', 'The `moveStartOffset(n)` change is deprecated, please use `moveStart(n)` instead.'],
['moveEndOffset', 'moveEnd', 'The `moveEndOffset(n)` change is deprecated, please use `moveEnd()` instead.'],
['moveToOffsets', 'moveOffsetsTo', 'The `moveToOffsets()` change is deprecated, please use `moveOffsetsTo()` instead.'],
['flipSelection', 'flip', 'The `flipSelection()` change is deprecated, please use `flip()` instead.'],
]
DEPRECATED_TRANSFORMS.forEach(([ old, current, warning ]) => {
Changes[old] = (change, ...args) => {
logger.deprecate('0.17.0', warning)
const { state } = change
const { document, selection } = state
const sel = selection[current](...args).normalize(document)
change.select(sel)
}
})
/**
* Export.
*
* @type {Object}
*/
export default Changes

47
src/changes/on-state.js Normal file
View File

@@ -0,0 +1,47 @@
/**
* Changes.
*
* @type {Object}
*/
const Changes = {}
/**
* Set the `isNative` flag on the underlying state to prevent re-renders.
*
* @param {Change} change
* @param {Boolean} value
*/
Changes.setIsNative = (change, value) => {
let { state } = change
state = state.set('isNative', value)
change.state = state
}
/**
* Set `properties` on the top-level state's data.
*
* @param {Change} change
* @param {Object} properties
*/
Changes.setData = (change, properties) => {
const { state } = change
const { data } = state
change.applyOperation({
type: 'set_data',
properties,
data,
})
}
/**
* Export.
*
* @type {Object}
*/
export default Changes

View File

@@ -48,7 +48,6 @@ class Content extends React.Component {
editor: Types.object.isRequired,
onBeforeInput: Types.func.isRequired,
onBlur: Types.func.isRequired,
onChange: Types.func.isRequired,
onCopy: Types.func.isRequired,
onCut: Types.func.isRequired,
onDrop: Types.func.isRequired,
@@ -104,8 +103,8 @@ class Content extends React.Component {
// the cursor will be added or removed again.
if (props.readOnly != this.props.readOnly) return true
// If the state has been transformed natively, never re-render, or else we
// will end up duplicating content.
// If the state has been changed natively, never re-render, or else we'll
// end up duplicating content.
if (props.state.isNative) return false
return (
@@ -325,17 +324,6 @@ class Content extends React.Component {
this.props.onFocus(event, data)
}
/**
* On change, bubble up.
*
* @param {State} state
*/
onChange = (state) => {
debug('onChange', state)
this.props.onChange(state)
}
/**
* On composition start, set the `isComposing` flag.
*
@@ -595,9 +583,9 @@ class Content extends React.Component {
const delta = textContent.length - text.length
const after = selection.collapseToEnd().move(delta)
// Create an updated state with the text replaced.
const next = state
.transform()
// Change the current state to have the text replaced.
editor.change((change) => {
change
.select({
anchorKey: key,
anchorOffset: start,
@@ -607,10 +595,7 @@ class Content extends React.Component {
.delete()
.insertText(textContent, marks)
.select(after)
.apply()
// Change the current state.
this.onChange(next)
})
}
/**
@@ -806,7 +791,7 @@ class Content extends React.Component {
const anchorInline = document.getClosestInline(anchor.key)
const focusInline = document.getClosestInline(focus.key)
if (anchorInline && !anchorInline.isVoid && anchor.offset == anchorText.length) {
if (anchorInline && !anchorInline.isVoid && anchor.offset == anchorText.text.length) {
const block = document.getClosestBlock(anchor.key)
const next = block.getNextText(anchor.key)
if (next) {
@@ -815,7 +800,7 @@ class Content extends React.Component {
}
}
if (focusInline && !focusInline.isVoid && focus.offset == focusText.length) {
if (focusInline && !focusInline.isVoid && focus.offset == focusText.text.length) {
const block = document.getClosestBlock(focus.key)
const next = block.getNextText(focus.key)
if (next) {

View File

@@ -5,6 +5,7 @@ import React from 'react'
import Types from 'prop-types'
import Stack from '../models/stack'
import State from '../models/state'
import SlatePropTypes from '../utils/prop-types'
import noop from '../utils/noop'
@@ -116,12 +117,11 @@ class Editor extends React.Component {
// Create a new `Stack`, omitting the `onChange` property since that has
// special significance on the editor itself.
const { onChange, ...rest } = props // eslint-disable-line no-unused-vars
const { state, onChange, ...rest } = props // eslint-disable-line no-unused-vars
const stack = Stack.create(rest)
this.state.stack = stack
// Resolve the state, running `onBeforeChange` first.
const state = stack.onBeforeChange(props.state, this)
// Cache and set the state.
this.cacheState(state)
this.state.state = state
@@ -129,33 +129,35 @@ class Editor extends React.Component {
for (let i = 0; i < EVENT_HANDLERS.length; i++) {
const method = EVENT_HANDLERS[i]
this[method] = (...args) => {
const next = this.state.stack[method](this.state.state, this, ...args)
this.onChange(next)
const stk = this.state.stack
const change = this.state.state.change()
stk[method](change, this, ...args)
stk.onBeforeChange(change, this)
stk.onChange(change, this)
this.onChange(change)
}
}
}
/**
* When the `props` are updated, create a new `Stack` if necessary, and
* run `onBeforeChange`.
* When the `props` are updated, create a new `Stack` if necessary.
*
* @param {Object} props
*/
componentWillReceiveProps = (props) => {
let { stack } = this.state
const { state } = props
// If any plugin-related properties will change, create a new `Stack`.
for (let i = 0; i < PLUGINS_PROPS.length; i++) {
const prop = PLUGINS_PROPS[i]
if (props[prop] == this.props[prop]) continue
const { onChange, ...rest } = props // eslint-disable-line no-unused-vars
stack = Stack.create(rest)
const stack = Stack.create(rest)
this.setState({ stack })
}
// Resolve the state, running the before change handler of the stack.
const state = stack.onBeforeChange(props.state, this)
// Cache and save the state.
this.cacheState(state)
this.setState({ state })
}
@@ -177,12 +179,7 @@ class Editor extends React.Component {
*/
blur = () => {
const state = this.state.state
.transform()
.blur()
.apply()
this.onChange(state)
this.change(t => t.blur())
}
/**
@@ -190,12 +187,7 @@ class Editor extends React.Component {
*/
focus = () => {
const state = this.state.state
.transform()
.focus()
.apply()
this.onChange(state)
this.change(t => t.focus())
}
/**
@@ -219,22 +211,36 @@ class Editor extends React.Component {
}
/**
* When the `state` changes, pass through plugins, then bubble up.
* Perform a change `fn` on the editor's current state.
*
* @param {State} state
* @param {Function} fn
*/
onChange = (state) => {
if (state == this.state.state) return
const { tmp, props } = this
const { stack } = this.state
const { onChange, onDocumentChange, onSelectionChange } = props
const { document, selection } = tmp
change = (fn) => {
const change = this.state.state.change()
fn(change)
this.onChange(change)
}
state = stack.onChange(state, this)
onChange(state)
if (state.document != document) onDocumentChange(state.document, state)
if (state.selection != selection) onSelectionChange(state.selection, state)
/**
* On change.
*
* @param {Change} change
*/
onChange = (change) => {
if (State.isState(change)) {
throw new Error('As of slate@0.22.0 the `editor.onChange` method must be passed a `Change` object not a `State` object.')
}
const { onChange, onDocumentChange, onSelectionChange } = this.props
const { document, selection } = this.tmp
const { state } = change
if (state == this.state.state) return
onChange(change)
if (state.document != document) onDocumentChange(state.document, change)
if (state.selection != selection) onSelectionChange(state.selection, change)
}
/**

View File

@@ -78,18 +78,16 @@ class Void extends React.Component {
this.debug('onClick', { event })
const { node, editor } = this.props
const next = editor
.getState()
.transform()
editor.change((change) => {
change
// COMPAT: In Chrome & Safari, selections that are at the zero offset of
// an inline node will be automatically replaced to be at the last offset
// of a previous inline node, which screws us up, so we always want to set
// it to the end of the node. (2016/11/29)
// an inline node will be automatically replaced to be at the last
// offset of a previous inline node, which screws us up, so we always
// want to set it to the end of the node. (2016/11/29)
.collapseToEndOf(node)
.focus()
.apply()
editor.onChange(next)
})
}
/**

View File

@@ -1,20 +1,24 @@
/**
* Slate-specific item types.
* Slate-specific model types.
*
* @type {Object}
*/
const MODEL_TYPES = {
STATE: '@@__SLATE_STATE__@@',
DOCUMENT: '@@__SLATE_DOCUMENT__@@',
BLOCK: '@@__SLATE_BLOCK__@@',
INLINE: '@@__SLATE_INLINE__@@',
TEXT: '@@__SLATE_TEXT__@@',
CHANGE: '@@__SLATE_CHANGE__@@',
CHARACTER: '@@__SLATE_CHARACTER__@@',
DOCUMENT: '@@__SLATE_DOCUMENT__@@',
HISTORY: '@@__SLATE_HISTORY__@@',
INLINE: '@@__SLATE_INLINE__@@',
MARK: '@@__SLATE_MARK__@@',
RANGE: '@@__SLATE_RANGE__@@',
SELECTION: '@@__SLATE_SELECTION__@@',
SCHEMA: '@@__SLATE_SCHEMA__@@',
SELECTION: '@@__SLATE_SELECTION__@@',
STACK: '@@__SLATE_STACK__@@',
STATE: '@@__SLATE_STATE__@@',
TEXT: '@@__SLATE_TEXT__@@',
}
/**

View File

@@ -14,8 +14,10 @@ import Block from './models/block'
import Character from './models/character'
import Data from './models/data'
import Document from './models/document'
import History from './models/history'
import Inline from './models/inline'
import Mark from './models/mark'
import Node from './models/node'
import Schema from './models/schema'
import Selection from './models/selection'
import Stack from './models/stack'
@@ -23,6 +25,12 @@ import State from './models/state'
import Text from './models/text'
import Range from './models/range'
/**
* Operations.
*/
import Operations from './operations'
/**
* Serializers.
*/
@@ -32,10 +40,10 @@ import Plain from './serializers/plain'
import Raw from './serializers/raw'
/**
* Transforms.
* Changes.
*/
import Transforms from './transforms'
import Changes from './changes'
/**
* Utils.
@@ -56,9 +64,12 @@ export {
Data,
Document,
Editor,
History,
Html,
Inline,
Mark,
Node,
Operations,
Placeholder,
Plain,
Range,
@@ -68,7 +79,7 @@ export {
Stack,
State,
Text,
Transforms,
Changes,
findDOMNode,
resetKeyGenerator,
setKeyGenerator
@@ -80,9 +91,12 @@ export default {
Data,
Document,
Editor,
History,
Html,
Inline,
Mark,
Node,
Operations,
Placeholder,
Plain,
Range,
@@ -92,7 +106,7 @@ export default {
Stack,
State,
Text,
Transforms,
Changes,
findDOMNode,
resetKeyGenerator,
setKeyGenerator

View File

@@ -2,6 +2,7 @@
This directory contains all of the immutable models that contain the data that powers Slate. They are built using [Immutable.js](https://facebook.github.io/immutable-js/). Here's what each of them does:
- [Block](#block)
- [Change](#change)
- [Character](#character)
- [Data](#data)
- [Document](#document)
@@ -11,7 +12,6 @@ This directory contains all of the immutable models that contain the data that p
- [Selection](#selection)
- [State](#state)
- [Text](#text)
- [Transform](#transform)
#### Block
@@ -19,6 +19,11 @@ This directory contains all of the immutable models that contain the data that p
Just like in the DOM, `Block` nodes are one that contain other inline content. They can be split apart, and wrapped in other blocks, but they will always contain at least a single [`Text`](#text) node of inline content. They can also contain associated [`Data`](#data)
#### Change
`Change` is not publicly exposed; you access it by calling the `.change()` method on a [`State`](#state) model. It's simply a wrapper around the somewhat-complex change tracking logic that allows for a state's history to be populated correctly.
#### Character
The content of each [`Text`](#text) node is modeled as a `Character` list. Each character contains a single character string, and any associated [`Marks`](#mark) that are applied to it.
@@ -60,14 +65,9 @@ The `State` is the highest-level model. It is really just a convenient wrapper a
Since `State` has knowledge of both the [`Document`](#document) and the [`Selection`](#selection), it provides a handful of convenience methods for updating the both at the same time. For example, when inserting a new content fragment, it inserts the fragment and then moves the selection to the end of the newly inserted content.
The `State` is the object that lets you apply "transforms" that change the current document or selection. By having them all be applied through the top-level state, it can keep track of changes in the `History`, allowing for undoing and redoing changes.
The `State` is the object that lets you apply "changes" that change the current document or selection. By having them all be applied through the top-level state, it can keep track of changes in the `History`, allowing for undoing and redoing changes.
#### Text
`Text` is the lowest-level [`Node`](#node) in the tree. Each `Text` node contains a list of [`Characters`](#characters), which can optionally be dynamically decorated.
#### Transform
`Transform` is not publicly exposed; you access it by calling the `transform()` method on a [`State`](#state) model. It's simply a wrapper around the somewhat-complex transformation logic that allows for a state's history to be populated correctly.

View File

@@ -40,51 +40,63 @@ const DEFAULTS = {
class Block extends new Record(DEFAULTS) {
/**
* Create a new `Block` with `properties`.
* Create a new `Block` with `attrs`.
*
* @param {Object|Block} properties
* @param {Object|Block} attrs
* @return {Block}
*/
static create(properties = {}) {
if (Block.isBlock(properties)) return properties
if (Inline.isInline(properties)) return properties
if (Text.isText(properties)) return properties
if (!properties.type) throw new Error('You must pass a block `type`.')
static create(attrs = {}) {
if (Block.isBlock(attrs)) return attrs
if (Inline.isInline(attrs)) return attrs
if (Text.isText(attrs)) return attrs
properties.key = properties.key || generateKey()
properties.data = Data.create(properties.data)
properties.isVoid = !!properties.isVoid
properties.nodes = Block.createList(properties.nodes)
if (properties.nodes.size == 0) {
properties.nodes = properties.nodes.push(Text.create())
if (!attrs.type) {
throw new Error('You must pass a block `type`.')
}
return new Block(properties)
const { nodes } = attrs
const empty = !nodes || nodes.size == 0 || nodes.length == 0
const block = new Block({
type: attrs.type,
key: attrs.key || generateKey(),
data: Data.create(attrs.data),
isVoid: !!attrs.isVoid,
nodes: Node.createList(empty ? [Text.create()] : nodes),
})
return block
}
/**
* Create a list of `Blocks` from an array.
* Create a list of `Blocks` from `elements`.
*
* @param {Array<Object|Block>} elements
* @param {Array<Object|Block>|List<Block>} elements
* @return {List<Block>}
*/
static createList(elements = []) {
if (List.isList(elements)) return elements
return new List(elements.map(Block.create))
if (List.isList(elements)) {
return elements
}
if (Array.isArray(elements)) {
const list = new List(elements.map(Block.create))
return list
}
throw new Error(`Block.createList() must be passed an \`Array\` or a \`List\`. You passed: ${elements}`)
}
/**
* Determines if the passed in paramter is a Slate Block or not
* Check if a `value` is a `Block`.
*
* @param {*} maybeBlock
* @param {Any} value
* @return {Boolean}
*/
static isBlock(maybeBlock) {
return !!(maybeBlock && maybeBlock[MODEL_TYPES.BLOCK])
static isBlock(value) {
return !!(value && value[MODEL_TYPES.BLOCK])
}
/**
@@ -107,16 +119,6 @@ class Block extends new Record(DEFAULTS) {
return this.text == ''
}
/**
* Get the length of the concatenated text of the node.
*
* @return {Number}
*/
get length() {
return this.text.length
}
/**
* Get the concatenated text `string` of all child nodes.
*
@@ -130,7 +132,7 @@ class Block extends new Record(DEFAULTS) {
}
/**
* Pseduo-symbol that shows this is a Slate Block
* Attach a pseudo-symbol for type checking.
*/
Block.prototype[MODEL_TYPES.BLOCK] = true
@@ -139,9 +141,10 @@ Block.prototype[MODEL_TYPES.BLOCK] = true
* Mix in `Node` methods.
*/
for (const method in Node) {
Block.prototype[method] = Node[method]
}
Object.getOwnPropertyNames(Node.prototype).forEach((method) => {
if (method == 'constructor') return
Block.prototype[method] = Node.prototype[method]
})
/**
* Export.

219
src/models/change.js Normal file
View File

@@ -0,0 +1,219 @@
import MODEL_TYPES from '../constants/model-types'
import Debug from 'debug'
import Changes from '../changes'
import apply from '../operations/apply'
import logger from '../utils/logger'
import pick from 'lodash/pick'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:change')
/**
* Change.
*
* @type {Change}
*/
class Change {
/**
* Check if a `value` is a `Change`.
*
* @param {Any} value
* @return {Boolean}
*/
static isChange(value) {
return !!(value && value[MODEL_TYPES.CHANGE])
}
/**
* Create a new `Change` with `attrs`.
*
* @param {Object} attrs
* @property {State} state
*/
constructor(attrs) {
const { state } = attrs
this.state = state
this.operations = []
this.flags = pick(attrs, ['merge', 'save'])
this.setIsNative(attrs.isNative === undefined ? false : attrs.isNative)
}
/**
* Get the kind.
*
* @return {String}
*/
get kind() {
return 'change'
}
/**
* Apply an `operation` to the current state, saving the operation to the
* history if needed.
*
* @param {Object} operation
* @param {Object} options
* @return {Change}
*/
applyOperation(operation, options = {}) {
const { operations, flags } = this
let { state } = this
let { history } = state
// Default options to the change-level flags, this allows for setting
// specific options for all of the operations of a given change.
options = { ...flags, ...options }
// Derive the default option values.
const {
merge = operations.length == 0 ? null : true,
save = true,
skip = null,
} = options
// Apply the operation to the state.
debug('apply', { operation, save, merge })
state = apply(state, operation)
// If needed, save the operation to the history.
if (history && save) {
history = history.save(operation, { merge, skip })
state = state.set('history', history)
}
// Update the mutable change object.
this.state = state
this.operations.push(operation)
return this
}
/**
* Apply a series of `operations` to the current state.
*
* @param {Array} operations
* @param {Object} options
* @return {Change}
*/
applyOperations(operations, options) {
operations.forEach(op => this.applyOperation(op, options))
return this
}
/**
* Call a change `fn` with arguments.
*
* @param {Function} fn
* @param {Mixed} ...args
* @return {Change}
*/
call(fn, ...args) {
fn(this, ...args)
return this
}
/**
* Noop.
*
* @return {State}
*/
apply(options = {}) {
logger.deprecate('0.22.0', 'The `change.apply()` method is deprecrated and no longer necessary, as all operations are applied immediately when invoked. You can access the change\'s state, which is already pre-computed, directly via `change.state` instead.')
return this.state
}
}
/**
* Attach a pseudo-symbol for type checking.
*/
Change.prototype[MODEL_TYPES.CHANGE] = true
/**
* Add a change method for each of the changes.
*/
Object.keys(Changes).forEach((type) => {
Change.prototype[type] = function (...args) {
debug(type, { args })
this.call(Changes[type], ...args)
return this
}
})
/**
* Add deprecation warnings in case people try to access a change as a state.
*/
;[
'hasUndos',
'hasRedos',
'isBlurred',
'isFocused',
'isCollapsed',
'isExpanded',
'isBackward',
'isForward',
'startKey',
'endKey',
'startOffset',
'endOffset',
'anchorKey',
'focusKey',
'anchorOffset',
'focusOffset',
'startBlock',
'endBlock',
'anchorBlock',
'focusBlock',
'startInline',
'endInline',
'anchorInline',
'focusInline',
'startText',
'endText',
'anchorText',
'focusText',
'characters',
'marks',
'blocks',
'fragment',
'inlines',
'texts',
'isEmpty',
].forEach((getter) => {
Object.defineProperty(Change.prototype, getter, {
get() {
logger.deprecate('0.22.0', `You attempted to access the \`${getter}\` property of what was previously a \`state\` object but is now a \`change\` object. This syntax has been deprecated as plugins are now passed \`change\` objects instead of \`state\` objects.`)
return this.state[getter]
}
})
})
Change.prototype.transform = function () {
logger.deprecate('0.22.0', 'You attempted to call `.transform()` on what was previously a `state` object but is now already a `change` object. This syntax has been deprecated as plugins are now passed `change` objects instead of `state` objects.')
return this
}
/**
* Export.
*
* @type {Change}
*/
export default Change

View File

@@ -23,28 +23,41 @@ const DEFAULTS = {
class Character extends new Record(DEFAULTS) {
/**
* Create a character record with `properties`.
* Create a `Character` with `attrs`.
*
* @param {Object|Character} properties
* @param {Object|Character} attrs
* @return {Character}
*/
static create(properties = {}) {
if (Character.isCharacter(properties)) return properties
properties.marks = Mark.createSet(properties.marks)
return new Character(properties)
static create(attrs = {}) {
if (Character.isCharacter(attrs)) return attrs
const character = new Character({
text: attrs.text,
marks: Mark.createSet(attrs.marks),
})
return character
}
/**
* Create a characters list from an array of characters.
* Create a list of `Characters` from `elements`.
*
* @param {Array<Object|Character>} array
* @param {Array<Object|Character>|List<Character>} elements
* @return {List<Character>}
*/
static createList(array = []) {
if (List.isList(array)) return array
return new List(array.map(Character.create))
static createList(elements = []) {
if (List.isList(elements)) {
return elements
}
if (Array.isArray(elements)) {
const list = new List(elements.map(Character.create))
return list
}
throw new Error(`Character.createList() must be passed an \`Array\` or a \`List\`. You passed: ${elements}`)
}
/**
@@ -56,20 +69,20 @@ class Character extends new Record(DEFAULTS) {
*/
static createListFromText(string, marks) {
const chars = string.split('').map((text) => { return { text, marks } })
const chars = string.split('').map(text => ({ text, marks }))
const list = Character.createList(chars)
return list
}
/**
* Determines if the passed in paramter is a Slate Character or not
* Check if a `value` is a `Character`.
*
* @param {*} maybeCharacter
* @param {Any} value
* @return {Boolean}
*/
static isCharacter(maybeCharacter) {
return !!(maybeCharacter && maybeCharacter[MODEL_TYPES.CHARACTER])
static isCharacter(value) {
return !!(value && value[MODEL_TYPES.CHARACTER])
}
/**
@@ -85,7 +98,7 @@ class Character extends new Record(DEFAULTS) {
}
/**
* Pseduo-symbol that shows this is a Slate Character
* Attach a pseudo-symbol for type checking.
*/
Character.prototype[MODEL_TYPES.CHARACTER] = true

View File

@@ -13,16 +13,16 @@ import { Map } from 'immutable'
const Data = {
/**
* Create a new `Data` with `properties`.
* Create a new `Data` with `attrs`.
*
* @param {Object} properties
* @param {Object} attrs
* @return {Data} data
*/
create(properties = {}) {
return Map.isMap(properties)
? properties
: new Map(properties)
create(attrs = {}) {
return Map.isMap(attrs)
? attrs
: new Map(attrs)
}
}

View File

@@ -11,7 +11,6 @@ import './inline'
*/
import Data from './data'
import Block from './block'
import Node from './node'
import MODEL_TYPES from '../constants/model-types'
import generateKey from '../utils/generate-key'
@@ -38,31 +37,33 @@ const DEFAULTS = {
class Document extends new Record(DEFAULTS) {
/**
* Create a new `Document` with `properties`.
* Create a new `Document` with `attrs`.
*
* @param {Object|Document} properties
* @param {Object|Document} attrs
* @return {Document}
*/
static create(properties = {}) {
if (Document.isDocument(properties)) return properties
static create(attrs = {}) {
if (Document.isDocument(attrs)) return attrs
properties.key = properties.key || generateKey()
properties.data = Data.create(properties.data)
properties.nodes = Block.createList(properties.nodes)
const document = new Document({
key: attrs.key || generateKey(),
data: Data.create(attrs.data),
nodes: Node.createList(attrs.nodes),
})
return new Document(properties)
return document
}
/**
* Determines if the passed in paramter is a Slate Document or not
* Check if a `value` is a `Document`.
*
* @param {*} maybeDocument
* @param {Any} value
* @return {Boolean}
*/
static isDocument(maybeDocument) {
return !!(maybeDocument && maybeDocument[MODEL_TYPES.DOCUMENT])
static isDocument(value) {
return !!(value && value[MODEL_TYPES.DOCUMENT])
}
/**
@@ -85,16 +86,6 @@ class Document extends new Record(DEFAULTS) {
return this.text == ''
}
/**
* Get the length of the concatenated text of the document.
*
* @return {Number}
*/
get length() {
return this.text.length
}
/**
* Get the concatenated text `string` of all child nodes.
*
@@ -108,7 +99,7 @@ class Document extends new Record(DEFAULTS) {
}
/**
* Pseduo-symbol that shows this is a Slate Document
* Attach a pseudo-symbol for type checking.
*/
Document.prototype[MODEL_TYPES.DOCUMENT] = true
@@ -117,9 +108,10 @@ Document.prototype[MODEL_TYPES.DOCUMENT] = true
* Mix in `Node` methods.
*/
for (const method in Node) {
Document.prototype[method] = Node[method]
}
Object.getOwnPropertyNames(Node.prototype).forEach((method) => {
if (method == 'constructor') return
Document.prototype[method] = Node.prototype[method]
})
/**
* Export.

189
src/models/history.js Normal file
View File

@@ -0,0 +1,189 @@
import MODEL_TYPES from '../constants/model-types'
import Debug from 'debug'
import isEqual from 'lodash/isEqual'
import { Record, Stack } from 'immutable'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:history')
/**
* Default properties.
*
* @type {Object}
*/
const DEFAULTS = {
redos: new Stack(),
undos: new Stack(),
}
/**
* History.
*
* @type {History}
*/
class History extends new Record(DEFAULTS) {
/**
* Create a new `History` with `attrs`.
*
* @param {Object} attrs
* @return {History}
*/
static create(attrs = {}) {
if (History.isHistory(attrs)) return attrs
const history = new History({
undos: attrs.undos || new Stack(),
redos: attrs.redos || new Stack(),
})
return history
}
/**
* Check if a `value` is a `History`.
*
* @param {Any} value
* @return {Boolean}
*/
static isHistory(value) {
return !!(value && value[MODEL_TYPES.HISTORY])
}
/**
* Get the kind.
*
* @return {String}
*/
get kind() {
return 'history'
}
/**
* Save an `operation` into the history.
*
* @param {Object} operation
* @param {Object} options
* @return {History}
*/
save(operation, options = {}) {
let history = this
let { undos } = history
let { merge, skip } = options
const prevBatch = undos.peek()
const prevOperation = prevBatch && prevBatch[prevBatch.length - 1]
if (skip == null) {
skip = shouldSkip(operation, prevOperation)
}
if (skip) {
return history
}
if (merge == null) {
merge = shouldMerge(operation, prevOperation)
}
debug('save', { operation, merge })
// If the `merge` flag is true, add the operation to the previous batch.
if (merge) {
const batch = prevBatch.slice()
batch.push(operation)
undos = undos.pop()
undos = undos.push(batch)
}
// Otherwise, create a new batch with the operation.
else {
const batch = [operation]
undos = undos.push(batch)
}
// Constrain the history to 100 entries for memory's sake.
if (undos.length > 100) {
undos = undos.take(100)
}
history = history.set('undos', undos)
return history
}
}
/**
* Attach a pseudo-symbol for type checking.
*/
History.prototype[MODEL_TYPES.HISTORY] = true
/**
* Check whether to merge a new operation `o` into the previous operation `p`.
*
* @param {Object} o
* @param {Object} p
* @return {Boolean}
*/
function shouldMerge(o, p) {
if (!p) return false
const merge = (
(
o.type == 'set_selection' &&
p.type == 'set_selection'
) || (
o.type == 'insert_text' &&
p.type == 'insert_text' &&
o.offset == p.offset + p.text.length &&
isEqual(o.path, p.path)
) || (
o.type == 'remove_text' &&
p.type == 'remove_text' &&
o.offset + o.text.length == p.offset &&
isEqual(o.path, p.path)
)
)
return merge
}
/**
* Check whether to skip a new operation `o`, given previous operation `p`.
*
* @param {Object} o
* @param {Object} p
* @return {Boolean}
*/
function shouldSkip(o, p) {
if (!p) return false
const skip = (
o.type == 'set_selection' &&
p.type == 'set_selection'
)
return skip
}
/**
* Export.
*
* @type {History}
*/
export default History

View File

@@ -40,28 +40,32 @@ const DEFAULTS = {
class Inline extends new Record(DEFAULTS) {
/**
* Create a new `Inline` with `properties`.
* Create a new `Inline` with `attrs`.
*
* @param {Object|Inline} properties
* @param {Object|Inline} attrs
* @return {Inline}
*/
static create(properties = {}) {
if (Block.isBlock(properties)) return properties
if (Inline.isInline(properties)) return properties
if (Text.isText(properties)) return properties
if (!properties.type) throw new Error('You must pass an inline `type`.')
static create(attrs = {}) {
if (Block.isBlock(attrs)) return attrs
if (Inline.isInline(attrs)) return attrs
if (Text.isText(attrs)) return attrs
properties.key = properties.key || generateKey()
properties.data = Data.create(properties.data)
properties.isVoid = !!properties.isVoid
properties.nodes = Inline.createList(properties.nodes)
if (properties.nodes.size == 0) {
properties.nodes = properties.nodes.push(Text.create())
if (!attrs.type) {
throw new Error('You must pass an inline `type`.')
}
return new Inline(properties)
const { nodes } = attrs
const empty = !nodes || nodes.size == 0 || nodes.length == 0
const inline = new Inline({
type: attrs.type,
key: attrs.key || generateKey(),
data: Data.create(attrs.data),
isVoid: !!attrs.isVoid,
nodes: Node.createList(empty ? [Text.create()] : nodes),
})
return inline
}
/**
@@ -77,14 +81,14 @@ class Inline extends new Record(DEFAULTS) {
}
/**
* Determines if the passed in paramter is a Slate Inline or not
* Check if a `value` is a `Inline`.
*
* @param {*} maybeInline
* @param {Any} value
* @return {Boolean}
*/
static isInline(maybeInline) {
return !!(maybeInline && maybeInline[MODEL_TYPES.INLINE])
static isInline(value) {
return !!(value && value[MODEL_TYPES.INLINE])
}
/**
@@ -107,16 +111,6 @@ class Inline extends new Record(DEFAULTS) {
return this.text == ''
}
/**
* Get the length of the concatenated text of the node.
*
* @return {Number}
*/
get length() {
return this.text.length
}
/**
* Get the concatenated text `string` of all child nodes.
*
@@ -130,7 +124,7 @@ class Inline extends new Record(DEFAULTS) {
}
/**
* Pseduo-symbol that shows this is a Slate Inline
* Attach a pseudo-symbol for type checking.
*/
Inline.prototype[MODEL_TYPES.INLINE] = true
@@ -139,9 +133,10 @@ Inline.prototype[MODEL_TYPES.INLINE] = true
* Mix in `Node` methods.
*/
for (const method in Node) {
Inline.prototype[method] = Node[method]
}
Object.getOwnPropertyNames(Node.prototype).forEach((method) => {
if (method == 'constructor') return
Inline.prototype[method] = Node.prototype[method]
})
/**
* Export.

View File

@@ -24,40 +24,60 @@ const DEFAULTS = {
class Mark extends new Record(DEFAULTS) {
/**
* Create a new `Mark` with `properties`.
* Create a new `Mark` with `attrs`.
*
* @param {Object|Mark} properties
* @param {Object|Mark} attrs
* @return {Mark}
*/
static create(properties = {}) {
if (Mark.isMark(properties)) return properties
if (!properties.type) throw new Error('You must provide a `type` for the mark.')
properties.data = Data.create(properties.data)
return new Mark(properties)
static create(attrs = {}) {
if (Mark.isMark(attrs)) return attrs
if (!attrs.type) {
throw new Error(`You must provide \`attrs.type\` to \`Mark.create(attrs)\`.`)
}
const mark = new Mark({
type: attrs.type,
data: Data.create(attrs.data),
})
return mark
}
/**
* Create a marks set from an array of marks.
* Create a set of marks.
*
* @param {Array<Object|Mark>} array
* @param {Array<Object|Mark>} elements
* @return {Set<Mark>}
*/
static createSet(array = []) {
if (Set.isSet(array)) return array
return new Set(array.map(Mark.create))
static createSet(elements) {
if (Set.isSet(elements)) {
return elements
}
if (Array.isArray(elements)) {
const marks = new Set(elements.map(Mark.create))
return marks
}
if (elements == null) {
return new Set()
}
throw new Error(`Mark.createSet() must be passed an \`Array\`, a \`List\` or \`null\`. You passed: ${elements}`)
}
/**
* Determines if the passed in paramter is a Slate Mark or not
* Check if a `value` is a `Mark`.
*
* @param {*} maybeMark
* @param {Any} value
* @return {Boolean}
*/
static isMark(maybeMark) {
return !!(maybeMark && maybeMark[MODEL_TYPES.MARK])
static isMark(value) {
return !!(value && value[MODEL_TYPES.MARK])
}
/**
@@ -82,7 +102,7 @@ class Mark extends new Record(DEFAULTS) {
}
/**
* Pseduo-symbol that shows this is a Slate Mark
* Attach a pseudo-symbol for type checking.
*/
Mark.prototype[MODEL_TYPES.MARK] = true

File diff suppressed because it is too large Load Diff

View File

@@ -24,28 +24,32 @@ const DEFAULTS = {
class Range extends new Record(DEFAULTS) {
/**
* Create a new `Range` with `properties`.
* Create a new `Range` with `attrs`.
*
* @param {Object|Range} properties
* @param {Object|Range} attrs
* @return {Range}
*/
static create(properties = {}) {
if (Range.isRange(properties)) return properties
properties.text = properties.text
properties.marks = Mark.createSet(properties.marks)
return new Range(properties)
static create(attrs = {}) {
if (Range.isRange(attrs)) return attrs
const range = new Range({
text: attrs.text,
marks: Mark.createSet(attrs.marks),
})
return range
}
/**
* Determines if the passed in paramter is a Slate Range or not
* Check if a `value` is a `Range`.
*
* @param {*} maybeRange
* @param {Any} value
* @return {Boolean}
*/
static isRange(maybeRange) {
return !!(maybeRange && maybeRange[MODEL_TYPES.RANGE])
static isRange(value) {
return !!(value && value[MODEL_TYPES.RANGE])
}
/**
@@ -66,8 +70,7 @@ class Range extends new Record(DEFAULTS) {
getCharacters() {
const { marks } = this
return Character.createList(this.text
const characters = Character.createList(this.text
.split('')
.map((char) => {
return Character.create({
@@ -75,12 +78,14 @@ class Range extends new Record(DEFAULTS) {
marks
})
}))
return characters
}
}
/**
* Pseduo-symbol that shows this is a Slate Range
* Attach a pseudo-symbol for type checking.
*/
Range.prototype[MODEL_TYPES.RANGE] = true

View File

@@ -1,10 +1,11 @@
import React from 'react'
import isReactComponent from '../utils/is-react-component'
import typeOf from 'type-of'
import MODEL_TYPES from '../constants/model-types'
import { Record } from 'immutable'
import React from 'react'
import find from 'lodash/find'
import isReactComponent from '../utils/is-react-component'
import logger from '../utils/logger'
import typeOf from 'type-of'
import { Record } from 'immutable'
/**
* Default properties.
@@ -25,26 +26,27 @@ const DEFAULTS = {
class Schema extends new Record(DEFAULTS) {
/**
* Create a new `Schema` with `properties`.
* Create a new `Schema` with `attrs`.
*
* @param {Object|Schema} properties
* @param {Object|Schema} attrs
* @return {Schema}
*/
static create(properties = {}) {
if (Schema.isSchema(properties)) return properties
return new Schema(normalizeProperties(properties))
static create(attrs = {}) {
if (Schema.isSchema(attrs)) return attrs
const schema = new Schema(normalizeProperties(attrs))
return schema
}
/**
* Determines if the passed in paramter is a Slate Schema or not
* Check if a `value` is a `Schema`.
*
* @param {*} maybeSchema
* @param {Any} value
* @return {Boolean}
*/
static isSchema(maybeSchema) {
return !!(maybeSchema && maybeSchema[MODEL_TYPES.SCHEMA])
static isSchema(value) {
return !!(value && value[MODEL_TYPES.SCHEMA])
}
/**
@@ -170,6 +172,12 @@ function normalizeProperties(properties) {
rules = rules.concat(array)
}
if (properties.transform) {
logger.deprecate('0.22.0', 'The `schema.transform` property has been deprecated in favor of `schema.change`.')
properties.change = properties.transform
delete properties.transform
}
return { rules }
}
@@ -249,7 +257,7 @@ function normalizeMarkComponent(render) {
}
/**
* Pseduo-symbol that shows this is a Slate Schema
* Attach a pseudo-symbol for type checking.
*/
Schema.prototype[MODEL_TYPES.SCHEMA] = true

View File

@@ -1,5 +1,5 @@
import warn from '../utils/warn'
import logger from '../utils/logger'
import MODEL_TYPES from '../constants/model-types'
import { Record } from 'immutable'
@@ -28,26 +28,27 @@ const DEFAULTS = {
class Selection extends new Record(DEFAULTS) {
/**
* Create a new `Selection` with `properties`.
* Create a new `Selection` with `attrs`.
*
* @param {Object|Selection} properties
* @param {Object|Selection} attrs
* @return {Selection}
*/
static create(properties = {}) {
if (Selection.isSelection(properties)) return properties
return new Selection(properties)
static create(attrs = {}) {
if (Selection.isSelection(attrs)) return attrs
const selection = new Selection(attrs)
return selection
}
/**
* Determines if the passed in paramter is a Slate Selection or not
* Check if a `value` is a `Selection`.
*
* @param {*} maybeSelection
* @param {Any} value
* @return {Boolean}
*/
static isSelection(maybeSelection) {
return !!(maybeSelection && maybeSelection[MODEL_TYPES.SELECTION])
static isSelection(value) {
return !!(value && value[MODEL_TYPES.SELECTION])
}
/**
@@ -186,7 +187,7 @@ class Selection extends new Record(DEFAULTS) {
hasAnchorAtEndOf(node) {
const last = getLast(node)
return this.anchorKey == last.key && this.anchorOffset == last.length
return this.anchorKey == last.key && this.anchorOffset == last.text.length
}
/**
@@ -217,7 +218,7 @@ class Selection extends new Record(DEFAULTS) {
hasAnchorIn(node) {
return node.kind == 'text'
? node.key == this.anchorKey
: node.hasDescendant(this.anchorKey)
: this.anchorKey != null && node.hasDescendant(this.anchorKey)
}
/**
@@ -229,7 +230,7 @@ class Selection extends new Record(DEFAULTS) {
hasFocusAtEndOf(node) {
const last = getLast(node)
return this.focusKey == last.key && this.focusOffset == last.length
return this.focusKey == last.key && this.focusOffset == last.text.length
}
/**
@@ -273,7 +274,7 @@ class Selection extends new Record(DEFAULTS) {
hasFocusIn(node) {
return node.kind == 'text'
? node.key == this.focusKey
: node.hasDescendant(this.focusKey)
: this.focusKey != null && node.hasDescendant(this.focusKey)
}
/**
@@ -516,7 +517,7 @@ class Selection extends new Record(DEFAULTS) {
moveAnchorToEndOf(node) {
node = getLast(node)
return this.moveAnchorTo(node.key, node.length)
return this.moveAnchorTo(node.key, node.text.length)
}
/**
@@ -540,7 +541,7 @@ class Selection extends new Record(DEFAULTS) {
moveFocusToEndOf(node) {
node = getLast(node)
return this.moveFocusTo(node.key, node.length)
return this.moveFocusTo(node.key, node.text.length)
}
/**
@@ -569,14 +570,8 @@ class Selection extends new Record(DEFAULTS) {
const selection = this
let { anchorKey, anchorOffset, focusKey, focusOffset, isBackward } = selection
// If the selection isn't formed yet or is malformed, ensure that it is
// properly zeroed out.
if (
anchorKey == null ||
focusKey == null ||
!node.hasDescendant(anchorKey) ||
!node.hasDescendant(focusKey)
) {
// If the selection is unset, make sure it is properly zeroed out.
if (anchorKey == null || focusKey == null) {
return selection.merge({
anchorKey: null,
anchorOffset: 0,
@@ -590,9 +585,22 @@ class Selection extends new Record(DEFAULTS) {
let anchorNode = node.getDescendant(anchorKey)
let focusNode = node.getDescendant(focusKey)
// If the selection is malformed, warn and zero it out.
if (!anchorNode || !focusNode) {
logger.warn('The selection was invalid and was reset. The selection in question was:', selection)
const first = node.getFirstText()
return selection.merge({
anchorKey: first ? first.key : null,
anchorOffset: 0,
focusKey: first ? first.key : null,
focusOffset: 0,
isBackward: false,
})
}
// If the anchor node isn't a text node, match it to one.
if (anchorNode.kind != 'text') {
warn('The selection anchor was set to a Node that is not a Text node. This should not happen and can degrade performance. The node in question was:', anchorNode)
logger.warn('The selection anchor was set to a Node that is not a Text node. This should not happen and can degrade performance. The node in question was:', anchorNode)
const anchorText = anchorNode.getTextAtOffset(anchorOffset)
const offset = anchorNode.getOffset(anchorText.key)
anchorOffset = anchorOffset - offset
@@ -601,7 +609,7 @@ class Selection extends new Record(DEFAULTS) {
// If the focus node isn't a text node, match it to one.
if (focusNode.kind != 'text') {
warn('The selection focus was set to a Node that is not a Text node. This should not happen and can degrade performance. The node in question was:', focusNode)
logger.warn('The selection focus was set to a Node that is not a Text node. This should not happen and can degrade performance. The node in question was:', focusNode)
const focusText = focusNode.getTextAtOffset(focusOffset)
const offset = focusNode.getOffset(focusText.key)
focusOffset = focusOffset - offset
@@ -634,7 +642,7 @@ class Selection extends new Record(DEFAULTS) {
*/
unset() {
warn('The `Selection.unset` method is deprecated, please switch to using `Selection.deselect` instead.')
logger.deprecate('0.17.0', 'The `Selection.unset` method is deprecated, please switch to using `Selection.deselect` instead.')
return this.deselect()
}
@@ -646,7 +654,7 @@ class Selection extends new Record(DEFAULTS) {
*/
moveForward(n = 1) {
warn('The `Selection.moveForward(n)` method is deprecated, please switch to using `Selection.move(n)` instead.')
logger.deprecate('0.17.0', 'The `Selection.moveForward(n)` method is deprecated, please switch to using `Selection.move(n)` instead.')
return this.move(n)
}
@@ -658,7 +666,7 @@ class Selection extends new Record(DEFAULTS) {
*/
moveBackward(n = 1) {
warn('The `Selection.moveBackward(n)` method is deprecated, please switch to using `Selection.move(-n)` (with a negative number) instead.')
logger.deprecate('0.17.0', 'The `Selection.moveBackward(n)` method is deprecated, please switch to using `Selection.move(-n)` (with a negative number) instead.')
return this.move(0 - n)
}
@@ -670,7 +678,7 @@ class Selection extends new Record(DEFAULTS) {
*/
moveAnchorOffset(n = 1) {
warn('The `Selection.moveAnchorOffset(n)` method is deprecated, please switch to using `Selection.moveAnchor(n)` instead.')
logger.deprecate('0.17.0', 'The `Selection.moveAnchorOffset(n)` method is deprecated, please switch to using `Selection.moveAnchor(n)` instead.')
return this.moveAnchor(n)
}
@@ -682,7 +690,7 @@ class Selection extends new Record(DEFAULTS) {
*/
moveFocusOffset(n = 1) {
warn('The `Selection.moveFocusOffset(n)` method is deprecated, please switch to using `Selection.moveFocus(n)` instead.')
logger.deprecate('0.17.0', 'The `Selection.moveFocusOffset(n)` method is deprecated, please switch to using `Selection.moveFocus(n)` instead.')
return this.moveFocus(n)
}
@@ -694,7 +702,7 @@ class Selection extends new Record(DEFAULTS) {
*/
moveStartOffset(n = 1) {
warn('The `Selection.moveStartOffset(n)` method is deprecated, please switch to using `Selection.moveStart(n)` instead.')
logger.deprecate('0.17.0', 'The `Selection.moveStartOffset(n)` method is deprecated, please switch to using `Selection.moveStart(n)` instead.')
return this.moveStart(n)
}
@@ -706,7 +714,7 @@ class Selection extends new Record(DEFAULTS) {
*/
moveEndOffset(n = 1) {
warn('The `Selection.moveEndOffset(n)` method is deprecated, please switch to using `Selection.moveEnd(n)` instead.')
logger.deprecate('0.17.0', 'The `Selection.moveEndOffset(n)` method is deprecated, please switch to using `Selection.moveEnd(n)` instead.')
return this.moveEnd(n)
}
@@ -718,7 +726,7 @@ class Selection extends new Record(DEFAULTS) {
*/
extendForward(n = 1) {
warn('The `Selection.extendForward(n)` method is deprecated, please switch to using `Selection.extend(n)` instead.')
logger.deprecate('0.17.0', 'The `Selection.extendForward(n)` method is deprecated, please switch to using `Selection.extend(n)` instead.')
return this.extend(n)
}
@@ -730,7 +738,7 @@ class Selection extends new Record(DEFAULTS) {
*/
extendBackward(n = 1) {
warn('The `Selection.extendBackward(n)` method is deprecated, please switch to using `Selection.extend(-n)` (with a negative number) instead.')
logger.deprecate('0.17.0', 'The `Selection.extendBackward(n)` method is deprecated, please switch to using `Selection.extend(-n)` (with a negative number) instead.')
return this.extend(0 - n)
}
@@ -743,14 +751,14 @@ class Selection extends new Record(DEFAULTS) {
*/
moveToOffsets(anchorOffset, focusOffset = anchorOffset) {
warn('The `Selection.moveToOffsets` method is deprecated, please switch to using `Selection.moveOffsetsTo` instead.')
logger.deprecate('0.17.0', 'The `Selection.moveToOffsets` method is deprecated, please switch to using `Selection.moveOffsetsTo` instead.')
return this.moveOffsetsTo(anchorOffset, focusOffset)
}
}
/**
* Pseduo-symbol that shows this is a Slate Selection
* Attach a pseudo-symbol for type checking.
*/
Selection.prototype[MODEL_TYPES.SELECTION] = true

View File

@@ -1,8 +1,8 @@
import MODEL_TYPES from '../constants/model-types'
import CorePlugin from '../plugins/core'
import Debug from 'debug'
import Schema from './schema'
import State from './state'
import { Record } from 'immutable'
/**
@@ -19,27 +19,18 @@ const debug = Debug('slate:stack')
* @type {Array}
*/
const EVENT_HANDLER_METHODS = [
const METHODS = [
'onBeforeInput',
'onBeforeChange',
'onBlur',
'onFocus',
'onCopy',
'onCut',
'onDrop',
'onFocus',
'onKeyDown',
'onKeyUp',
'onPaste',
'onSelect',
]
/**
* Methods that accumulate an updated state.
*
* @type {Array}
*/
const STATE_ACCUMULATOR_METHODS = [
'onBeforeChange',
'onChange',
]
@@ -65,16 +56,28 @@ class Stack extends new Record(DEFAULTS) {
/**
* Constructor.
*
* @param {Object} properties
* @param {Object} attrs
* @property {Array} plugins
* @property {Schema|Object} schema
* @property {Function} ...handlers
*/
static create(properties) {
const plugins = resolvePlugins(properties)
static create(attrs) {
const plugins = resolvePlugins(attrs)
const schema = resolveSchema(plugins)
return new Stack({ plugins, schema })
const stack = new Stack({ plugins, schema })
return stack
}
/**
* Check if a `value` is a `Stack`.
*
* @param {Any} value
* @return {Boolean}
*/
static isStack(value) {
return !!(value && value[MODEL_TYPES.STACK])
}
/**
@@ -98,7 +101,7 @@ class Stack extends new Record(DEFAULTS) {
* @return {Component}
*/
render = (state, editor, props) => {
render(state, editor, props) {
debug('render')
const plugins = this.plugins.slice().reverse()
let children
@@ -121,7 +124,7 @@ class Stack extends new Record(DEFAULTS) {
* @return {Array}
*/
renderPortal = (state, editor) => {
renderPortal(state, editor) {
debug('renderPortal')
const portals = []
@@ -129,8 +132,7 @@ class Stack extends new Record(DEFAULTS) {
const plugin = this.plugins[i]
if (!plugin.renderPortal) continue
const portal = plugin.renderPortal(state, editor)
if (portal == null) continue
portals.push(portal)
if (portal) portals.push(portal)
}
return portals
@@ -139,74 +141,33 @@ class Stack extends new Record(DEFAULTS) {
}
/**
* Mix in the event handler methods.
*
* @param {State} state
* @param {Editor} editor
* @param {Mixed} ...args
* @return {State|Null}
* Attach a pseudo-symbol for type checking.
*/
for (let i = 0; i < EVENT_HANDLER_METHODS.length; i++) {
const method = EVENT_HANDLER_METHODS[i]
Stack.prototype[method] = function (state, editor, ...args) {
Stack.prototype[MODEL_TYPES.STACK] = true
/**
* Mix in the stack methods.
*
* @param {Change} change
* @param {Editor} editor
* @param {Mixed} ...args
*/
for (let i = 0; i < METHODS.length; i++) {
const method = METHODS[i]
Stack.prototype[method] = function (change, editor, ...args) {
debug(method)
for (let k = 0; k < this.plugins.length; k++) {
const plugin = this.plugins[k]
if (!plugin[method]) continue
const next = plugin[method](...args, state, editor)
if (next == null) continue
assertState(next)
return next
const next = plugin[method](...args, change, editor)
if (next != null) break
}
return state
}
}
/**
* Mix in the state accumulator methods.
*
* @param {State} state
* @param {Editor} editor
* @param {Mixed} ...args
* @return {State|Null}
*/
for (let i = 0; i < STATE_ACCUMULATOR_METHODS.length; i++) {
const method = STATE_ACCUMULATOR_METHODS[i]
Stack.prototype[method] = function (state, editor, ...args) {
debug(method)
if (method == 'onChange') {
state = this.onBeforeChange(state, editor)
}
for (let k = 0; k < this.plugins.length; k++) {
const plugin = this.plugins[k]
if (!plugin[method]) continue
const next = plugin[method](...args, state, editor)
if (next == null) continue
assertState(next)
state = next
}
return state
}
}
/**
* Assert that a `value` is a state object.
*
* @param {Mixed} value
*/
function assertState(value) {
if (State.isState(value)) return
throw new Error(`A plugin returned an unexpected state value: ${value}`)
}
/**
* Resolve a schema from a set of `plugins`.
*

View File

@@ -1,22 +1,12 @@
import Document from './document'
import SCHEMA from '../schemas/core'
import Selection from './selection'
import Transform from './transform'
import MODEL_TYPES from '../constants/model-types'
import { Record, Set, Stack, List, Map } from 'immutable'
/**
* History.
*
* @type {History}
*/
const History = new Record({
undos: new Stack(),
redos: new Stack()
})
import SCHEMA from '../schemas/core'
import Change from './change'
import Document from './document'
import History from './history'
import Selection from './selection'
import logger from '../utils/logger'
import { Record, Set, List, Map } from 'immutable'
/**
* Default properties.
@@ -41,24 +31,24 @@ const DEFAULTS = {
class State extends new Record(DEFAULTS) {
/**
* Create a new `State` with `properties`.
* Create a new `State` with `attrs`.
*
* @param {Object|State} properties
* @param {Object|State} attrs
* @param {Object} options
* @property {Boolean} normalize
* @return {State}
*/
static create(properties = {}, options = {}) {
if (State.isState(properties)) return properties
static create(attrs = {}, options = {}) {
if (State.isState(attrs)) return attrs
const document = Document.create(properties.document)
let selection = Selection.create(properties.selection)
const document = Document.create(attrs.document)
let selection = Selection.create(attrs.selection)
let data = new Map()
if (selection.isUnset) {
const text = document.getFirstText()
selection = selection.collapseToStartOf(text)
if (text) selection = selection.collapseToStartOf(text)
}
// Set default value for `data`.
@@ -68,25 +58,30 @@ class State extends new Record(DEFAULTS) {
}
}
// Then add data provided in `properties`.
if (properties.data) data = data.merge(properties.data)
// Then add data provided in `attrs`.
if (attrs.data) data = data.merge(attrs.data)
const state = new State({ document, selection, data })
let state = new State({ document, selection, data })
return options.normalize === false
? state
: state.transform().normalize(SCHEMA).apply({ save: false })
if (options.normalize !== false) {
state = state
.change({ save: false })
.normalize(SCHEMA)
.state
}
return state
}
/**
* Determines if the passed in paramter is a Slate State or not
* Check if a `value` is a `State`.
*
* @param {*} maybeState
* @param {Any} value
* @return {Boolean}
*/
static isState(maybeState) {
return !!(maybeState && maybeState[MODEL_TYPES.STATE])
static isState(value) {
return !!(value && value[MODEL_TYPES.STATE])
}
/**
@@ -106,7 +101,7 @@ class State extends new Record(DEFAULTS) {
*/
get hasUndos() {
return this.history.undos.size > 0
return this.history.undos.length > 0
}
/**
@@ -116,7 +111,7 @@ class State extends new Record(DEFAULTS) {
*/
get hasRedos() {
return this.history.redos.size > 0
return this.history.redos.length > 0
}
/**
@@ -482,24 +477,31 @@ class State extends new Record(DEFAULTS) {
}
/**
* Return a new `Transform` with the current state as a starting point.
* Create a new `Change` with the current state as a starting point.
*
* @param {Object} properties
* @return {Transform}
* @param {Object} attrs
* @return {Change}
*/
transform(properties = {}) {
const state = this
return new Transform({
...properties,
state
})
change(attrs = {}) {
return new Change({ ...attrs, state: this })
}
/**
* Deprecated.
*
* @return {Change}
*/
transform(...args) {
logger.deprecate('0.22.0', 'The `state.transform()` method has been deprecated in favor of `state.change()`.')
return this.change(...args)
}
}
/**
* Pseduo-symbol that shows this is a Slate State
* Attach a pseudo-symbol for type checking.
*/
State.prototype[MODEL_TYPES.STATE] = true

View File

@@ -27,17 +27,22 @@ const DEFAULTS = {
class Text extends new Record(DEFAULTS) {
/**
* Create a new `Text` with `properties`.
* Create a new `Text` with `attrs`.
*
* @param {Object|Text} properties
* @param {Object|Text} attrs
* @return {Text}
*/
static create(properties = {}) {
if (Text.isText(properties)) return properties
properties.key = properties.key || generateKey()
properties.characters = Character.createList(properties.characters)
return new Text(properties)
static create(attrs = {}) {
if (Text.isText(attrs)) return attrs
if (attrs.ranges) return Text.createFromRanges(attrs.ranges)
const text = new Text({
characters: Character.createList(attrs.characters),
key: attrs.key || generateKey(),
})
return text
}
/**
@@ -48,10 +53,10 @@ class Text extends new Record(DEFAULTS) {
* @return {Text}
*/
static createFromString(text, marks = Set()) {
return Text.createFromRanges([
Range.create({ text, marks })
])
static createFromString(string, marks = Set()) {
const range = Range.create({ text: string, marks })
const text = Text.createFromRanges([range])
return text
}
/**
@@ -62,12 +67,14 @@ class Text extends new Record(DEFAULTS) {
*/
static createFromRanges(ranges) {
return Text.create({
characters: ranges.reduce((characters, range) => {
range = Range.create(range)
return characters.concat(range.getCharacters())
const characters = ranges
.map(Range.create)
.reduce((list, range) => {
return list.concat(range.getCharacters())
}, Character.createList())
})
const text = Text.create({ characters })
return text
}
/**
@@ -78,19 +85,23 @@ class Text extends new Record(DEFAULTS) {
*/
static createList(elements = []) {
if (List.isList(elements)) return elements
return new List(elements.map(Text.create))
if (List.isList(elements)) {
return elements
}
const list = new List(elements.map(Text.create))
return list
}
/**
* Determines if the passed in paramter is a Slate Text or not
* Check if a `value` is a `Text`.
*
* @param {*} maybeText
* @param {Any} value
* @return {Boolean}
*/
static isText(maybeText) {
return !!(maybeText && maybeText[MODEL_TYPES.TEXT])
static isText(value) {
return !!(value && value[MODEL_TYPES.TEXT])
}
/**
@@ -113,16 +124,6 @@ class Text extends new Record(DEFAULTS) {
return this.text == ''
}
/**
* Get the length of the concatenated text of the node.
*
* @return {Number}
*/
get length() {
return this.text.length
}
/**
* Get the concatenated text of the node.
*
@@ -317,7 +318,6 @@ class Text extends new Record(DEFAULTS) {
*/
insertText(index, text, marks) {
marks = marks || this.getMarksAtIndex(index)
let { characters } = this
const chars = Character.createListFromText(text, marks)
@@ -383,11 +383,13 @@ class Text extends new Record(DEFAULTS) {
* @param {Number} index
* @param {Number} length
* @param {Mark} mark
* @param {Mark} newMark
* @param {Object} properties
* @return {Text}
*/
updateMark(index, length, mark, newMark) {
updateMark(index, length, mark, properties) {
const newMark = mark.merge(properties)
const characters = this.characters.map((char, i) => {
if (i < index) return char
if (i >= index + length) return char
@@ -416,7 +418,7 @@ class Text extends new Record(DEFAULTS) {
}
/**
* Pseudo-symbol that shows this is a Slate Text
* Attach a pseudo-symbol for type checking.
*/
Text.prototype[MODEL_TYPES.TEXT] = true

View File

@@ -1,172 +0,0 @@
import Debug from 'debug'
import Transforms from '../transforms'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:transform')
/**
* Transform.
*
* @type {Transform}
*/
class Transform {
/**
* Constructor.
*
* @param {Object} properties
* @property {State} state
*/
constructor(properties) {
const { state } = properties
this.state = state
this.operations = []
}
/**
* Get the kind.
*
* @return {String}
*/
get kind() {
return 'transform'
}
/**
* Apply the transform and return the new state.
*
* @param {Object} options
* @property {Boolean} isNative
* @property {Boolean} merge
* @property {Boolean} save
* @return {State}
*/
apply(options = {}) {
const transform = this
let { merge, save, isNative = false } = options
// Ensure that the selection is normalized.
transform.normalizeSelection()
const { state, operations } = transform
const { history } = state
const { undos } = history
const previous = undos.peek()
// If there are no operations, abort early.
if (!operations.length) return state
// If there's a previous save point, determine if the new operations should
// be merged into the previous ones.
if (previous && merge == null) {
merge = (
isOnlySelections(operations) ||
isContiguousInserts(operations, previous) ||
isContiguousRemoves(operations, previous)
)
}
// If the save flag isn't set, determine whether we should save.
if (save == null) {
save = !isOnlySelections(operations)
}
// Save the new operations.
if (save) this.save({ merge })
// Return the new state with the `isNative` flag set.
return this.state.set('isNative', !!isNative)
}
}
/**
* Add a transform method for each of the transforms.
*/
Object.keys(Transforms).forEach((type) => {
Transform.prototype[type] = function (...args) {
debug(type, { args })
Transforms[type](this, ...args)
return this
}
})
/**
* Check whether a list of `operations` only contains selection operations.
*
* @param {Array} operations
* @return {Boolean}
*/
function isOnlySelections(operations) {
return operations.every(op => op.type == 'set_selection')
}
/**
* Check whether a list of `operations` and a list of `previous` operations are
* contiguous text insertions.
*
* @param {Array} operations
* @param {Array} previous
*/
function isContiguousInserts(operations, previous) {
const edits = operations.filter(op => op.type != 'set_selection')
const prevEdits = previous.filter(op => op.type != 'set_selection')
if (!edits.length || !prevEdits.length) return false
const onlyInserts = edits.every(op => op.type == 'insert_text')
const prevOnlyInserts = prevEdits.every(op => op.type == 'insert_text')
if (!onlyInserts || !prevOnlyInserts) return false
const first = edits[0]
const last = prevEdits[prevEdits.length - 1]
if (first.key != last.key) return false
if (first.offset != last.offset + last.text.length) return false
return true
}
/**
* Check whether a list of `operations` and a list of `previous` operations are
* contiguous text removals.
*
* @param {Array} operations
* @param {Array} previous
*/
function isContiguousRemoves(operations, previous) {
const edits = operations.filter(op => op.type != 'set_selection')
const prevEdits = previous.filter(op => op.type != 'set_selection')
if (!edits.length || !prevEdits.length) return false
const onlyRemoves = edits.every(op => op.type == 'remove_text')
const prevOnlyRemoves = prevEdits.every(op => op.type == 'remove_text')
if (!onlyRemoves || !prevOnlyRemoves) return false
const first = edits[0]
const last = prevEdits[prevEdits.length - 1]
if (first.key != last.key) return false
if (first.offset + first.length != last.offset) return false
return true
}
/**
* Export.
*
* @type {Transform}
*/
export default Transform

2
src/operations/Readme.md Normal file
View File

@@ -0,0 +1,2 @@
This directory contains all of the low-level operations logic that ships with Slate by default and makes up the core of how changes are applied.

498
src/operations/apply.js Normal file
View File

@@ -0,0 +1,498 @@
import Debug from 'debug'
import Normalize from '../utils/normalize'
import logger from '../utils/logger'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:operation:apply')
/**
* Applying functions.
*
* @type {Object}
*/
const APPLIERS = {
/**
* Add mark to text at `offset` and `length` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
add_mark(state, operation) {
const { path, offset, length } = operation
const mark = Normalize.mark(operation.mark)
let { document } = state
let node = document.assertPath(path)
node = node.addMark(offset, length, mark)
document = document.updateNode(node)
state = state.set('document', document)
return state
},
/**
* Insert a `node` at `index` in a node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
insert_node(state, operation) {
const { path } = operation
const node = Normalize.node(operation.node)
const index = path[path.length - 1]
const rest = path.slice(0, -1)
let { document } = state
let parent = document.assertPath(rest)
parent = parent.insertNode(index, node)
document = document.updateNode(parent)
state = state.set('document', document)
return state
},
/**
* Insert `text` at `offset` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
insert_text(state, operation) {
const { path, offset, text } = operation
let { marks } = operation
if (Array.isArray(marks)) marks = Normalize.marks(marks)
let { document, selection } = state
const { anchorKey, focusKey, anchorOffset, focusOffset } = selection
let node = document.assertPath(path)
// Update the document
node = node.insertText(offset, text, marks)
document = document.updateNode(node)
// Update the selection
if (anchorKey == node.key && anchorOffset >= offset) {
selection = selection.moveAnchor(text.length)
}
if (focusKey == node.key && focusOffset >= offset) {
selection = selection.moveFocus(text.length)
}
state = state.set('document', document).set('selection', selection)
return state
},
/**
* Merge a node at `path` with the previous node.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
merge_node(state, operation) {
const { path } = operation
const withPath = path.slice(0, path.length - 1).concat([path[path.length - 1] - 1])
let { document, selection } = state
const one = document.assertPath(withPath)
const two = document.assertPath(path)
let parent = document.getParent(one.key)
const oneIndex = parent.nodes.indexOf(one)
const twoIndex = parent.nodes.indexOf(two)
// Perform the merge in the document.
parent = parent.mergeNode(oneIndex, twoIndex)
document = document.updateNode(parent)
// If the nodes are text nodes and the selection is inside the second node
// update it to refer to the first node instead.
if (one.kind == 'text') {
const { anchorKey, anchorOffset, focusKey, focusOffset } = selection
let normalize = false
if (anchorKey == two.key) {
selection = selection.moveAnchorTo(one.key, one.text.length + anchorOffset)
normalize = true
}
if (focusKey == two.key) {
selection = selection.moveFocusTo(one.key, one.text.length + focusOffset)
normalize = true
}
if (normalize) {
selection = selection.normalize(document)
}
}
// Update the document and selection.
state = state.set('document', document).set('selection', selection)
return state
},
/**
* Move a node by `path` to `newPath`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
move_node(state, operation) {
const { path, newPath } = operation
const newIndex = newPath[newPath.length - 1]
const newParentPath = newPath.slice(0, -1)
const oldParentPath = path.slice(0, -1)
const oldIndex = path[path.length - 1]
let { document } = state
const node = document.assertPath(path)
// Remove the node from its current parent.
let parent = document.getParent(node.key)
parent = parent.removeNode(oldIndex)
document = document.updateNode(parent)
// Find the new target...
let target
// If the old path and the rest of the new path are the same, then the new
// target is the old parent.
if (
(oldParentPath.every((x, i) => x === newParentPath[i])) &&
(oldParentPath.length === newParentPath.length)
) {
target = parent
}
// Otherwise, if the old path removal resulted in the new path being no longer
// correct, we need to decrement the new path at the old path's last index.
else if (
(oldParentPath.every((x, i) => x === newParentPath[i])) &&
(oldIndex < newParentPath[oldParentPath.length])
) {
newParentPath[oldParentPath.length]--
target = document.assertPath(newParentPath)
}
// Otherwise, we can just grab the target normally...
else {
target = document.assertPath(newParentPath)
}
// Insert the new node to its new parent.
target = target.insertNode(newIndex, node)
document = document.updateNode(target)
state = state.set('document', document)
return state
},
/**
* Remove mark from text at `offset` and `length` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
remove_mark(state, operation) {
const { path, offset, length } = operation
const mark = Normalize.mark(operation.mark)
let { document } = state
let node = document.assertPath(path)
node = node.removeMark(offset, length, mark)
document = document.updateNode(node)
state = state.set('document', document)
return state
},
/**
* Remove a node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
remove_node(state, operation) {
const { path } = operation
let { document, selection } = state
const { startKey, endKey } = selection
const node = document.assertPath(path)
// If the selection is set, check to see if it needs to be updated.
if (selection.isSet) {
const hasStartNode = node.hasNode(startKey)
const hasEndNode = node.hasNode(endKey)
let normalize = false
// If one of the selection's nodes is being removed, we need to update it.
if (hasStartNode) {
const prev = document.getPreviousText(startKey)
const next = document.getNextText(startKey)
if (prev) {
selection = selection.moveStartTo(prev.key, prev.text.length)
normalize = true
} else if (next) {
selection = selection.moveStartTo(next.key, 0)
normalize = true
} else {
selection = selection.deselect()
}
}
if (hasEndNode) {
const prev = document.getPreviousText(endKey)
const next = document.getNextText(endKey)
if (prev) {
selection = selection.moveEndTo(prev.key, prev.text.length)
normalize = true
} else if (next) {
selection = selection.moveEndTo(next.key, 0)
normalize = true
} else {
selection = selection.deselect()
}
}
if (normalize) {
selection = selection.normalize(document)
}
}
// Remove the node from the document.
let parent = document.getParent(node.key)
const index = parent.nodes.indexOf(node)
parent = parent.removeNode(index)
document = document.updateNode(parent)
// Update the document and selection.
state = state.set('document', document).set('selection', selection)
return state
},
/**
* Remove `text` at `offset` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
remove_text(state, operation) {
const { path, offset, text } = operation
const { length } = text
const rangeOffset = offset + length
let { document, selection } = state
const { anchorKey, focusKey, anchorOffset, focusOffset } = selection
let node = document.assertPath(path)
// Update the selection.
if (anchorKey == node.key && anchorOffset >= rangeOffset) {
selection = selection.moveAnchor(-length)
}
if (focusKey == node.key && focusOffset >= rangeOffset) {
selection = selection.moveFocus(-length)
}
node = node.removeText(offset, length)
document = document.updateNode(node)
state = state.set('document', document).set('selection', selection)
return state
},
/**
* Set `data` on `state`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
set_data(state, operation) {
const { properties } = operation
let { data } = state
data = data.merge(properties)
state = state.set('data', data)
return state
},
/**
* Set `properties` on mark on text at `offset` and `length` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
set_mark(state, operation) {
const { path, offset, length, properties } = operation
const mark = Normalize.mark(operation.mark)
let { document } = state
let node = document.assertPath(path)
node = node.updateMark(offset, length, mark, properties)
document = document.updateNode(node)
state = state.set('document', document)
return state
},
/**
* Set `properties` on a node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
set_node(state, operation) {
const { path, properties } = operation
let { document } = state
let node = document.assertPath(path)
// Warn when trying to overwite a node's children.
if (properties.nodes && properties.nodes != node.nodes) {
logger.warn('Updating a Node\'s `nodes` property via `setNode()` is not allowed. Use the appropriate insertion and removal operations instead. The opeartion in question was:', operation)
delete properties.nodes
}
// Warn when trying to change a node's key.
if (properties.key && properties.key != node.key) {
logger.warn('Updating a Node\'s `key` property via `setNode()` is not allowed. There should be no reason to do this. The opeartion in question was:', operation)
delete properties.key
}
node = node.merge(properties)
document = document.updateNode(node)
state = state.set('document', document)
return state
},
/**
* Set `properties` on the selection.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
set_selection(state, operation) {
const properties = { ...operation.properties }
let { document, selection } = state
if (properties.marks !== undefined) {
properties.marks = Normalize.marks(properties.marks)
}
if (properties.anchorPath !== undefined) {
properties.anchorKey = properties.anchorPath === null
? null
: document.assertPath(properties.anchorPath).key
delete properties.anchorPath
}
if (properties.focusPath !== undefined) {
properties.focusKey = properties.focusPath === null
? null
: document.assertPath(properties.focusPath).key
delete properties.focusPath
}
selection = selection.merge(properties)
selection = selection.normalize(document)
state = state.set('selection', selection)
return state
},
/**
* Split a node by `path` at `offset`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
split_node(state, operation) {
const { path, position } = operation
let { document, selection } = state
// Calculate a few things...
const node = document.assertPath(path)
let parent = document.getParent(node.key)
const index = parent.nodes.indexOf(node)
// Split the node by its parent.
parent = parent.splitNode(index, position)
document = document.updateNode(parent)
// Determine whether we need to update the selection...
const { startKey, endKey, startOffset, endOffset } = selection
const next = document.getNextText(node.key)
let normalize = false
// If the start point is after or equal to the split, update it.
if (node.key == startKey && position <= startOffset) {
selection = selection.moveStartTo(next.key, startOffset - position)
normalize = true
}
// If the end point is after or equal to the split, update it.
if (node.key == endKey && position <= endOffset) {
selection = selection.moveEndTo(next.key, endOffset - position)
normalize = true
}
// Normalize the selection if we changed it, since the methods we use might
// leave it in a non-normalized state.
if (normalize) {
selection = selection.normalize(document)
}
// Return the updated state.
state = state.set('document', document).set('selection', selection)
return state
},
}
/**
* Apply an `operation` to a `state`.
*
* @param {State} state
* @param {Object} operation
* @return {State} state
*/
function applyOperation(state, operation) {
const { type } = operation
const apply = APPLIERS[type]
if (!apply) {
throw new Error(`Unknown operation type: "${type}".`)
}
debug(type, operation)
state = apply(state, operation)
return state
}
/**
* Export.
*
* @type {Function}
*/
export default applyOperation

14
src/operations/index.js Normal file
View File

@@ -0,0 +1,14 @@
import apply from './apply'
import invert from './invert'
/**
* Export.
*
* @type {Object}
*/
export default {
apply,
invert,
}

199
src/operations/invert.js Normal file
View File

@@ -0,0 +1,199 @@
import Debug from 'debug'
import pick from 'lodash/pick'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:operation:invert')
/**
* Invert an `op`.
*
* @param {Object} op
* @return {Object}
*/
function invertOperation(op) {
const { type } = op
debug(type, op)
/**
* Insert node.
*/
if (type == 'insert_node') {
return {
...op,
type: 'remove_node',
}
}
/**
* Remove node.
*/
if (type == 'remove_node') {
return {
...op,
type: 'insert_node',
}
}
/**
* Move node.
*/
if (type == 'move_node') {
return {
...op,
path: op.newPath,
newPath: op.path,
}
}
/**
* Merge node.
*/
if (type == 'merge_node') {
const { path } = op
const { length } = path
const last = length - 1
return {
...op,
type: 'split_node',
path: path.slice(0, last).concat([path[last] - 1]),
}
}
/**
* Split node.
*/
if (type == 'split_node') {
const { path } = op
const { length } = path
const last = length - 1
return {
...op,
type: 'merge_node',
path: path.slice(0, last).concat([path[last] + 1]),
}
}
/**
* Set node.
*/
if (type == 'set_node') {
const { properties, node } = op
return {
...op,
node: node.merge(properties),
properties: pick(node, Object.keys(properties)),
}
}
/**
* Insert text.
*/
if (type == 'insert_text') {
return {
...op,
type: 'remove_text',
}
}
/**
* Remove text.
*/
if (type == 'remove_text') {
return {
...op,
type: 'insert_text',
}
}
/**
* Add mark.
*/
if (type == 'add_mark') {
return {
...op,
type: 'remove_mark',
}
}
/**
* Remove mark.
*/
if (type == 'remove_mark') {
return {
...op,
type: 'add_mark',
}
}
/**
* Set mark.
*/
if (type == 'set_mark') {
const { properties, mark } = op
return {
...op,
mark: mark.merge(properties),
properties: pick(mark, Object.keys(properties)),
}
}
/**
* Set selection.
*/
if (type == 'set_selection') {
const { properties, selection } = op
const inverse = {
...op,
selection: { ...selection, ...properties },
properties: pick(selection, Object.keys(properties)),
}
return inverse
}
/**
* Set data.
*/
if (type == 'set_data') {
const { properties, data } = op
return {
...op,
data: data.merge(properties),
properties: pick(data, Object.keys(properties)),
}
}
/**
* Unknown.
*/
throw new Error(`Unknown op type: "${type}".`)
}
/**
* Export.
*
* @type {Function}
*/
export default invertOperation

View File

@@ -40,28 +40,25 @@ function Plugin(options = {}) {
/**
* On before change, enforce the editor's schema.
*
* @param {State} state
* @param {Change} change
* @param {Editor} schema
* @return {State}
*/
function onBeforeChange(state, editor) {
// Don't normalize with plugins schema when typing text in native mode
if (state.isNative) return state
function onBeforeChange(change, editor) {
const { state } = change
const schema = editor.getSchema()
const prevState = editor.getState()
// Since schema can only normalize the document, we avoid creating
// a transform and normalize the selection if the document is the same
if (prevState && state.document == prevState.document) return state
// PERF: Skip normalizing if the change is native, since we know that it
// can't have changed anything that requires a core schema fix.
if (state.isNative) return
const newState = state.transform()
.normalize(schema)
.apply({ merge: true })
// PERF: Skip normalizing if the document hasn't changed, since the core
// schema only normalizes changes to the document, not selection.
if (prevState && state.document == prevState.document) return
change.normalize(schema)
debug('onBeforeChange')
return newState
}
/**
@@ -70,12 +67,12 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
* @param {Editor} editor
* @return {State}
*/
function onBeforeInput(e, data, state, editor) {
function onBeforeInput(e, data, change, editor) {
const { state } = change
const { document, startKey, startBlock, startOffset, startInline, startText } = state
const pText = startBlock.getPreviousText(startKey)
const pInline = pText && startBlock.getClosestInline(pText.key)
@@ -101,8 +98,6 @@ function Plugin(options = {}) {
const chars = initialChars.insert(startOffset, char)
let transform = state.transform()
// COMPAT: In iOS, when choosing from the predictive text suggestions, the
// native selection will be changed to span the existing word, so that the word
// is replaced. But the `select` event for this change doesn't fire until after
@@ -121,7 +116,7 @@ function Plugin(options = {}) {
selection.focusKey !== focusPoint.key ||
selection.focusOffset !== focusPoint.offset
) {
transform = transform
change = change
.select({
anchorKey: anchorPoint.key,
anchorOffset: anchorPoint.offset,
@@ -132,10 +127,8 @@ function Plugin(options = {}) {
}
// Determine what the characters should be, if not natively inserted.
let next = transform
.insertText(e.data)
.apply()
change.insertText(e.data)
const next = change.state
const nextText = next.startText
const nextChars = nextText.getDecorations(decorators)
@@ -163,7 +156,7 @@ function Plugin(options = {}) {
// have been automatically changed. So we can't render natively because
// the cursor isn't technique in the right spot. (2016/12/01)
(!(pInline && !pInline.isVoid && startOffset == 0)) &&
(!(nInline && !nInline.isVoid && startOffset == startText.length)) &&
(!(nInline && !nInline.isVoid && startOffset == startText.text.length)) &&
// COMPAT: When inserting a Space character, Chrome will sometimes
// split the text node into two adjacent text nodes. See:
// https://github.com/ianstormtaylor/slate/issues/938
@@ -172,16 +165,17 @@ function Plugin(options = {}) {
(chars.equals(nextChars))
)
// Add the `isNative` flag directly, so we don't have to re-transform.
// If `isNative`, set the flag on the change.
if (isNative) {
next = next.set('isNative', isNative)
change.setIsNative(true)
}
// If not native, prevent default so that the DOM remains untouched.
if (!isNative) e.preventDefault()
// Otherwise, prevent default so that the DOM remains untouched.
else {
e.preventDefault()
}
debug('onBeforeInput', { data, isNative })
return next
}
/**
@@ -189,16 +183,12 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onBlur(e, data, state) {
function onBlur(e, data, change) {
debug('onBlur', { data })
return state
.transform()
.blur()
.apply()
change.blur()
}
/**
@@ -206,13 +196,12 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onCopy(e, data, state) {
function onCopy(e, data, change) {
debug('onCopy', data)
onCutOrCopy(e, data, state)
onCutOrCopy(e, data, change)
}
/**
@@ -220,26 +209,19 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Change} change
* @param {Editor} editor
* @return {State}
*/
function onCut(e, data, state, editor) {
function onCut(e, data, change, editor) {
debug('onCut', data)
onCutOrCopy(e, data, state)
onCutOrCopy(e, data, change)
const window = getWindow(e.target)
// Once the fake cut content has successfully been added to the clipboard,
// delete the content in the current selection.
window.requestAnimationFrame(() => {
const next = editor
.getState()
.transform()
.delete()
.apply()
editor.onChange(next)
editor.change(t => t.delete())
})
}
@@ -249,13 +231,13 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onCutOrCopy(e, data, state) {
function onCutOrCopy(e, data, change) {
const window = getWindow(e.target)
const native = window.getSelection()
const { state } = change
const { endBlock, endInline } = state
const isVoidBlock = endBlock && endBlock.isVoid
const isVoidInline = endInline && endInline.isVoid
@@ -334,21 +316,20 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onDrop(e, data, state) {
function onDrop(e, data, change) {
debug('onDrop', { data })
switch (data.type) {
case 'text':
case 'html':
return onDropText(e, data, state)
return onDropText(e, data, change)
case 'fragment':
return onDropFragment(e, data, state)
return onDropFragment(e, data, change)
case 'node':
return onDropNode(e, data, state)
return onDropNode(e, data, change)
}
}
@@ -357,13 +338,13 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onDropNode(e, data, state) {
function onDropNode(e, data, change) {
debug('onDropNode', { data })
const { state } = change
const { selection } = state
let { node, target, isInternal } = data
@@ -379,23 +360,22 @@ function Plugin(options = {}) {
: 0 - selection.endOffset)
}
const transform = state.transform()
if (isInternal) transform.delete()
if (isInternal) {
change.delete()
}
if (Block.isBlock(node)) {
return transform
change
.select(target)
.insertBlock(node)
.removeNodeByKey(node.key)
.apply()
}
if (Inline.isInline(node)) {
return transform
change
.select(target)
.insertInline(node)
.removeNodeByKey(node.key)
.apply()
}
}
@@ -404,13 +384,13 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onDropFragment(e, data, state) {
function onDropFragment(e, data, change) {
debug('onDropFragment', { data })
const { state } = change
const { selection } = state
let { fragment, target, isInternal } = data
@@ -426,14 +406,13 @@ function Plugin(options = {}) {
: 0 - selection.endOffset)
}
const transform = state.transform()
if (isInternal) {
change.delete()
}
if (isInternal) transform.delete()
return transform
change
.select(target)
.insertFragment(fragment)
.apply()
}
/**
@@ -441,24 +420,24 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onDropText(e, data, state) {
function onDropText(e, data, change) {
debug('onDropText', { data })
const { text, target } = data
const { state } = change
const { document } = state
const transform = state
.transform()
.select(target)
const { text, target } = data
const { anchorKey } = target
let hasVoidParent = document.hasVoidParent(target.anchorKey)
change.select(target)
let hasVoidParent = document.hasVoidParent(anchorKey)
// Insert text into nearest text node
if (hasVoidParent) {
let node = document.getNode(target.anchorKey)
let node = document.getNode(anchorKey)
while (hasVoidParent) {
node = document.getNextText(node.key)
@@ -466,17 +445,15 @@ function Plugin(options = {}) {
hasVoidParent = document.hasVoidParent(node.key)
}
if (node) transform.collapseToStartOf(node)
if (node) change.collapseToStartOf(node)
}
text
.split('\n')
.forEach((line, i) => {
if (i > 0) transform.splitBlock()
transform.insertText(line)
if (i > 0) change.splitBlock()
change.insertText(line)
})
return transform.apply()
}
/**
@@ -484,26 +461,25 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDown(e, data, state) {
function onKeyDown(e, data, change) {
debug('onKeyDown', { data })
switch (data.key) {
case 'enter': return onKeyDownEnter(e, data, state)
case 'backspace': return onKeyDownBackspace(e, data, state)
case 'delete': return onKeyDownDelete(e, data, state)
case 'left': return onKeyDownLeft(e, data, state)
case 'right': return onKeyDownRight(e, data, state)
case 'up': return onKeyDownUp(e, data, state)
case 'down': return onKeyDownDown(e, data, state)
case 'd': return onKeyDownD(e, data, state)
case 'h': return onKeyDownH(e, data, state)
case 'k': return onKeyDownK(e, data, state)
case 'y': return onKeyDownY(e, data, state)
case 'z': return onKeyDownZ(e, data, state)
case 'enter': return onKeyDownEnter(e, data, change)
case 'backspace': return onKeyDownBackspace(e, data, change)
case 'delete': return onKeyDownDelete(e, data, change)
case 'left': return onKeyDownLeft(e, data, change)
case 'right': return onKeyDownRight(e, data, change)
case 'up': return onKeyDownUp(e, data, change)
case 'down': return onKeyDownDown(e, data, change)
case 'd': return onKeyDownD(e, data, change)
case 'h': return onKeyDownH(e, data, change)
case 'k': return onKeyDownK(e, data, change)
case 'y': return onKeyDownY(e, data, change)
case 'z': return onKeyDownZ(e, data, change)
}
}
@@ -512,11 +488,11 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownEnter(e, data, state) {
function onKeyDownEnter(e, data, change) {
const { state } = change
const { document, startKey } = state
const hasVoidParent = document.hasVoidParent(startKey)
@@ -525,16 +501,11 @@ function Plugin(options = {}) {
if (hasVoidParent) {
const text = document.getNextText(startKey)
if (!text) return
return state
.transform()
.collapseToStartOf(text)
.apply()
change.collapseToStartOf(text)
return
}
return state
.transform()
.splitBlock()
.apply()
change.splitBlock()
}
/**
@@ -542,19 +513,14 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownBackspace(e, data, state) {
function onKeyDownBackspace(e, data, change) {
let boundary = 'Char'
if (data.isWord) boundary = 'Word'
if (data.isLine) boundary = 'Line'
return state
.transform()
[`delete${boundary}Backward`]()
.apply()
change[`delete${boundary}Backward`]()
}
/**
@@ -562,19 +528,14 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownDelete(e, data, state) {
function onKeyDownDelete(e, data, change) {
let boundary = 'Char'
if (data.isWord) boundary = 'Word'
if (data.isLine) boundary = 'Line'
return state
.transform()
[`delete${boundary}Forward`]()
.apply()
change[`delete${boundary}Forward`]()
}
/**
@@ -589,11 +550,12 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownLeft(e, data, state) {
function onKeyDownLeft(e, data, change) {
const { state } = change
if (data.isCtrl) return
if (data.isAlt) return
if (state.isExpanded) return
@@ -618,18 +580,12 @@ function Plugin(options = {}) {
if (previousBlock === startBlock && previousInline && !previousInline.isVoid) {
const extendOrMove = data.isShift ? 'extend' : 'move'
return state
.transform()
.collapseToEndOf(previous)
[extendOrMove](-1)
.apply()
change.collapseToEndOf(previous)[extendOrMove](-1)
return
}
// Otherwise, move to the end of the previous node.
return state
.transform()
.collapseToEndOf(previous)
.apply()
change.collapseToEndOf(previous)
}
}
@@ -650,11 +606,12 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownRight(e, data, state) {
function onKeyDownRight(e, data, change) {
const { state } = change
if (data.isCtrl) return
if (data.isAlt) return
if (state.isExpanded) return
@@ -669,16 +626,12 @@ function Plugin(options = {}) {
const next = document.getNextText(startKey)
// If there's no next text node in the document, abort.
if (!next) return state
if (!next) return
// If the next text is inside a void node, move to the end of it.
const isInVoid = document.hasVoidParent(next.key)
if (isInVoid) {
return state
.transform()
.collapseToEndOf(next)
.apply()
if (document.hasVoidParent(next.key)) {
change.collapseToEndOf(next)
return
}
// If the next text is in the current block, and inside an inline node,
@@ -689,18 +642,12 @@ function Plugin(options = {}) {
if (nextBlock == startBlock && nextInline) {
const extendOrMove = data.isShift ? 'extend' : 'move'
return state
.transform()
.collapseToStartOf(next)
[extendOrMove](1)
.apply()
change.collapseToStartOf(next)[extendOrMove](1)
return
}
// Otherwise, move to the start of the next text node.
return state
.transform()
.collapseToStartOf(next)
.apply()
change.collapseToStartOf(next)
}
}
@@ -715,21 +662,18 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownUp(e, data, state) {
function onKeyDownUp(e, data, change) {
const { state } = change
const { selection, document, focusKey, focusBlock } = state
const previousBlock = document.getPreviousBlock(focusKey)
if (previousBlock && previousBlock.isVoid && !data.isAlt) {
const transform = data.isShift ? 'extendToStartOf' : 'collapseToStartOf'
e.preventDefault()
return state
.transform()
[transform](previousBlock)
.apply()
return change[transform](previousBlock)
}
if (!IS_MAC || data.isCtrl || !data.isAlt) return
@@ -743,10 +687,7 @@ function Plugin(options = {}) {
const text = block.getFirstText()
e.preventDefault()
return state
.transform()
[transform](text)
.apply()
change[transform](text)
}
/**
@@ -760,21 +701,18 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownDown(e, data, state) {
function onKeyDownDown(e, data, change) {
const { state } = change
const { selection, document, focusKey, focusBlock } = state
const nextBlock = document.getNextBlock(focusKey)
if (nextBlock && nextBlock.isVoid && !data.isAlt) {
const transform = data.isShift ? 'extendToStartOf' : 'collapseToStartOf'
e.preventDefault()
return state
.transform()
[transform](nextBlock)
.apply()
return change[transform](nextBlock)
}
if (!IS_MAC || data.isCtrl || !data.isAlt) return
@@ -788,10 +726,7 @@ function Plugin(options = {}) {
const text = block.getLastText()
e.preventDefault()
return state
.transform()
[transform](text)
.apply()
change[transform](text)
}
/**
@@ -799,17 +734,13 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownD(e, data, state) {
function onKeyDownD(e, data, change) {
if (!IS_MAC || !data.isCtrl) return
e.preventDefault()
return state
.transform()
.deleteCharForward()
.apply()
change.deleteCharForward()
}
/**
@@ -817,17 +748,13 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownH(e, data, state) {
function onKeyDownH(e, data, change) {
if (!IS_MAC || !data.isCtrl) return
e.preventDefault()
return state
.transform()
.deleteCharBackward()
.apply()
change.deleteCharBackward()
}
/**
@@ -835,17 +762,13 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownK(e, data, state) {
function onKeyDownK(e, data, change) {
if (!IS_MAC || !data.isCtrl) return
e.preventDefault()
return state
.transform()
.deleteLineForward()
.apply()
change.deleteLineForward()
}
/**
@@ -853,17 +776,12 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownY(e, data, state) {
function onKeyDownY(e, data, change) {
if (!data.isMod) return
return state
.transform()
.redo()
.apply({ save: false })
change.redo()
}
/**
@@ -871,17 +789,12 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onKeyDownZ(e, data, state) {
function onKeyDownZ(e, data, change) {
if (!data.isMod) return
return state
.transform()
[data.isShift ? 'redo' : 'undo']()
.apply({ save: false })
change[data.isShift ? 'redo' : 'undo']()
}
/**
@@ -889,19 +802,18 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onPaste(e, data, state) {
function onPaste(e, data, change) {
debug('onPaste', { data })
switch (data.type) {
case 'fragment':
return onPasteFragment(e, data, state)
return onPasteFragment(e, data, change)
case 'text':
case 'html':
return onPasteText(e, data, state)
return onPasteText(e, data, change)
}
}
@@ -910,17 +822,12 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onPasteFragment(e, data, state) {
function onPasteFragment(e, data, change) {
debug('onPasteFragment', { data })
return state
.transform()
.insertFragment(data.fragment)
.apply()
change.insertFragment(data.fragment)
}
/**
@@ -928,23 +835,15 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onPasteText(e, data, state) {
function onPasteText(e, data, change) {
debug('onPasteText', { data })
const transform = state.transform()
data.text
.split('\n')
.forEach((line, i) => {
if (i > 0) transform.splitBlock()
transform.insertText(line)
data.text.split('\n').forEach((line, i) => {
if (i > 0) change.splitBlock()
change.insertText(line)
})
return transform.apply()
}
/**
@@ -952,17 +851,12 @@ function Plugin(options = {}) {
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
* @param {Change} change
*/
function onSelect(e, data, state) {
function onSelect(e, data, change) {
debug('onSelect', { data })
return state
.transform()
.select(data.selection)
.apply()
change.select(data.selection)
}
/**
@@ -985,7 +879,6 @@ function Plugin(options = {}) {
onBeforeInput={editor.onBeforeInput}
onBlur={editor.onBlur}
onFocus={editor.onFocus}
onChange={editor.onChange}
onCopy={editor.onCopy}
onCut={editor.onCut}
onDrop={editor.onDrop}

View File

@@ -1,2 +1,2 @@
This directory contains the core schema that ships with Slate by default, which controls all of the "core" document and selection validation logic. For example, it ensures that two adjacent text nodes are always joined, or that the top-level document only ever contains block nodes. It is not exposed by default, since it is only needed internally.
This directory contains the core schema that ships with Slate by default, which controls all of the "core" document and selection validation logic. For example, it ensures that two adjacent text nodes are always mergeed, or that the top-level document only ever contains block nodes. It is not exposed by default, since it is only needed internally.

View File

@@ -33,15 +33,15 @@ const rules = [
const invalids = document.nodes.filter(n => n.kind != 'block')
return invalids.size ? invalids : null
},
normalize: (transform, document, invalids) => {
normalize: (change, document, invalids) => {
invalids.forEach((node) => {
transform.removeNodeByKey(node.key, OPTS)
change.removeNodeByKey(node.key, OPTS)
})
}
},
/**
* Only allow block, inline and text nodes in blocks.
* Only allow block nodes or inline and text nodes in blocks.
*
* @type {Object}
*/
@@ -51,15 +51,19 @@ const rules = [
return node.kind == 'block'
},
validate: (block) => {
const invalids = block.nodes.filter((n) => {
return n.kind != 'block' && n.kind != 'inline' && n.kind != 'text'
})
const first = block.nodes.first()
if (!first) return null
const kinds = first.kind == 'block'
? ['block']
: ['inline', 'text']
const invalids = block.nodes.filter(n => !kinds.includes(n.kind))
return invalids.size ? invalids : null
},
normalize: (transform, block, invalids) => {
normalize: (change, block, invalids) => {
invalids.forEach((node) => {
transform.removeNodeByKey(node.key, OPTS)
change.removeNodeByKey(node.key, OPTS)
})
}
},
@@ -78,9 +82,9 @@ const rules = [
const invalids = inline.nodes.filter(n => n.kind != 'inline' && n.kind != 'text')
return invalids.size ? invalids : null
},
normalize: (transform, inline, invalids) => {
normalize: (change, inline, invalids) => {
invalids.forEach((node) => {
transform.removeNodeByKey(node.key, OPTS)
change.removeNodeByKey(node.key, OPTS)
})
}
},
@@ -98,9 +102,9 @@ const rules = [
validate: (node) => {
return node.nodes.size == 0
},
normalize: (transform, node) => {
normalize: (change, node) => {
const text = Text.create()
transform.insertNodeByKey(node.key, 0, text, OPTS)
change.insertNodeByKey(node.key, 0, text, OPTS)
}
},
@@ -120,14 +124,14 @@ const rules = [
validate: (node) => {
return node.text !== ' ' || node.nodes.size !== 1
},
normalize: (transform, node, result) => {
normalize: (change, node, result) => {
const text = Text.createFromString(' ')
const index = node.nodes.size
transform.insertNodeByKey(node.key, index, text, OPTS)
change.insertNodeByKey(node.key, index, text, OPTS)
node.nodes.forEach((child) => {
transform.removeNodeByKey(child.key, OPTS)
change.removeNodeByKey(child.key, OPTS)
})
}
},
@@ -151,16 +155,16 @@ const rules = [
const invalids = block.nodes.filter(n => n.kind == 'inline' && n.text == '')
return invalids.size ? invalids : null
},
normalize: (transform, block, invalids) => {
normalize: (change, block, invalids) => {
// If all of the block's nodes are invalid, insert an empty text node so
// that the selection will be preserved when they are all removed.
if (block.nodes.size == invalids.size) {
const text = Text.create()
transform.insertNodeByKey(block.key, 1, text, OPTS)
change.insertNodeByKey(block.key, 1, text, OPTS)
}
invalids.forEach((node) => {
transform.removeNodeByKey(node.key, OPTS)
change.removeNodeByKey(node.key, OPTS)
})
}
},
@@ -195,18 +199,18 @@ const rules = [
return invalids.size ? invalids : null
},
normalize: (transform, block, invalids) => {
normalize: (change, block, invalids) => {
// Shift for every text node inserted previously.
let shift = 0
invalids.forEach(({ index, insertAfter, insertBefore }) => {
if (insertBefore) {
transform.insertNodeByKey(block.key, shift + index, Text.create(), OPTS)
change.insertNodeByKey(block.key, shift + index, Text.create(), OPTS)
shift++
}
if (insertAfter) {
transform.insertNodeByKey(block.key, shift + index + 1, Text.create(), OPTS)
change.insertNodeByKey(block.key, shift + index + 1, Text.create(), OPTS)
shift++
}
})
@@ -214,7 +218,7 @@ const rules = [
},
/**
* Join adjacent text nodes.
* Merge adjacent text nodes.
*
* @type {Object}
*/
@@ -229,19 +233,16 @@ const rules = [
const next = node.nodes.get(i + 1)
if (child.kind != 'text') return
if (!next || next.kind != 'text') return
return [child, next]
return next
})
.filter(Boolean)
return invalids.size ? invalids : null
},
normalize: (transform, node, pairs) => {
// We reverse the list to handle consecutive joins, since the earlier nodes
// will always exist after each join.
pairs.reverse().forEach((pair) => {
const [ first, second ] = pair
return transform.joinNodeByKey(second.key, first.key, OPTS)
})
normalize: (change, node, invalids) => {
// Reverse the list to handle consecutive merges, since the earlier nodes
// will always exist after each merge.
invalids.reverse().forEach(n => change.mergeNodeByKey(n.key, OPTS))
}
},
@@ -261,7 +262,7 @@ const rules = [
const invalids = nodes.filter((desc, i) => {
if (desc.kind != 'text') return
if (desc.length > 0) return
if (desc.text.length > 0) return
const prev = i > 0 ? nodes.get(i - 1) : null
const next = nodes.get(i + 1)
@@ -281,9 +282,9 @@ const rules = [
return invalids.size ? invalids : null
},
normalize: (transform, node, invalids) => {
normalize: (change, node, invalids) => {
invalids.forEach((text) => {
transform.removeNodeByKey(text.key, OPTS)
change.removeNodeByKey(text.key, OPTS)
})
}
}

View File

@@ -4,6 +4,7 @@ import Character from '../models/character'
import Document from '../models/document'
import Inline from '../models/inline'
import Mark from '../models/mark'
import Node from '../models/node'
import Selection from '../models/selection'
import State from '../models/state'
import Text from '../models/text'
@@ -22,11 +23,12 @@ const Raw = {
*
* @param {Object} object
* @param {Object} options (optional)
* @return {Block}
* @return {State}
*/
deserialize(object, options) {
return Raw.deserializeState(object, options)
const state = Raw.deserializeState(object, options)
return state
},
/**
@@ -40,15 +42,16 @@ const Raw = {
deserializeBlock(object, options = {}) {
if (options.terse) object = Raw.untersifyBlock(object)
return Block.create({
const nodes = Node.createList(object.nodes.map(node => Raw.deserializeNode(node, options)))
const block = Block.create({
key: object.key,
type: object.type,
data: object.data,
isVoid: object.isVoid,
nodes: Block.createList(object.nodes.map((node) => {
return Raw.deserializeNode(node, options)
}))
nodes,
})
return block
},
/**
@@ -60,13 +63,14 @@ const Raw = {
*/
deserializeDocument(object, options) {
return Document.create({
const nodes = object.nodes.map(node => Raw.deserializeNode(node, options))
const document = Document.create({
key: object.key,
data: object.data,
nodes: Block.createList(object.nodes.map((node) => {
return Raw.deserializeNode(node, options)
}))
nodes,
})
return document
},
/**
@@ -80,15 +84,16 @@ const Raw = {
deserializeInline(object, options = {}) {
if (options.terse) object = Raw.untersifyInline(object)
return Inline.create({
const nodes = object.nodes.map(node => Raw.deserializeNode(node, options))
const inline = Inline.create({
key: object.key,
type: object.type,
data: object.data,
isVoid: object.isVoid,
nodes: Inline.createList(object.nodes.map((node) => {
return Raw.deserializeNode(node, options)
}))
nodes,
})
return inline
},
/**
@@ -100,7 +105,8 @@ const Raw = {
*/
deserializeMark(object, options) {
return Mark.create(object)
const mark = Mark.create(object)
return mark
},
/**
@@ -133,19 +139,10 @@ const Raw = {
deserializeRange(object, options = {}) {
if (options.terse) object = Raw.untersifyRange(object)
const marks = Mark.createSet(object.marks.map((mark) => {
return Raw.deserializeMark(mark, options)
}))
return Character.createList(object.text
.split('')
.map((char) => {
return Character.create({
text: char,
marks,
})
}))
const marks = Mark.createSet(object.marks.map(mark => Raw.deserializeMark(mark, options)))
const chars = object.text.split('')
const characters = Character.createList(chars.map(text => ({ text, marks })))
return characters
},
/**
@@ -157,13 +154,15 @@ const Raw = {
*/
deserializeSelection(object, options = {}) {
return Selection.create({
const selection = Selection.create({
anchorKey: object.anchorKey,
anchorOffset: object.anchorOffset,
focusKey: object.focusKey,
focusOffset: object.focusOffset,
isFocused: object.isFocused,
})
return selection
},
/**
@@ -198,12 +197,16 @@ const Raw = {
deserializeText(object, options = {}) {
if (options.terse) object = Raw.untersifyText(object)
return Text.create({
key: object.key,
characters: object.ranges.reduce((characters, range) => {
return characters.concat(Raw.deserializeRange(range, options))
const characters = object.ranges.reduce((list, range) => {
return list.concat(Raw.deserializeRange(range, options))
}, Character.createList())
const text = Text.create({
key: object.key,
characters,
})
return text
},
/**
@@ -215,7 +218,8 @@ const Raw = {
*/
serialize(model, options) {
return Raw.serializeState(model, options)
const raw = Raw.serializeState(model, options)
return raw
},
/**

View File

@@ -1,2 +0,0 @@
This directory contains all of the transforms that ship with Slate by default. For example, transforms like `insertText` or `addMarkAtRange`.

View File

@@ -1,501 +0,0 @@
import Debug from 'debug'
import warn from '../utils/warn'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:operation')
/**
* Transforms.
*
* @type {Object}
*/
const Transforms = {}
/**
* Operations.
*
* @type {Object}
*/
const OPERATIONS = {
// Text operations.
insert_text: insertText,
remove_text: removeText,
// Mark operations.
add_mark: addMark,
remove_mark: removeMark,
set_mark: setMark,
// Node operations.
insert_node: insertNode,
join_node: joinNode,
move_node: moveNode,
remove_node: removeNode,
set_node: setNode,
split_node: splitNode,
// Selection operations.
set_selection: setSelection,
// State data operations.
set_data: setData
}
/**
* Apply an `operation` to the current state.
*
* @param {Transform} transform
* @param {Object} operation
*/
Transforms.applyOperation = (transform, operation) => {
const { state, operations } = transform
const { type } = operation
const fn = OPERATIONS[type]
if (!fn) {
throw new Error(`Unknown operation type: "${type}".`)
}
debug(type, operation)
transform.state = fn(state, operation)
transform.operations = operations.concat([operation])
}
/**
* Add mark to text at `offset` and `length` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function addMark(state, operation) {
const { path, offset, length, mark } = operation
let { document } = state
let node = document.assertPath(path)
node = node.addMark(offset, length, mark)
document = document.updateDescendant(node)
state = state.set('document', document)
return state
}
/**
* Insert a `node` at `index` in a node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function insertNode(state, operation) {
const { path, index, node } = operation
let { document } = state
let parent = document.assertPath(path)
const isParent = document == parent
parent = parent.insertNode(index, node)
document = isParent ? parent : document.updateDescendant(parent)
state = state.set('document', document)
return state
}
/**
* Insert `text` at `offset` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function insertText(state, operation) {
const { path, offset, text, marks } = operation
let { document, selection } = state
const { anchorKey, focusKey, anchorOffset, focusOffset } = selection
let node = document.assertPath(path)
// Update the document
node = node.insertText(offset, text, marks)
document = document.updateDescendant(node)
// Update the selection
if (anchorKey == node.key && anchorOffset >= offset) {
selection = selection.moveAnchor(text.length)
}
if (focusKey == node.key && focusOffset >= offset) {
selection = selection.moveFocus(text.length)
}
state = state.set('document', document).set('selection', selection)
return state
}
/**
* Join a node by `path` with a node `withPath`.
*
* @param {State} state
* @param {Object} operation
* @param {Boolean} operation.deep (optional) Join recursively the
* respective last node and first node of the nodes' children. Like a zipper :)
* @return {State}
*/
function joinNode(state, operation) {
const { path, withPath, deep = false } = operation
let { document, selection } = state
const first = document.assertPath(withPath)
const second = document.assertPath(path)
document = document.joinNode(first, second, { deep })
// If the operation is deep, or the nodes are text nodes, it means we will be
// merging two text nodes together, so we need to update the selection.
if (deep || second.kind == 'text') {
const { anchorKey, anchorOffset, focusKey, focusOffset } = selection
const firstText = first.kind == 'text' ? first : first.getLastText()
const secondText = second.kind == 'text' ? second : second.getFirstText()
if (anchorKey == secondText.key) {
selection = selection.merge({
anchorKey: firstText.key,
anchorOffset: anchorOffset + firstText.characters.size
})
}
if (focusKey == secondText.key) {
selection = selection.merge({
focusKey: firstText.key,
focusOffset: focusOffset + firstText.characters.size
})
}
}
state = state.set('document', document).set('selection', selection)
return state
}
/**
* Move a node by `path` to a new parent by `path` and `index`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function moveNode(state, operation) {
const { path, newPath, newIndex } = operation
let { document } = state
const node = document.assertPath(path)
const index = path[path.length - 1]
const parentPath = path.slice(0, -1)
// Remove the node from its current parent
let parent = document.getParent(node.key)
parent = parent.removeNode(index)
document = parent.kind === 'document' ? parent : document.updateDescendant(parent)
// Check if `parent` is an anchestor of `target`
const isAncestor = parentPath.every((x, i) => x === newPath[i])
let target
// If `parent` is an ancestor of `target` and their paths have same length,
// then `parent` and `target` are equal.
if (isAncestor && parentPath.length === newPath.length) {
target = parent
}
// Else if `parent` is an ancestor of `target` and `node` index is less than
// the index of the `target` ancestor with the same depth of `node`,
// then removing `node` changes the path to `target`.
// So we have to adjust `newPath` before picking `target`.
else if (isAncestor && index < newPath[parentPath.length]) {
newPath[parentPath.length]--
target = document.assertPath(newPath)
}
// Else pick `target`
else {
target = document.assertPath(newPath)
}
// Insert the new node to its new parent
target = target.insertNode(newIndex, node)
document = target.kind === 'document' ? target : document.updateDescendant(target)
state = state.set('document', document)
return state
}
/**
* Remove mark from text at `offset` and `length` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function removeMark(state, operation) {
const { path, offset, length, mark } = operation
let { document } = state
let node = document.assertPath(path)
node = node.removeMark(offset, length, mark)
document = document.updateDescendant(node)
state = state.set('document', document)
return state
}
/**
* Remove a node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function removeNode(state, operation) {
const { path } = operation
let { document, selection } = state
const { startKey, endKey } = selection
const node = document.assertPath(path)
// If the selection is set, check to see if it needs to be updated.
if (selection.isSet) {
const hasStartNode = node.hasNode(startKey)
const hasEndNode = node.hasNode(endKey)
// If one of the selection's nodes is being removed, we need to update it.
if (hasStartNode) {
const prev = document.getPreviousText(startKey)
const next = document.getNextText(startKey)
if (prev) {
selection = selection.moveStartTo(prev.key, prev.length)
} else if (next) {
selection = selection.moveStartTo(next.key, 0)
} else {
selection = selection.deselect()
}
}
if (hasEndNode) {
const prev = document.getPreviousText(endKey)
const next = document.getNextText(endKey)
if (prev) {
selection = selection.moveEndTo(prev.key, prev.length)
} else if (next) {
selection = selection.moveEndTo(next.key, 0)
} else {
selection = selection.deselect()
}
}
}
// Remove the node from the document.
let parent = document.getParent(node.key)
const index = parent.nodes.indexOf(node)
const isParent = document == parent
parent = parent.removeNode(index)
document = isParent ? parent : document.updateDescendant(parent)
// Update the document and selection.
state = state.set('document', document).set('selection', selection)
return state
}
/**
* Remove text at `offset` and `length` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function removeText(state, operation) {
const { path, offset, length } = operation
const rangeOffset = offset + length
let { document, selection } = state
const { anchorKey, focusKey, anchorOffset, focusOffset } = selection
let node = document.assertPath(path)
// Update the selection
if (anchorKey == node.key && anchorOffset >= rangeOffset) {
selection = selection.moveAnchor(-length)
}
if (focusKey == node.key && focusOffset >= rangeOffset) {
selection = selection.moveFocus(-length)
}
node = node.removeText(offset, length)
document = document.updateDescendant(node)
state = state.set('document', document).set('selection', selection)
return state
}
/**
* Set `data` on `state`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function setData(state, operation) {
const { properties } = operation
let { data } = state
data = data.merge(properties)
state = state.set('data', data)
return state
}
/**
* Set `properties` on mark on text at `offset` and `length` in node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function setMark(state, operation) {
const { path, offset, length, mark, newMark } = operation
let { document } = state
let node = document.assertPath(path)
node = node.updateMark(offset, length, mark, newMark)
document = document.updateDescendant(node)
state = state.set('document', document)
return state
}
/**
* Set `properties` on a node by `path`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function setNode(state, operation) {
const { path, properties } = operation
let { document } = state
let node = document.assertPath(path)
// Deprecate the ability to overwite a node's children.
if (properties.nodes && properties.nodes != node.nodes) {
warn('Updating a Node\'s `nodes` property via `setNode()` is not allowed. Use the appropriate insertion and removal operations instead. The opeartion in question was:', operation)
delete properties.nodes
}
// Deprecate the ability to change a node's key.
if (properties.key && properties.key != node.key) {
warn('Updating a Node\'s `key` property via `setNode()` is not allowed. There should be no reason to do this. The opeartion in question was:', operation)
delete properties.key
}
node = node.merge(properties)
document = node.kind === 'document' ? node : document.updateDescendant(node)
state = state.set('document', document)
return state
}
/**
* Set `properties` on the selection.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
function setSelection(state, operation) {
const properties = { ...operation.properties }
let { document, selection } = state
if (properties.anchorPath !== undefined) {
properties.anchorKey = properties.anchorPath === null
? null
: document.assertPath(properties.anchorPath).key
delete properties.anchorPath
}
if (properties.focusPath !== undefined) {
properties.focusKey = properties.focusPath === null
? null
: document.assertPath(properties.focusPath).key
delete properties.focusPath
}
selection = selection.merge(properties)
selection = selection.normalize(document)
state = state.set('selection', selection)
return state
}
/**
* Split a node by `path` at `offset`.
*
* @param {State} state
* @param {Object} operation
* @param {Array} operation.path The path of the node to split
* @param {Number} operation.offset (optional) Split using a relative offset
* @param {Number} operation.count (optional) Split after `count`
* children. Cannot be used in combination with offset.
* @return {State}
*/
function splitNode(state, operation) {
const { path, offset, count } = operation
let { document, selection } = state
// If there's no offset, it's using the `count` instead.
if (offset == null) {
document = document.splitNodeAfter(path, count)
state = state.set('document', document)
return state
}
// Otherwise, split using the `offset`, but calculate a few things first.
const node = document.assertPath(path)
const text = node.kind == 'text' ? node : node.getTextAtOffset(offset)
const textOffset = node.kind == 'text' ? offset : offset - node.getOffset(text.key)
const { anchorKey, anchorOffset, focusKey, focusOffset } = selection
document = document.splitNode(path, offset)
// Determine whether we need to update the selection.
const splitAnchor = text.key == anchorKey && textOffset <= anchorOffset
const splitFocus = text.key == focusKey && textOffset <= focusOffset
// If either the anchor of focus was after the split, we need to update them.
if (splitFocus || splitAnchor) {
const nextText = document.getNextText(text.key)
if (splitAnchor) {
selection = selection.merge({
anchorKey: nextText.key,
anchorOffset: anchorOffset - textOffset
})
}
if (splitFocus) {
selection = selection.merge({
focusKey: nextText.key,
focusOffset: focusOffset - textOffset
})
}
}
state = state.set('document', document).set('selection', selection)
return state
}
/**
* Export.
*
* @type {Object}
*/
export default Transforms

View File

@@ -1,511 +0,0 @@
import Normalize from '../utils/normalize'
/**
* Transforms.
*
* @type {Object}
*/
const Transforms = {}
/**
* Add a `mark` to the characters in the current selection.
*
* @param {Transform} transform
* @param {Mark} mark
*/
Transforms.addMark = (transform, mark) => {
mark = Normalize.mark(mark)
const { state } = transform
const { document, selection } = state
if (selection.isExpanded) {
transform.addMarkAtRange(selection, mark)
return
}
if (selection.marks) {
const marks = selection.marks.add(mark)
const sel = selection.set('marks', marks)
transform.select(sel)
return
}
const marks = document.getActiveMarksAtRange(selection).add(mark)
const sel = selection.set('marks', marks)
transform.select(sel)
}
/**
* Delete at the current selection.
*
* @param {Transform} transform
*/
Transforms.delete = (transform) => {
const { state } = transform
const { selection } = state
if (selection.isCollapsed) return
transform
.snapshotSelection()
.deleteAtRange(selection)
// Ensure that the selection is collapsed to the start, because in certain
// cases when deleting across inline nodes this isn't guaranteed.
.collapseToStart()
.snapshotSelection()
}
/**
* Delete backward `n` characters at the current selection.
*
* @param {Transform} transform
* @param {Number} n (optional)
*/
Transforms.deleteBackward = (transform, n = 1) => {
const { state } = transform
const { selection } = state
transform.deleteBackwardAtRange(selection, n)
}
/**
* Delete backward until the character boundary at the current selection.
*
* @param {Transform} transform
*/
Transforms.deleteCharBackward = (transform) => {
const { state } = transform
const { selection } = state
transform.deleteCharBackwardAtRange(selection)
}
/**
* Delete backward until the line boundary at the current selection.
*
* @param {Transform} transform
*/
Transforms.deleteLineBackward = (transform) => {
const { state } = transform
const { selection } = state
transform.deleteLineBackwardAtRange(selection)
}
/**
* Delete backward until the word boundary at the current selection.
*
* @param {Transform} transform
*/
Transforms.deleteWordBackward = (transform) => {
const { state } = transform
const { selection } = state
transform.deleteWordBackwardAtRange(selection)
}
/**
* Delete forward `n` characters at the current selection.
*
* @param {Transform} transform
* @param {Number} n (optional)
*/
Transforms.deleteForward = (transform, n = 1) => {
const { state } = transform
const { selection } = state
transform.deleteForwardAtRange(selection, n)
}
/**
* Delete forward until the character boundary at the current selection.
*
* @param {Transform} transform
*/
Transforms.deleteCharForward = (transform) => {
const { state } = transform
const { selection } = state
transform.deleteCharForwardAtRange(selection)
}
/**
* Delete forward until the line boundary at the current selection.
*
* @param {Transform} transform
*/
Transforms.deleteLineForward = (transform) => {
const { state } = transform
const { selection } = state
transform.deleteLineForwardAtRange(selection)
}
/**
* Delete forward until the word boundary at the current selection.
*
* @param {Transform} transform
*/
Transforms.deleteWordForward = (transform) => {
const { state } = transform
const { selection } = state
transform.deleteWordForwardAtRange(selection)
}
/**
* Insert a `block` at the current selection.
*
* @param {Transform} transform
* @param {String|Object|Block} block
*/
Transforms.insertBlock = (transform, block) => {
block = Normalize.block(block)
const { state } = transform
const { selection } = state
transform.insertBlockAtRange(selection, block)
// If the node was successfully inserted, update the selection.
const node = transform.state.document.getNode(block.key)
if (node) transform.collapseToEndOf(node)
}
/**
* Insert a `fragment` at the current selection.
*
* @param {Transform} transform
* @param {Document} fragment
*/
Transforms.insertFragment = (transform, fragment) => {
let { state } = transform
let { document, selection } = state
if (!fragment.nodes.size) return
const { startText, endText } = state
const lastText = fragment.getLastText()
const lastInline = fragment.getClosestInline(lastText.key)
const keys = document.getTexts().map(text => text.key)
const isAppending = (
selection.hasEdgeAtEndOf(endText) ||
selection.hasEdgeAtStartOf(startText)
)
transform.deselect()
transform.insertFragmentAtRange(selection, fragment)
state = transform.state
document = state.document
const newTexts = document.getTexts().filter(n => !keys.includes(n.key))
const newText = isAppending ? newTexts.last() : newTexts.takeLast(2).first()
let after
if (newText && lastInline) {
after = selection.collapseToEndOf(newText)
}
else if (newText) {
after = selection
.collapseToStartOf(newText)
.move(lastText.length)
}
else {
after = selection
.collapseToStart()
.move(lastText.length)
}
transform.select(after)
}
/**
* Insert a `inline` at the current selection.
*
* @param {Transform} transform
* @param {String|Object|Block} inline
*/
Transforms.insertInline = (transform, inline) => {
inline = Normalize.inline(inline)
const { state } = transform
const { selection } = state
transform.insertInlineAtRange(selection, inline)
// If the node was successfully inserted, update the selection.
const node = transform.state.document.getNode(inline.key)
if (node) transform.collapseToEndOf(node)
}
/**
* Insert a `text` string at the current selection.
*
* @param {Transform} transform
* @param {String} text
* @param {Set<Mark>} marks (optional)
*/
Transforms.insertText = (transform, text, marks) => {
const { state } = transform
const { document, selection } = state
marks = marks || selection.marks
transform.insertTextAtRange(selection, text, marks)
// If the text was successfully inserted, and the selection had marks on it,
// unset the selection's marks.
if (selection.marks && document != transform.state.document) {
transform.select({ marks: null })
}
}
/**
* Set `properties` of the block nodes in the current selection.
*
* @param {Transform} transform
* @param {Object} properties
*/
Transforms.setBlock = (transform, properties) => {
const { state } = transform
const { selection } = state
transform.setBlockAtRange(selection, properties)
}
/**
* Set `properties` of the inline nodes in the current selection.
*
* @param {Transform} transform
* @param {Object} properties
*/
Transforms.setInline = (transform, properties) => {
const { state } = transform
const { selection } = state
transform.setInlineAtRange(selection, properties)
}
/**
* Split the block node at the current selection, to optional `depth`.
*
* @param {Transform} transform
* @param {Number} depth (optional)
*/
Transforms.splitBlock = (transform, depth = 1) => {
const { state } = transform
const { selection } = state
transform
.snapshotSelection()
.splitBlockAtRange(selection, depth)
.collapseToEnd()
.snapshotSelection()
}
/**
* Split the inline nodes at the current selection, to optional `depth`.
*
* @param {Transform} transform
* @param {Number} depth (optional)
*/
Transforms.splitInline = (transform, depth = Infinity) => {
const { state } = transform
const { selection } = state
transform
.snapshotSelection()
.splitInlineAtRange(selection, depth)
.snapshotSelection()
}
/**
* Remove a `mark` from the characters in the current selection.
*
* @param {Transform} transform
* @param {Mark} mark
*/
Transforms.removeMark = (transform, mark) => {
mark = Normalize.mark(mark)
const { state } = transform
const { document, selection } = state
if (selection.isExpanded) {
transform.removeMarkAtRange(selection, mark)
return
}
if (selection.marks) {
const marks = selection.marks.remove(mark)
const sel = selection.set('marks', marks)
transform.select(sel)
return
}
const marks = document.getActiveMarksAtRange(selection).remove(mark)
const sel = selection.set('marks', marks)
transform.select(sel)
}
/**
* Add or remove a `mark` from the characters in the current selection,
* depending on whether it's already there.
*
* @param {Transform} transform
* @param {Mark} mark
*/
Transforms.toggleMark = (transform, mark) => {
mark = Normalize.mark(mark)
const { state } = transform
const exists = state.activeMarks.some(m => m.equals(mark))
if (exists) {
transform.removeMark(mark)
} else {
transform.addMark(mark)
}
}
/**
* Unwrap the current selection from a block parent with `properties`.
*
* @param {Transform} transform
* @param {Object|String} properties
*/
Transforms.unwrapBlock = (transform, properties) => {
const { state } = transform
const { selection } = state
transform.unwrapBlockAtRange(selection, properties)
}
/**
* Unwrap the current selection from an inline parent with `properties`.
*
* @param {Transform} transform
* @param {Object|String} properties
*/
Transforms.unwrapInline = (transform, properties) => {
const { state } = transform
const { selection } = state
transform.unwrapInlineAtRange(selection, properties)
}
/**
* Wrap the block nodes in the current selection with a new block node with
* `properties`.
*
* @param {Transform} transform
* @param {Object|String} properties
*/
Transforms.wrapBlock = (transform, properties) => {
const { state } = transform
const { selection } = state
transform.wrapBlockAtRange(selection, properties)
}
/**
* Wrap the current selection in new inline nodes with `properties`.
*
* @param {Transform} transform
* @param {Object|String} properties
*/
Transforms.wrapInline = (transform, properties) => {
let { state } = transform
let { document, selection } = state
let after
const { startKey } = selection
transform.deselect()
transform.wrapInlineAtRange(selection, properties)
state = transform.state
document = state.document
// Determine what the selection should be after wrapping.
if (selection.isCollapsed) {
after = selection
}
else if (selection.startOffset == 0) {
// Find the inline that has been inserted.
// We want to handle multiple wrap, so we need to take the highest parent
const inline = document.getAncestors(startKey)
.find(parent => (
parent.kind == 'inline' &&
parent.getOffset(startKey) == 0
))
const start = inline ? document.getPreviousText(inline.getFirstText().key) : document.getFirstText()
const end = document.getNextText(inline ? inline.getLastText().key : start.key)
// Move selection to wrap around the inline
after = selection
.moveAnchorToEndOf(start)
.moveFocusToStartOf(end)
}
else if (selection.startKey == selection.endKey) {
const text = document.getNextText(selection.startKey)
after = selection.moveToRangeOf(text)
}
else {
const anchor = document.getNextText(selection.anchorKey)
const focus = document.getDescendant(selection.focusKey)
after = selection.merge({
anchorKey: anchor.key,
anchorOffset: 0,
focusKey: focus.key,
focusOffset: selection.focusOffset
})
}
after = after.normalize(document)
transform.select(after)
}
/**
* Wrap the current selection with prefix/suffix.
*
* @param {Transform} transform
* @param {String} prefix
* @param {String} suffix
*/
Transforms.wrapText = (transform, prefix, suffix = prefix) => {
const { state } = transform
const { selection } = state
transform.wrapTextAtRange(selection, prefix, suffix)
// If the selection was collapsed, it will have moved the start offset too.
if (selection.isCollapsed) {
transform.moveStart(0 - prefix.length)
}
// Adding the suffix will have pushed the end of the selection further on, so
// we need to move it back to account for this.
transform.moveEnd(0 - suffix.length)
// There's a chance that the selection points moved "through" each other,
// resulting in a now-incorrect selection direction.
if (selection.isForward != transform.state.selection.isForward) {
transform.flip()
}
}
/**
* Export.
*
* @type {Object}
*/
export default Transforms

View File

@@ -1,30 +0,0 @@
/**
* Transforms.
*
* @type {Object}
*/
const Transforms = {}
/**
* Call a `fn` as if it was a core transform. This is a convenience method to
* make using non-core transforms easier to read and chain.
*
* @param {Transform} transform
* @param {Function} fn
* @param {Mixed} ...args
*/
Transforms.call = (transform, fn, ...args) => {
fn(transform, ...args)
return
}
/**
* Export.
*
* @type {Object}
*/
export default Transforms

View File

@@ -1,122 +0,0 @@
/**
* Transforms.
*
* @type {Object}
*/
const Transforms = {}
/**
* Redo to the next state in the history.
*
* @param {Transform} transform
*/
Transforms.redo = (transform) => {
let { state } = transform
let { history } = state
let { undos, redos } = history
// If there's no next snapshot, abort.
const next = redos.peek()
if (!next) return
// Shift the next state into the undo stack.
redos = redos.pop()
undos = undos.push(next)
// Replay the next operations.
next.forEach((op) => {
transform.applyOperation(op)
})
// Update the history.
state = transform.state
history = history.set('undos', undos).set('redos', redos)
state = state.set('history', history)
// Update the transform.
transform.state = state
}
/**
* Save the operations into the history.
*
* @param {Transform} transform
* @param {Object} options
*/
Transforms.save = (transform, options = {}) => {
const { merge = false } = options
let { state, operations } = transform
let { history } = state
let { undos, redos } = history
let previous = undos.peek()
// If there are no operations, abort.
if (!operations.length) return
// Create a new save point or merge the operations into the previous one.
if (merge && previous) {
undos = undos.pop()
previous = previous.concat(operations)
undos = undos.push(previous)
} else {
undos = undos.push(operations)
}
// Clear the redo stack and constrain the undos stack.
if (undos.size > 100) undos = undos.take(100)
redos = redos.clear()
// Update the state.
history = history.set('undos', undos).set('redos', redos)
state = state.set('history', history)
// Update the transform.
transform.state = state
}
/**
* Undo the previous operations in the history.
*
* @param {Transform} transform
*/
Transforms.undo = (transform) => {
let { state } = transform
let { history } = state
let { undos, redos } = history
// If there's no previous snapshot, abort.
const previous = undos.peek()
if (!previous) return
// Shift the previous operations into the redo stack.
undos = undos.pop()
redos = redos.push(previous)
// Replay the inverse of the previous operations.
previous.slice().reverse().forEach((op) => {
op.inverse.forEach((inv) => {
transform.applyOperation(inv)
})
})
// Update the history.
state = transform.state
history = history.set('undos', undos).set('redos', redos)
state = state.set('history', history)
// Update the transform.
transform.state = state
}
/**
* Export.
*
* @type {Object}
*/
export default Transforms

View File

@@ -1,233 +0,0 @@
import warn from '../utils/warn'
/**
* Transforms.
*
* @type {Object}
*/
const Transforms = {}
/**
* Set `properties` on the selection.
*
* @param {Transform} transform
* @param {Object} properties
*/
Transforms.select = (transform, properties) => {
transform.setSelectionOperation(properties)
}
/**
* Selects the whole selection.
*
* @param {Transform} transform
* @param {Object} properties
*/
Transforms.selectAll = (transform) => {
const { state } = transform
const { document, selection } = state
const next = selection.moveToRangeOf(document)
transform.setSelectionOperation(next)
}
/**
* Snapshot the current selection.
*
* @param {Transform} transform
*/
Transforms.snapshotSelection = (transform) => {
const { state } = transform
const { selection } = state
transform.setSelectionOperation(selection, { snapshot: true })
}
/**
* Set `properties` on the selection.
*
* @param {Mixed} ...args
* @param {Transform} transform
*/
Transforms.moveTo = (transform, properties) => {
warn('The `moveTo()` transform is deprecated, please use `select()` instead.')
transform.select(properties)
}
/**
* Unset the selection's marks.
*
* @param {Transform} transform
*/
Transforms.unsetMarks = (transform) => {
warn('The `unsetMarks()` transform is deprecated.')
transform.setSelectionOperation({ marks: null })
}
/**
* Unset the selection, removing an association to a node.
*
* @param {Transform} transform
*/
Transforms.unsetSelection = (transform) => {
warn('The `unsetSelection()` transform is deprecated, please use `deselect()` instead.')
transform.setSelectionOperation({
anchorKey: null,
anchorOffset: 0,
focusKey: null,
focusOffset: 0,
isFocused: false,
isBackward: false
})
}
/**
* Mix in selection transforms that are just a proxy for the selection method.
*/
const PROXY_TRANSFORMS = [
'blur',
'collapseTo',
'collapseToAnchor',
'collapseToEnd',
'collapseToEndOf',
'collapseToFocus',
'collapseToStart',
'collapseToStartOf',
'extend',
'extendTo',
'extendToEndOf',
'extendToStartOf',
'flip',
'focus',
'move',
'moveAnchor',
'moveAnchorOffsetTo',
'moveAnchorTo',
'moveAnchorToEndOf',
'moveAnchorToStartOf',
'moveEnd',
'moveEndOffsetTo',
'moveEndTo',
'moveFocus',
'moveFocusOffsetTo',
'moveFocusTo',
'moveFocusToEndOf',
'moveFocusToStartOf',
'moveOffsetsTo',
'moveStart',
'moveStartOffsetTo',
'moveStartTo',
// 'moveTo', Commented out for now, since it conflicts with a deprecated one.
'moveToEnd',
'moveToEndOf',
'moveToRangeOf',
'moveToStart',
'moveToStartOf',
'deselect',
]
PROXY_TRANSFORMS.forEach((method) => {
Transforms[method] = (transform, ...args) => {
const normalize = method != 'deselect'
const { state } = transform
const { document, selection } = state
let next = selection[method](...args)
if (normalize) next = next.normalize(document)
transform.setSelectionOperation(next)
}
})
/**
* Mix in node-related transforms.
*/
const PREFIXES = [
'moveTo',
'collapseTo',
'extendTo',
]
const DIRECTIONS = [
'Next',
'Previous',
]
const KINDS = [
'Block',
'Inline',
'Text',
]
PREFIXES.forEach((prefix) => {
const edges = [
'Start',
'End',
]
if (prefix == 'moveTo') {
edges.push('Range')
}
edges.forEach((edge) => {
DIRECTIONS.forEach((direction) => {
KINDS.forEach((kind) => {
const get = `get${direction}${kind}`
const getAtRange = `get${kind}sAtRange`
const index = direction == 'Next' ? 'last' : 'first'
const method = `${prefix}${edge}Of`
const name = `${method}${direction}${kind}`
Transforms[name] = (transform) => {
const { state } = transform
const { document, selection } = state
const nodes = document[getAtRange](selection)
const node = nodes[index]()
const target = document[get](node.key)
if (!target) return
const next = selection[method](target)
transform.setSelectionOperation(next)
}
})
})
})
})
/**
* Mix in deprecated transforms with a warning.
*/
const DEPRECATED_TRANSFORMS = [
['extendBackward', 'extend', 'The `extendBackward(n)` transform is deprecated, please use `extend(n)` instead with a negative offset.'],
['extendForward', 'extend', 'The `extendForward(n)` transform is deprecated, please use `extend(n)` instead.'],
['moveBackward', 'move', 'The `moveBackward(n)` transform is deprecated, please use `move(n)` instead with a negative offset.'],
['moveForward', 'move', 'The `moveForward(n)` transform is deprecated, please use `move(n)` instead.'],
['moveStartOffset', 'moveStart', 'The `moveStartOffset(n)` transform is deprecated, please use `moveStart(n)` instead.'],
['moveEndOffset', 'moveEnd', 'The `moveEndOffset(n)` transform is deprecated, please use `moveEnd()` instead.'],
['moveToOffsets', 'moveOffsetsTo', 'The `moveToOffsets()` transform is deprecated, please use `moveOffsetsTo()` instead.'],
['flipSelection', 'flip', 'The `flipSelection()` transform is deprecated, please use `flip()` instead.'],
]
DEPRECATED_TRANSFORMS.forEach(([ old, current, warning ]) => {
Transforms[old] = (transform, ...args) => {
warn(warning)
const { state } = transform
const { document, selection } = state
const sel = selection[current](...args).normalize(document)
transform.setSelectionOperation(sel)
}
})
/**
* Export.
*
* @type {Object}
*/
export default Transforms

View File

@@ -1,532 +0,0 @@
import Normalize from '../utils/normalize'
/**
* Transforms.
*
* @type {Object}
*/
const Transforms = {}
/**
* Add mark to text at `offset` and `length` in node by `path`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Number} offset
* @param {Number} length
* @param {Mixed} mark
*/
Transforms.addMarkOperation = (transform, path, offset, length, mark) => {
const inverse = [{
type: 'remove_mark',
path,
offset,
length,
mark,
}]
const operation = {
type: 'add_mark',
path,
offset,
length,
mark,
inverse,
}
transform.applyOperation(operation)
}
/**
* Insert a `node` at `index` in a node by `path`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Number} index
* @param {Node} node
*/
Transforms.insertNodeOperation = (transform, path, index, node) => {
const inversePath = path.slice().concat([index])
const inverse = [{
type: 'remove_node',
path: inversePath,
}]
const operation = {
type: 'insert_node',
path,
index,
node,
inverse,
}
transform.applyOperation(operation)
}
/**
* Insert `text` at `offset` in node by `path`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Number} offset
* @param {String} text
* @param {Set<Mark>} marks (optional)
*/
Transforms.insertTextOperation = (transform, path, offset, text, marks) => {
const inverseLength = text.length
const inverse = [{
type: 'remove_text',
path,
offset,
length: inverseLength,
}]
const operation = {
type: 'insert_text',
path,
offset,
text,
marks,
inverse,
}
transform.applyOperation(operation)
}
/**
* Join a node by `path` with a node `withPath`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Array} withPath
*/
Transforms.joinNodeOperation = (transform, path, withPath) => {
const { state } = transform
const { document } = state
const node = document.assertPath(withPath)
let inverse
if (node.kind === 'text') {
const offset = node.length
inverse = [{
type: 'split_node',
path: withPath,
offset,
}]
} else {
// The number of children after which we split
const count = node.nodes.count()
inverse = [{
type: 'split_node',
path: withPath,
count,
}]
}
const operation = {
type: 'join_node',
path,
withPath,
inverse,
}
transform.applyOperation(operation)
}
/**
* Move a node by `path` to a `newPath` and `newIndex`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Array} newPath
* @param {Number} newIndex
*/
Transforms.moveNodeOperation = (transform, path, newPath, newIndex) => {
const parentPath = path.slice(0, -1)
const parentIndex = path[path.length - 1]
const inversePath = newPath.slice().concat([newIndex])
const inverse = [{
type: 'move_node',
path: inversePath,
newPath: parentPath,
newIndex: parentIndex,
}]
const operation = {
type: 'move_node',
path,
newPath,
newIndex,
inverse,
}
transform.applyOperation(operation)
}
/**
* Remove mark from text at `offset` and `length` in node by `path`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Number} offset
* @param {Number} length
* @param {Mark} mark
*/
Transforms.removeMarkOperation = (transform, path, offset, length, mark) => {
const inverse = [{
type: 'add_mark',
path,
offset,
length,
mark,
}]
const operation = {
type: 'remove_mark',
path,
offset,
length,
mark,
inverse,
}
transform.applyOperation(operation)
}
/**
* Remove a node by `path`.
*
* @param {Transform} transform
* @param {Array} path
*/
Transforms.removeNodeOperation = (transform, path) => {
const { state } = transform
const { document } = state
const node = document.assertPath(path)
const inversePath = path.slice(0, -1)
const inverseIndex = path[path.length - 1]
const inverse = [{
type: 'insert_node',
path: inversePath,
index: inverseIndex,
node,
}]
const operation = {
type: 'remove_node',
path,
inverse,
}
transform.applyOperation(operation)
}
/**
* Remove text at `offset` and `length` in node by `path`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Number} offset
* @param {Number} length
*/
Transforms.removeTextOperation = (transform, path, offset, length) => {
const { state } = transform
const { document } = state
const node = document.assertPath(path)
const ranges = node.getRanges()
const inverse = []
// Loop the ranges of text in the node, creating inverse insert operations for
// each of the ranges that overlap with the remove operation. This is
// necessary because insert's can only have a single set of marks associated
// with them, but removes can remove many.
ranges.reduce((start, range) => {
const { text, marks } = range
const end = start + text.length
if (start > offset + length) return end
if (end <= offset) return end
const endOffset = Math.min(end, offset + length)
const string = text.slice(offset - start, endOffset - start)
inverse.push({
type: 'insert_text',
path,
offset,
text: string,
marks,
})
return end
}, 0)
const operation = {
type: 'remove_text',
path,
offset,
length,
inverse,
}
transform.applyOperation(operation)
}
/**
* Merge `properties` into state `data`.
*
* @param {Transform} transform
* @param {Object} properties
*/
Transforms.setDataOperation = (transform, properties) => {
const { state } = transform
const { data } = state
const inverseProps = {}
for (const k in properties) {
inverseProps[k] = data[k]
}
const inverse = [{
type: 'set_data',
properties: inverseProps
}]
const operation = {
type: 'set_data',
properties,
inverse,
}
transform.applyOperation(operation)
}
/**
* Set `properties` on mark on text at `offset` and `length` in node by `path`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Number} offset
* @param {Number} length
* @param {Mark} mark
* @param {Mark} newMark
*/
Transforms.setMarkOperation = (transform, path, offset, length, mark, newMark) => {
const inverse = [{
type: 'set_mark',
path,
offset,
length,
mark: newMark,
newMark: mark
}]
const operation = {
type: 'set_mark',
path,
offset,
length,
mark,
newMark,
inverse,
}
transform.applyOperation(operation)
}
/**
* Set `properties` on a node by `path`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Object} properties
*/
Transforms.setNodeOperation = (transform, path, properties) => {
const { state } = transform
const { document } = state
const node = document.assertPath(path)
const inverseProps = {}
for (const k in properties) {
inverseProps[k] = node[k]
}
const inverse = [{
type: 'set_node',
path,
properties: inverseProps
}]
const operation = {
type: 'set_node',
path,
properties,
inverse,
}
transform.applyOperation(operation)
}
/**
* Set the selection to a new `selection`.
*
* @param {Transform} transform
* @param {Mixed} selection
*/
Transforms.setSelectionOperation = (transform, properties, options = {}) => {
properties = Normalize.selectionProperties(properties)
const { state } = transform
const { document, selection } = state
const prevProps = {}
const props = {}
// Remove any properties that are already equal to the current selection. And
// create a dictionary of the previous values for all of the properties that
// are being changed, for the inverse operation.
for (const k in properties) {
if (!options.snapshot && properties[k] == selection[k]) continue
props[k] = properties[k]
prevProps[k] = selection[k]
}
// If the selection moves, clear any marks, unless the new selection
// does change the marks in some way
const moved = [
'anchorKey',
'anchorOffset',
'focusKey',
'focusOffset',
].some(p => props.hasOwnProperty(p))
if (
selection.marks &&
properties.marks == selection.marks &&
moved
) {
props.marks = null
}
// Resolve the selection keys into paths.
if (props.anchorKey) {
props.anchorPath = document.getPath(props.anchorKey)
delete props.anchorKey
}
if (prevProps.anchorKey) {
prevProps.anchorPath = document.getPath(prevProps.anchorKey)
delete prevProps.anchorKey
}
if (props.focusKey) {
props.focusPath = document.getPath(props.focusKey)
delete props.focusKey
}
if (prevProps.focusKey) {
prevProps.focusPath = document.getPath(prevProps.focusKey)
delete prevProps.focusKey
}
// Define an inverse of the operation for undoing.
const inverse = [{
type: 'set_selection',
properties: prevProps
}]
// Define the operation.
const operation = {
type: 'set_selection',
properties: props,
inverse,
}
// Apply the operation.
transform.applyOperation(operation)
}
/**
* Split a node by `path` at `offset`.
*
* @param {Transform} transform
* @param {Array} path
* @param {Number} offset
*/
Transforms.splitNodeAtOffsetOperation = (transform, path, offset) => {
const inversePath = path.slice()
inversePath[path.length - 1] += 1
const inverse = [{
type: 'join_node',
path: inversePath,
withPath: path,
// We will split down to the text nodes, so we must join nodes recursively.
deep: true
}]
const operation = {
type: 'split_node',
path,
offset,
count: null,
inverse,
}
transform.applyOperation(operation)
}
/**
* Split a node by `path` after its 'count' child.
*
* @param {Transform} transform
* @param {Array} path
* @param {Number} count
*/
Transforms.splitNodeOperation = (transform, path, count) => {
const inversePath = path.slice()
inversePath[path.length - 1] += 1
const inverse = [{
type: 'join_node',
path: inversePath,
withPath: path,
deep: false
}]
const operation = {
type: 'split_node',
path,
offset: null,
count,
inverse,
}
transform.applyOperation(operation)
}
/**
* Export.
*
* @type {Object}
*/
export default Transforms

Some files were not shown because too many files have changed in this diff Show More