1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-02-01 05:16:10 +01:00

Fix normalizing dirty paths (#2222)

#### Is this adding or improving a _feature_ or fixing a _bug_?

Fix.

#### What's the new behavior?

The dirty paths in a change are now transformed against incoming operations, such that they don't get out of sync as normalizations occur. This is a rough pass to get correctness and the bug fixed, and we can later optimize lots of the little details for performance.

#### Have you checked that...?

<!-- 
Please run through this checklist for your pull request: 
-->

* [x] The new code matches the existing patterns and styles.
* [x] The tests pass with `yarn test`.
* [x] The linter passes with `yarn lint`. (Fix errors with `yarn prettier`.)
* [x] The relevant examples still work. (Run examples with `yarn watch`.)

#### Does this fix any issues or need any specific reviewers?

Fixes: #2211
Fixes: #2215
Fixes: #2194
This commit is contained in:
Ian Storm Taylor 2018-10-02 15:57:48 -07:00 committed by GitHub
parent 9ed08c1544
commit d84f5072c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 191 additions and 103 deletions

View File

@ -39,7 +39,7 @@ export const output = {
nodes: [
{
object: 'text',
key: '11',
key: '13',
leaves: [
{
object: 'leaf',
@ -69,7 +69,7 @@ export const output = {
},
{
object: 'text',
key: '12',
key: '14',
leaves: [
{
object: 'leaf',
@ -88,7 +88,7 @@ export const output = {
nodes: [
{
object: 'text',
key: '13',
key: '11',
leaves: [
{
object: 'leaf',
@ -118,7 +118,7 @@ export const output = {
},
{
object: 'text',
key: '14',
key: '12',
leaves: [
{
object: 'leaf',

View File

@ -1,7 +1,7 @@
import Debug from 'debug'
import isPlainObject from 'is-plain-object'
import warning from 'slate-dev-warning'
import { List, Map } from 'immutable'
import { List } from 'immutable'
import Changes from '../changes'
import Operation from './operation'
@ -55,7 +55,6 @@ class Change {
const { operations } = this
let { value } = this
let { history } = value
const oldValue = value
// Add in the current `value` in case the operation was serialized.
if (isPlainObject(operation)) {
@ -84,9 +83,16 @@ class Change {
value = value.set('history', history)
}
// Get the keys of the affected nodes, and mark them as dirty.
const keys = getDirtyKeys(operation, value, oldValue)
this.tmp.dirty = this.tmp.dirty.concat(keys)
// Get the paths of the affected nodes, and mark them as dirty.
const newDirtyPaths = getDirtyPaths(operation)
const dirty = this.tmp.dirty.reduce((memo, path) => {
path = PathUtils.create(path)
const transformed = PathUtils.transform(path, operation)
memo = memo.concat(transformed.toArray())
return memo
}, newDirtyPaths)
this.tmp.dirty = dirty
// Update the mutable change object.
this.value = value
@ -117,7 +123,7 @@ class Change {
call(fn, ...args) {
fn(this, ...args)
this.normalizeDirtyOperations()
this.normalizeDirtyPaths()
return this
}
@ -130,75 +136,29 @@ class Change {
normalize() {
const { value } = this
const { document } = value
const keys = Object.keys(document.getKeysToPathsTable())
this.normalizeKeys(keys)
return this
}
/**
* Normalize any new "dirty" operations that have been added to the change.
*
* @return {Change}
*/
normalizeDirtyOperations() {
const { normalize, dirty } = this.tmp
if (!normalize) return this
if (!dirty.length) return this
this.tmp.dirty = []
this.normalizeKeys(dirty)
return this
}
/**
* Normalize a set of nodes by their `keys`.
*
* @param {Array} keys
* @return {Change}
*/
normalizeKeys(keys) {
const { value } = this
const { document } = value
// TODO: if we had an `Operations.tranform` method, we could optimize this
// to not use keys, and instead used transformed operation paths.
const table = document.getKeysToPathsTable()
let map = Map()
// TODO: this could be optimized to not need the nested map, and instead use
// clever sorting to arrive at the proper depth-first normalizing.
keys.forEach(key => {
const path = table[key]
if (!path) return
if (!path.length) return
if (!map.hasIn(path)) map = map.setIn(path, Map())
})
// To avoid infinite loops, we need to defer normalization until the end.
this.withoutNormalizing(() => {
this.normalizeMapAndPath(map)
})
const paths = Object.values(table).map(PathUtils.create)
this.tmp.dirty = this.tmp.dirty.concat(paths)
this.normalizeDirtyPaths()
return this
}
/**
* Normalize all of the nodes in a normalization `map`, depth-first. An
* additional `path` argument specifics the current depth/location.
* Normalize any new "dirty" paths that have been added to the change.
*
* @param {Map} map
* @param {Array} path (optional)
* @return {Change}
*/
normalizeMapAndPath(map, path = []) {
map.forEach((m, k) => {
const p = [...path, k]
this.normalizeMapAndPath(m, p)
})
normalizeDirtyPaths() {
if (!this.tmp.normalize) {
return this
}
while (this.tmp.dirty.length) {
const path = this.tmp.dirty.pop()
this.normalizeNodeByPath(path)
}
this.normalizePath(path)
return this
}
@ -210,7 +170,7 @@ class Change {
* @return {Change}
*/
normalizePath(path) {
normalizeNodeByPath(path) {
const { value } = this
let { document, schema } = value
let node = document.assertNode(path)
@ -264,7 +224,10 @@ class Change {
iterate()
}
iterate()
this.withoutNormalizing(() => {
iterate()
})
return this
}
@ -281,11 +244,7 @@ class Change {
this.tmp.normalize = false
fn(this)
this.tmp.normalize = value
if (this.tmp.normalize) {
this.normalizeDirtyOperations()
}
this.normalizeDirtyPaths()
return this
}
@ -373,18 +332,14 @@ class Change {
}
/**
* Get the "dirty" nodes's keys for a given `operation` and values.
* Get the "dirty" paths for a given `operation`.
*
* @param {Operation} operation
* @param {Value} newValue
* @param {Value} oldValue
* @return {Array}
*/
function getDirtyKeys(operation, newValue, oldValue) {
function getDirtyPaths(operation) {
const { type, node, path, newPath } = operation
const newDocument = newValue.document
const oldDocument = oldValue.document
switch (type) {
case 'add_mark':
@ -393,46 +348,50 @@ function getDirtyKeys(operation, newValue, oldValue) {
case 'remove_text':
case 'set_mark':
case 'set_node': {
const target = newDocument.assertNode(path)
const keys = [target.key]
return keys
return [path]
}
case 'insert_node': {
const table = node.getKeysToPathsTable()
const keys = Object.keys(table)
return keys
const paths = Object.values(table).map(p => path.concat(p))
const parentPath = PathUtils.lift(path)
return [parentPath, path, ...paths]
}
case 'split_node': {
const parentPath = PathUtils.lift(path)
const nextPath = PathUtils.increment(path)
const target = newDocument.assertNode(path)
const split = newDocument.assertNode(nextPath)
const keys = [target.key, split.key]
return keys
return [parentPath, path, nextPath]
}
case 'merge_node': {
const parentPath = PathUtils.lift(path)
const previousPath = PathUtils.decrement(path)
const merged = newDocument.assertNode(previousPath)
const keys = [merged.key]
return keys
return [parentPath, previousPath]
}
case 'move_node': {
const parentPath = PathUtils.lift(path)
const newParentPath = PathUtils.lift(newPath)
const oldParent = oldDocument.assertNode(parentPath)
const newParent = oldDocument.assertNode(newParentPath)
const keys = [oldParent.key, newParent.key]
return keys
let parentPath = PathUtils.lift(path)
let newParentPath = PathUtils.lift(newPath)
// HACK: this clause only exists because the `move_path` logic isn't
// consistent when it deals with siblings.
if (!PathUtils.isSibling(path, newPath)) {
if (newParentPath.size && PathUtils.isYounger(path, newPath)) {
newParentPath = PathUtils.decrement(newParentPath, 1, path.size - 1)
}
if (parentPath.size && PathUtils.isYounger(newPath, path)) {
parentPath = PathUtils.increment(parentPath, 1, newPath.size - 1)
}
}
return [parentPath, newParentPath]
}
case 'remove_node': {
const parentPath = PathUtils.lift(path)
const parent = newDocument.assertNode(parentPath)
const keys = [parent.key]
return keys
return [parentPath]
}
default: {

View File

@ -143,6 +143,23 @@ function isEqual(path, target) {
return path.equals(target)
}
/**
* Is a `path` older than a `target` path? Meaning that it ends as an older
* sibling of one of the indexes in the target.
*
* @param {List} path
* @param {List} target
* @return {Boolean}
*/
function isOlder(path, target) {
const index = path.size - 1
const [p, t] = crop(path, target, index)
const pl = path.get(index)
const tl = target.get(index)
return isEqual(p, t) && pl > tl
}
/**
* Is a `path` a sibling of a `target` path?
*
@ -158,6 +175,23 @@ function isSibling(path, target) {
return p.equals(t)
}
/**
* Is a `path` younger than a `target` path? Meaning that it ends as a younger
* sibling of one of the indexes in the target.
*
* @param {List} path
* @param {List} target
* @return {Boolean}
*/
function isYounger(path, target) {
const index = path.size - 1
const [p, t] = crop(path, target, index)
const pl = path.get(index)
const tl = target.get(index)
return isEqual(p, t) && pl < tl
}
/**
* Lift a `path` to refer to its parent.
*
@ -222,6 +256,98 @@ function relate(a, b) {
return path
}
/**
* Transform a `path` by an `operation`, adjusting it to stay current.
*
* @param {List} path
* @param {Operation} operation
* @return {List<List>}
*/
function transform(path, operation) {
const { type, position, path: p } = operation
if (
type === 'add_mark' ||
type === 'insert_text' ||
type === 'remove_mark' ||
type === 'remove_text' ||
type === 'set_mark' ||
type === 'set_node' ||
type === 'set_selection' ||
type === 'set_value' ||
path.size === 0
) {
return List([path])
}
const pIndex = p.size - 1
const pEqual = isEqual(p, path)
const pYounger = isYounger(p, path)
const pAbove = isAbove(p, path)
if (type === 'insert_node') {
if (pEqual || pYounger || pAbove) {
path = increment(path, 1, pIndex)
}
}
if (type === 'remove_node') {
if (pYounger) {
path = decrement(path, 1, pIndex)
} else if (pEqual || pAbove) {
path = []
}
}
if (type === 'merge_node') {
if (pEqual || pYounger) {
path = decrement(path, 1, pIndex)
} else if (pAbove) {
path = decrement(path, 1, pIndex)
path = increment(path, position, pIndex + 1)
}
}
if (type === 'split_node') {
if (pEqual) {
path = [path, increment(path)]
} else if (pYounger) {
path = increment(path, 1, pIndex)
} else if (pAbove) {
if (path.get(pIndex + 1) >= position) {
path = increment(path, 1, pIndex)
path = decrement(path, position, pIndex + 1)
}
}
}
if (type === 'move_node') {
const { newPath: np } = operation
const npIndex = np.size - 1
const npEqual = isEqual(np, path)
const npYounger = isYounger(np, path)
const npAbove = isAbove(np, path)
if (pAbove) {
path = np.concat(path.slice(p.size))
} else {
if (pEqual) {
path = np
} else if (pYounger) {
path = decrement(path, 1, pIndex)
}
if (npEqual || npYounger || npAbove) {
path = increment(path, 1, npIndex)
}
}
}
const paths = Array.isArray(path) ? path : [path]
return List(paths)
}
/**
* Export.
*
@ -238,9 +364,12 @@ export default {
isAfter,
isBefore,
isEqual,
isOlder,
isSibling,
isYounger,
lift,
max,
min,
relate,
transform,
}