1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-12 18:24:03 +02:00

Characters to leaves (#1816)

* Preparing tests

* splitNode replacement

* Remove slow splice; replace mergeNode

* normalize Leaves

* Partially remove characters

* Partially remove characters

* styling

* Fix bugs; almost all characters are replaced

* Fixes almost existing tests; preparing adding tests for text

* Remove un-necessary check

* Empty leaf

* fix characters in getMarks

* Faster fromJSON

* Some corner cases for empty text

* Fix naive bug

* Supporting empty text with marks

* Supporting empty text with marks in hyperscript

* changes tests for marks in empty text

* Support splitNode marks with empty text

* Add tests for splitNode->insert

* Faster removeText

* Add warning ; remove getMarksAtIndex cache

* Remove characters in getInsertMarkAtRange

* Adding tests

* Change names of tests

* Update marks test

* Add a test confirm for invalid offsets:

* Add test for get active marks between offsets

* Fix document

* Add testing for insert-text

* Better remove text

* More sensible marks in empty text

* Allow marks of empty text after deleting partially marked text

* Add test for removing on partially marked text

* chnage test structure

* Add test for removeText

* Add test for removeText

* Avoid conflict between empty marked text and cursor

* Simple style fixes

* Simple style fixes

* Line break fixes

* Line break fixes

* Annotate the createLeaves

* Line breaks in test

* Line breaks fix

* add add-marks test

* add merge test

* Fix version update

* Remove empty_leaf optimization; optimize of that will be other PRs

* Clean up getMarksAtPosition

* Fix get-insert-marks-at-range

* clean up get-marks-at-position

* Fix spaces
This commit is contained in:
Jinxuan Zhu
2018-06-14 22:39:41 -04:00
committed by Ian Storm Taylor
parent c500becf81
commit cb3a9a5528
55 changed files with 1407 additions and 194 deletions

View File

@@ -273,7 +273,7 @@ function createChildren(children, options = {}) {
const firstNodeOrText = children.find(c => typeof c !== 'string')
const firstText = Text.isText(firstNodeOrText) ? firstNodeOrText : null
const key = options.key ? options.key : firstText ? firstText.key : undefined
let node = Text.create({ key })
let node = Text.create({ key, leaves: [{ text: '', marks: options.marks }] })
// Create a helper to update the current node while preserving any stored
// anchor or focus information.
@@ -290,10 +290,18 @@ function createChildren(children, options = {}) {
// If the child is a non-text node, push the current node and the new child
// onto the array, then creating a new node for future selection tracking.
if (Node.isNode(child) && !Text.isText(child)) {
if (node.text.length || node.__anchor != null || node.__focus != null)
if (
node.text.length ||
node.__anchor != null ||
node.__focus != null ||
node.getMarksAtIndex(0).size
) {
array.push(node)
}
array.push(child)
node = isLast ? null : Text.create()
node = isLast
? null
: Text.create({ leaves: [{ text: '', marks: options.marks }] })
length = 0
}

View File

@@ -0,0 +1,42 @@
/** @jsx h */
import h from '../..'
export const input = (
<document>
<block type="paragraph">
<mark type="bold" />
</block>
</document>
)
export const output = {
object: 'document',
data: {},
nodes: [
{
object: 'block',
type: 'paragraph',
isVoid: false,
data: {},
nodes: [
{
object: 'text',
leaves: [
{
object: 'leaf',
text: '',
marks: [
{
type: 'bold',
object: 'mark',
data: {},
},
],
},
],
},
],
},
],
}

View File

@@ -13,7 +13,7 @@ import Mark from './mark'
*/
const DEFAULTS = {
marks: new Set(),
marks: Set(),
text: '',
}
@@ -49,6 +49,115 @@ class Leaf extends Record(DEFAULTS) {
)
}
/**
* Create a valid List of `Leaf` from `leaves`
*
* @param {List<Leaf>} leaves
* @return {List<Leaf>}
*/
static createLeaves(leaves) {
if (leaves.size <= 1) return leaves
let invalid = false
// TODO: we can make this faster with [List] and then flatten
const result = List().withMutations(cache => {
// Search from the leaves left end to find invalid node;
leaves.findLast((leaf, index) => {
const firstLeaf = cache.first()
// If the first leaf of cache exist, check whether the first leaf is connectable with the current leaf
if (firstLeaf) {
// If marks equals, then the two leaves can be connected
if (firstLeaf.marks.equals(leaf.marks)) {
invalid = true
cache.set(0, firstLeaf.set('text', `${leaf.text}${firstLeaf.text}`))
return
}
// If the cached leaf is empty, drop the empty leaf with the upcoming leaf
if (firstLeaf.text === '') {
invalid = true
cache.set(0, leaf)
return
}
// If the current leaf is empty, drop the leaf
if (leaf.text === '') {
invalid = true
return
}
}
cache.unshift(leaf)
})
})
if (!invalid) return leaves
return result
}
/**
* Split a list of leaves to two lists; if the leaves are valid leaves, the returned leaves are also valid
* Corner Cases:
* 1. if offset is smaller than 0, then return [List(), leaves]
* 2. if offset is bigger than the text length, then return [leaves, List()]
*
* @param {List<Leaf> leaves
* @return {Array<List<Leaf>>}
*/
static splitLeaves(leaves, offset) {
if (offset < 0) return [List(), leaves]
if (leaves.size === 0) {
return [List(), List()]
}
let endOffset = 0
let index = -1
let left, right
leaves.find(leaf => {
index++
const startOffset = endOffset
const { text } = leaf
endOffset += text.length
if (endOffset < offset) return false
if (startOffset > offset) return false
const length = offset - startOffset
left = leaf.set('text', text.slice(0, length))
right = leaf.set('text', text.slice(length))
return true
})
if (!left) return [leaves, List()]
if (left.text === '') {
if (index === 0) {
return [List.of(left), leaves]
}
return [leaves.take(index), leaves.skip(index)]
}
if (right.text === '') {
if (index === leaves.size - 1) {
return [leaves, List.of(right)]
}
return [leaves.take(index + 1), leaves.skip(index + 1)]
}
return [
leaves.take(index).push(left),
leaves.skip(index + 1).unshift(right),
]
}
/**
* Create a `Leaf` list from `attrs`.
*
@@ -79,7 +188,7 @@ class Leaf extends Record(DEFAULTS) {
const leaf = new Leaf({
text,
marks: new Set(marks.map(Mark.fromJSON)),
marks: Set(marks.map(Mark.fromJSON)),
})
return leaf
@@ -136,6 +245,10 @@ class Leaf extends Record(DEFAULTS) {
*/
getCharacters() {
logger.deprecate(
'slate@0.34.0',
'The `characters` property of Slate objects is deprecated'
)
const { marks } = this
const characters = Character.createList(
this.text.split('').map(char => {
@@ -149,6 +262,48 @@ class Leaf extends Record(DEFAULTS) {
return characters
}
/**
* Update a `mark` at leaf, replace with newMark
*
* @param {Mark} mark
* @param {Mark} newMark
* @returns {Leaf}
*/
updateMark(mark, newMark) {
const { marks } = this
if (newMark.equals(mark)) return this
if (!marks.has(mark)) return this
const newMarks = marks.withMutations(collection => {
collection.remove(mark).add(newMark)
})
return this.set('marks', newMarks)
}
/**
* Add a `set` of marks at `index` and `length`.
*
* @param {Set<Mark>} set
* @returns {Text}
*/
addMarks(set) {
const { marks } = this
return this.set('marks', marks.union(set))
}
/**
* Remove a `mark` at `index` and `length`.
*
* @param {Mark} mark
* @returns {Text}
*/
removeMark(mark) {
const { marks } = this
return this.set('marks', marks.remove(mark))
}
/**
* Return a JSON representation of the leaf.
*

View File

@@ -63,7 +63,7 @@ class Mark extends Record(DEFAULTS) {
}
if (elements == null) {
return new Set()
return Set()
}
throw new Error(

View File

@@ -1036,11 +1036,9 @@ class Node {
return this.getMarksAtPosition(range.startKey, range.startOffset)
}
const text = this.getDescendant(range.startKey)
const char = text.characters.get(range.startOffset)
if (!char) return Set()
return char.marks
const { startKey, startOffset } = range
const text = this.getDescendant(startKey)
return text.getMarksAtIndex(startOffset + 1)
}
/**
@@ -1164,26 +1162,28 @@ class Node {
*
* @param {string} key
* @param {number} offset
* @return {OrderedSet}
* @return {Set}
*/
getMarksAtPosition(key, offset) {
if (offset == 0) {
const previous = this.getPreviousText(key)
if (!previous || previous.text.length == 0) return OrderedSet()
if (this.getClosestBlock(key) !== this.getClosestBlock(previous.key)) {
return OrderedSet()
}
const text = this.getDescendant(key)
const currentMarks = text.getMarksAtIndex(offset)
if (offset !== 0) return currentMarks
const closestBlock = this.getClosestBlock(key)
const char = previous.characters.last()
if (!char) return OrderedSet()
return new OrderedSet(char.marks)
if (closestBlock.text === '') {
// insert mark for empty block; the empty block are often created by split node or add marks in a range including empty blocks
return currentMarks
}
const text = this.getDescendant(key)
const char = text.characters.get(offset - 1)
if (!char) return OrderedSet()
return new OrderedSet(char.marks)
const previous = this.getPreviousText(key)
if (!previous) return Set()
if (closestBlock.hasDescendant(previous.key)) {
return previous.getMarksAtIndex(previous.text.length)
}
return currentMarks
}
/**
@@ -1857,10 +1857,9 @@ class Node {
)
}
// If the nodes are text nodes, concatenate their characters together.
// If the nodes are text nodes, concatenate their leaves together
if (one.object == 'text') {
const characters = one.characters.concat(two.characters)
one = one.set('characters', characters)
one = one.mergeText(two)
} else {
// Otherwise, concatenate their child nodes together.
const nodes = one.nodes.concat(two.nodes)
@@ -1942,7 +1941,7 @@ class Node {
throw new Error(`Could not find a descendant node with key "${key}".`)
const index = parent.nodes.findIndex(n => n.key === key)
const nodes = parent.nodes.splice(index, 1)
const nodes = parent.nodes.delete(index)
parent = parent.set('nodes', nodes)
node = node.updateNode(parent)
@@ -1957,7 +1956,7 @@ class Node {
*/
removeNode(index) {
const nodes = this.nodes.splice(index, 1)
const nodes = this.nodes.delete(index)
return this.set('nodes', nodes)
}
@@ -1978,10 +1977,7 @@ class Node {
// If the child is a text node, the `position` refers to the text offset at
// which to split it.
if (child.object == 'text') {
const befores = child.characters.take(position)
const afters = child.characters.skip(position)
one = child.set('characters', befores)
two = child.set('characters', afters).regenerateKey()
;[one, two] = child.splitText(position)
} else {
// Otherwise, if the child is not a text node, the `position` refers to the
// index at which to split its children.

View File

@@ -1,9 +1,7 @@
import isPlainObject from 'is-plain-object'
import logger from 'slate-dev-logger'
import { List, OrderedSet, Record, Set, is } from 'immutable'
import { List, OrderedSet, Record, Set } from 'immutable'
import Character from './character'
import Mark from './mark'
import Leaf from './leaf'
import MODEL_TYPES, { isType } from '../constants/model-types'
import generateKey from '../utils/generate-key'
@@ -16,7 +14,7 @@ import memoize from '../utils/memoize'
*/
const DEFAULTS = {
characters: new List(),
leaves: List(),
key: undefined,
}
@@ -87,14 +85,19 @@ class Text extends Record(DEFAULTS) {
return object
}
const { leaves = [], key = generateKey() } = object
const { key = generateKey() } = object
let { leaves = List() } = object
const characters = leaves
.map(Leaf.fromJSON)
.reduce((l, r) => l.concat(r.getCharacters()), new List())
if (Array.isArray(leaves)) {
leaves = List(leaves.map(x => Leaf.create(x)))
} else if (List.isList(leaves)) {
leaves = leaves.map(x => Leaf.create(x))
} else {
throw new Error('leaves must be either Array or Immutable.List')
}
const node = new Text({
characters,
leaves: Leaf.createLeaves(leaves),
key,
})
@@ -162,7 +165,61 @@ class Text extends Record(DEFAULTS) {
*/
get text() {
return this.characters.reduce((string, char) => string + char.text, '')
return this.getString()
}
/**
* Get the concatenated text of the node, cached for text getter
*
* @returns {String}
*/
getString() {
return this.leaves.reduce((string, leaf) => string + leaf.text, '')
}
/**
* Get the concatenated characters of the node;
*
* @returns {String}
*/
get characters() {
return this.leaves.flatMap(x => x.getCharacters())
}
/**
* Find the 'first' leaf at offset; By 'first' the alorighthm prefers `endOffset === offset` than `startOffset === offset`
* Corner Cases:
* 1. if offset is negative, return the first leaf;
* 2. if offset is larger than text length, the leaf is null, startOffset, endOffset and index is of the last leaf
*
* @param {number}
* @returns {Object}
* @property {number} startOffset
* @property {number} endOffset
* @property {number} index
* @property {Leaf} leaf
*/
searchLeafAtOffset(offset) {
let endOffset = 0
let startOffset = 0
let index = -1
const leaf = this.leaves.find(l => {
index++
startOffset = endOffset
endOffset = startOffset + l.text.length
return endOffset >= offset
})
return {
leaf,
endOffset,
index,
startOffset,
}
}
/**
@@ -175,12 +232,14 @@ class Text extends Record(DEFAULTS) {
*/
addMark(index, length, mark) {
const marks = new Set([mark])
const marks = Set.of(mark)
return this.addMarks(index, length, marks)
}
/**
* Add a `set` of marks at `index` and `length`.
* Corner Cases:
* 1. If empty text, and if length === 0 and index === 0
*
* @param {Number} index
* @param {Number} length
@@ -189,42 +248,30 @@ class Text extends Record(DEFAULTS) {
*/
addMarks(index, length, set) {
const characters = this.characters.map((char, i) => {
if (i < index) return char
if (i >= index + length) return char
let { marks } = char
marks = marks.union(set)
char = char.set('marks', marks)
return char
})
if (this.text === '' && length === 0 && index === 0) {
const { leaves } = this
const first = leaves.first()
return this.set('characters', characters)
}
if (!first) {
return this.set(
'leaves',
List.of(Leaf.fromJSON({ text: '', marks: set }))
)
}
/**
* Derive a set of decorated characters with `decorations`.
*
* @param {List<Decoration>} decorations
* @return {List<Character>}
*/
const newFirst = first.addMarks(set)
if (newFirst === first) return this
return this.set('leaves', List.of(newFirst))
}
getDecoratedCharacters(decorations) {
let node = this
const { key, characters } = node
if (this.text === '') return this
if (length === 0) return this
if (index >= this.text.length) return this
// PERF: Exit early if there are no characters to be decorated.
if (characters.size == 0) return characters
decorations.forEach(range => {
const { startKey, endKey, startOffset, endOffset, marks } = range
const hasStart = startKey == key
const hasEnd = endKey == key
const index = hasStart ? startOffset : 0
const length = hasEnd ? endOffset - index : characters.size
node = node.addMarks(index, length, marks)
})
return node.characters
const [before, bundle] = Leaf.splitLeaves(this.leaves, index)
const [middle, after] = Leaf.splitLeaves(bundle, length)
const leaves = before.concat(middle.map(x => x.addMarks(set)), after)
return this.setLeaves(leaves)
}
/**
@@ -239,82 +286,85 @@ class Text extends Record(DEFAULTS) {
}
/**
* Derive the leaves for a list of `characters`.
* Derive the leaves for a list of `decorations`.
*
* @param {Array|Void} decorations (optional)
* @return {List<Leaf>}
*/
getLeaves(decorations = []) {
const characters = this.getDecoratedCharacters(decorations)
let leaves = []
let { leaves } = this
if (leaves.size === 0) return List.of(Leaf.create({}))
if (!decorations || decorations.length === 0) return leaves
if (this.text.length === 0) return leaves
const { key } = this
// PERF: cache previous values for faster lookup.
let prevChar
let prevLeaf
decorations.forEach(range => {
const { startKey, endKey, startOffset, endOffset, marks } = range
const hasStart = startKey == key
const hasEnd = endKey == key
// If there are no characters, return one empty range.
if (characters.size == 0) {
leaves.push({})
} else {
// Otherwise, loop the characters and build the leaves...
characters.forEach((char, i) => {
const { marks, text } = char
if (hasStart && hasEnd) {
const index = hasStart ? startOffset : 0
const length = hasEnd ? endOffset - index : this.text.length - index
// The first one can always just be created.
if (i == 0) {
prevChar = char
prevLeaf = { text, marks }
leaves.push(prevLeaf)
if (length < 1) return
if (index >= this.text.length) return
if (index !== 0 || length < this.text.length) {
const [before, bundle] = Leaf.splitLeaves(this.leaves, index)
const [middle, after] = Leaf.splitLeaves(bundle, length)
leaves = before.concat(middle.map(x => x.addMarks(marks)), after)
return
}
}
// Otherwise, compare the current and previous marks.
const prevMarks = prevChar.marks
const isSame = is(marks, prevMarks)
leaves = leaves.map(x => x.addMarks(marks))
})
// If the marks are the same, add the text to the previous range.
if (isSame) {
prevChar = char
prevLeaf.text += text
return
}
// Otherwise, create a new range.
prevChar = char
prevLeaf = { text, marks }
leaves.push(prevLeaf)
}, [])
}
// PERF: convert the leaves to immutable objects after iterating.
leaves = new List(leaves.map(object => new Leaf(object)))
// Return the leaves.
return leaves
if (leaves === this.leaves) return leaves
return Leaf.createLeaves(leaves)
}
/**
* Get all of the active marks on between two offsets
* Corner Cases:
* 1. if startOffset is equal or bigger than endOffset, then return Set();
* 2. If no text is selected between start and end, then return Set()
*
* @return {Set<Mark>}
*/
getActiveMarksBetweenOffsets(startOffset, endOffset) {
if (startOffset === 0 && endOffset === this.characters.size) {
if (startOffset <= 0 && endOffset >= this.text.length) {
return this.getActiveMarks()
}
const startCharacter = this.characters.get(startOffset)
if (!startCharacter) return Set()
const result = startCharacter.marks
if (result.size === 0) return result
return result.withMutations(x => {
for (let index = startOffset + 1; index < endOffset; index++) {
const c = this.characters.get(index)
x.intersect(c.marks)
if (x.size === 0) return
if (startOffset >= endOffset) return Set()
// For empty text in a paragraph, use getActiveMarks;
if (this.text === '') return this.getActiveMarks()
let result = null
let leafEnd = 0
this.leaves.forEach(leaf => {
const leafStart = leafEnd
leafEnd = leafStart + leaf.text.length
if (leafEnd <= startOffset) return
if (leafStart >= endOffset) return false
if (!result) {
result = leaf.marks
return
}
result = result.intersect(leaf.marks)
if (result && result.size === 0) return false
return false
})
return result || Set()
}
/**
@@ -324,12 +374,13 @@ class Text extends Record(DEFAULTS) {
*/
getActiveMarks() {
if (this.characters.size === 0) return Set()
const result = this.characters.first().marks
if (this.leaves.size === 0) return Set()
const result = this.leaves.first().marks
if (result.size === 0) return result
return result.withMutations(x => {
this.characters.forEach(c => {
this.leaves.forEach(c => {
x.intersect(c.marks)
if (x.size === 0) return false
})
@@ -338,20 +389,41 @@ class Text extends Record(DEFAULTS) {
/**
* Get all of the marks on between two offsets
* Corner Cases:
* 1. if startOffset is equal or bigger than endOffset, then return Set();
* 2. If no text is selected between start and end, then return Set()
*
* @return {OrderedSet<Mark>}
*/
getMarksBetweenOffsets(startOffset, endOffset) {
if (startOffset === 0 && endOffset === this.characters.size) {
if (startOffset <= 0 && endOffset >= this.text.length) {
return this.getMarks()
}
return new OrderedSet().withMutations(result => {
for (let index = startOffset; index < endOffset; index++) {
const c = this.characters.get(index)
result.union(c.marks)
if (startOffset >= endOffset) return Set()
// For empty text in a paragraph, use getActiveMarks;
if (this.text === '') return this.getActiveMarks()
let result = null
let leafEnd = 0
this.leaves.forEach(leaf => {
const leafStart = leafEnd
leafEnd = leafStart + leaf.text.length
if (leafEnd <= startOffset) return
if (leafStart >= endOffset) return false
if (!result) {
result = leaf.marks
return
}
result = result.union(leaf.marks)
})
return result || Set()
}
/**
@@ -372,34 +444,35 @@ class Text extends Record(DEFAULTS) {
*/
getMarksAsArray() {
if (this.characters.size === 0) return []
const first = this.characters.first().marks
let previousMark = first
if (this.leaves.size === 0) return []
const first = this.leaves.first().marks
if (this.leaves.size === 1) return first.toArray()
const result = []
this.characters.forEach(c => {
// If the character marks is the same with the
// previous characters, we do not need to
// add the marks twice
if (c.marks === previousMark) return true
previousMark = c.marks
result.push(previousMark.toArray())
this.leaves.forEach(leaf => {
result.push(leaf.marks.toArray())
})
return Array.prototype.concat.apply(first.toArray(), result)
}
/**
* Get the marks on the text at `index`.
* Corner Cases:
* 1. if no text is before the index, and index !== 0, then return Set()
* 2. (for insert after split node or mark at range) if index === 0, and text === '', then return the leaf.marks
* 3. if index === 0, text !== '', return Set()
*
*
* @param {Number} index
* @return {Set<Mark>}
*/
getMarksAtIndex(index) {
if (index == 0) return Mark.createSet()
const { characters } = this
const char = characters.get(index - 1)
if (!char) return Mark.createSet()
return char.marks
const { leaf } = this.searchLeafAtOffset(index)
if (!leaf) return Set()
return leaf.marks
}
/**
@@ -427,24 +500,42 @@ class Text extends Record(DEFAULTS) {
/**
* Insert `text` at `index`.
*
* @param {Numbder} index
* @param {Numbder} offset
* @param {String} text
* @param {String} marks (optional)
* @param {Set} marks (optional)
* @return {Text}
*/
insertText(index, text, marks) {
let { characters } = this
const chars = Character.createList(
text.split('').map(char => ({ text: char, marks }))
insertText(offset, text, marks) {
if (this.text === '') {
return this.set('leaves', List.of(Leaf.create({ text, marks })))
}
if (text.length === 0) return this
if (!marks) marks = Set()
const { startOffset, leaf, index } = this.searchLeafAtOffset(offset)
const delta = offset - startOffset
const beforeText = leaf.text.slice(0, delta)
const afterText = leaf.text.slice(delta)
const { leaves } = this
if (leaf.marks.equals(marks)) {
return this.set(
'leaves',
leaves.set(index, leaf.set('text', beforeText + text + afterText))
)
}
const nextLeaves = leaves.splice(
index,
1,
leaf.set('text', beforeText),
Leaf.create({ text, marks }),
leaf.set('text', afterText)
)
characters = characters
.slice(0, index)
.concat(chars)
.concat(characters.slice(index))
return this.set('characters', characters)
return this.setLeaves(nextLeaves)
}
/**
@@ -468,32 +559,74 @@ class Text extends Record(DEFAULTS) {
*/
removeMark(index, length, mark) {
const characters = this.characters.map((char, i) => {
if (i < index) return char
if (i >= index + length) return char
let { marks } = char
marks = marks.remove(mark)
char = char.set('marks', marks)
return char
})
if (this.text === '' && index === 0 && length === 0) {
const first = this.leaves.first()
if (!first) return this
const newFirst = first.removeMark(mark)
if (newFirst === first) return this
return this.set('leaves', List.of(newFirst))
}
return this.set('characters', characters)
if (length <= 0) return this
if (index >= this.text.length) return this
const [before, bundle] = Leaf.splitLeaves(this.leaves, index)
const [middle, after] = Leaf.splitLeaves(bundle, length)
const leaves = before.concat(middle.map(x => x.removeMark(mark)), after)
return this.setLeaves(leaves)
}
/**
* Remove text from the text node at `index` for `length`.
* Remove text from the text node at `start` for `length`.
*
* @param {Number} index
* @param {Number} start
* @param {Number} length
* @return {Text}
*/
removeText(index, length) {
let { characters } = this
const start = index
const end = index + length
characters = characters.filterNot((char, i) => start <= i && i < end)
return this.set('characters', characters)
removeText(start, length) {
if (length <= 0) return this
if (start >= this.text.length) return this
// PERF: For simple backspace, we can operate directly on the leaf
if (length === 1) {
const { leaf, index, startOffset } = this.searchLeafAtOffset(start)
const offset = start - startOffset
if (leaf) {
if (leaf.text.length === 1) {
const leaves = this.leaves.remove(index)
return this.setLeaves(leaves)
}
const beforeText = leaf.text.slice(0, offset)
const afterText = leaf.text.slice(offset + length)
const text = beforeText + afterText
if (text.length > 0) {
return this.set(
'leaves',
this.leaves.set(index, leaf.set('text', text))
)
}
}
}
const [before, bundle] = Leaf.splitLeaves(this.leaves, start)
const after = Leaf.splitLeaves(bundle, length)[1]
const leaves = Leaf.createLeaves(before.concat(after))
if (leaves.size === 1) {
const first = leaves.first()
if (first.text === '') {
return this.set(
'leaves',
List.of(first.set('marks', this.getActiveMarks()))
)
}
}
return this.set('leaves', leaves)
}
/**
@@ -539,18 +672,51 @@ class Text extends Record(DEFAULTS) {
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
let { marks } = char
if (!marks.has(mark)) return char
marks = marks.remove(mark)
marks = marks.add(newMark)
char = char.set('marks', marks)
return char
})
if (this.text === '' && length === 0 && index === 0) {
const { leaves } = this
const first = leaves.first()
if (!first) return this
const newFirst = first.updateMark(mark, newMark)
if (newFirst === first) return this
return this.set('leaves', List.of(newFirst))
}
return this.set('characters', characters)
if (length <= 0) return this
if (index >= this.text.length) return this
const [before, bundle] = Leaf.splitLeaves(this.leaves, index)
const [middle, after] = Leaf.splitLeaves(bundle, length)
const leaves = before.concat(
middle.map(x => x.updateMark(mark, newMark)),
after
)
return this.setLeaves(leaves)
}
/**
* Split this text and return two different texts
* @param {Number} position
* @returns {Array<Text>}
*/
splitText(offset) {
const splitted = Leaf.splitLeaves(this.leaves, offset)
const one = this.set('leaves', splitted[0])
const two = this.set('leaves', splitted[1]).regenerateKey()
return [one, two]
}
/**
* merge this text and another text at the end
* @param {Text} text
* @returns {Text}
*/
mergeText(text) {
const leaves = this.leaves.concat(text.leaves)
return this.setLeaves(leaves)
}
/**
@@ -566,7 +732,7 @@ class Text extends Record(DEFAULTS) {
/**
* Get the first invalid descendant
* PREF: Do not cache this method; because it can cause cycle reference
* PERF: Do not cache this method; because it can cause cycle reference
*
* @param {Schema} schema
* @returns {Text|Null}
@@ -575,6 +741,28 @@ class Text extends Record(DEFAULTS) {
getFirstInvalidDescendant(schema) {
return this.validate(schema) ? this : null
}
/**
* Set leaves with normalized `leaves`
*
* @param {Schema} schema
* @returns {Text|Null}
*/
setLeaves(leaves) {
const result = Leaf.createLeaves(leaves)
if (result.size === 1) {
const first = result.first()
if (!first.marks || first.marks.size === 0) {
if (first.text === '') {
return this.set('leaves', List())
}
}
}
return this.set('leaves', Leaf.createLeaves(leaves))
}
}
/**
@@ -588,14 +776,12 @@ Text.prototype[MODEL_TYPES.TEXT] = true
*/
memoize(Text.prototype, [
'getDecoratedCharacters',
'getDecorations',
'getLeaves',
'getActiveMarks',
'getMarks',
'getMarksAsArray',
'getMarksAtIndex',
'validate',
'getString',
])
/**

View File

@@ -31,8 +31,10 @@ export const output = (
wo<anchor />
<b>rd</b>
</link>
<b />
</paragraph>
<paragraph>
<b />
<link>
<b>an</b>
<focus />other

View File

@@ -33,8 +33,14 @@ export const output = (
<b>rd</b>
</i>
</link>
<i>
<b />
</i>
</paragraph>
<paragraph>
<i>
<b />
</i>
<link>
<i>
<b>an</b>

View File

@@ -0,0 +1,41 @@
/** @jsx h */
import h from '../../../helpers/h'
export default function(change) {
change.insertText('is ')
}
export const input = (
<value>
<document>
<paragraph>
<b>
<i>Cat</i>
</b>
</paragraph>
<paragraph>
<b>
<cursor />Cute
</b>
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
<b>
<i>Cat</i>
</b>
</paragraph>
<paragraph>
<b>
is <cursor />Cute
</b>
</paragraph>
</document>
</value>
)

View File

@@ -0,0 +1,31 @@
/** @jsx h */
import h from '../../../helpers/h'
export default function(change) {
change.insertText('Cat')
}
export const input = (
<value>
<document>
<paragraph>
<b>
<cursor />
</b>
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
<b>
Cat<cursor />
</b>
</paragraph>
</document>
</value>
)

View File

@@ -31,8 +31,10 @@ export const output = (
wo<anchor />
<b>rd</b>
</link>
<mark type="bold" />
</paragraph>
<paragraph>
<mark type="bold" />
<link>
<b>an</b>
<focus />other

View File

@@ -50,4 +50,6 @@ describe('models', () => {
}
})
})
require('./text/')
})

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = (
<text>
<b>Cat is</b>very <b>very Cute</b>
</text>
)[0]
export default function(t) {
return t.removeText(6, 9)
}
export const output = (
<text>
<b>Cat is Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,20 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = (
<text>
<b>Cat is</b>very <i>very Cute</i>
</text>
)[0]
export default function(t) {
return t.removeText(6, 9)
}
export const output = (
<text>
<b>Cat is</b>
<i> Cute</i>
</text>
)[0]

View File

@@ -0,0 +1,15 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = (
<text>
<b>Cat</b> is <i>Cute</i>
</text>
)[0]
export default function(t) {
return t.removeText(0, t.text.length)
}
export const output = <text />[0]

View File

@@ -0,0 +1,11 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = <b>Cat is Cute</b>[0]
export default function(t) {
return t.removeText(0, t.text.length)
}
export const output = <b />[0]

View File

@@ -0,0 +1,15 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = (
<b>
Cat is <i>Cute</i>
</b>
)[0]
export default function(t) {
return t.removeText(0, t.text.length)
}
export const output = <b />[0]

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = (
<text>
<b>Catt</b> is <i>Cute</i>
</text>
)[0]
export default function(t) {
return t.removeText(3, 1)
}
export const output = (
<text>
<b>Cat</b> is <i>Cute</i>
</text>
)[0]

View File

@@ -0,0 +1,43 @@
import assert from 'assert'
import fs from 'fs'
import { resolve } from 'path'
describe('texts', () => {
const dir = resolve(__dirname)
const categories = fs
.readdirSync(dir)
.filter(c => c[0] != '.' && c != 'index.js')
for (const category of categories) {
describe(category, () => {
const categoryDir = resolve(dir, category)
const methods = fs
.readdirSync(categoryDir)
.filter(c => c[0] != '.' && !c.includes('.js'))
for (const method of methods) {
describe(method, () => {
const testDir = resolve(categoryDir, method)
const tests = fs
.readdirSync(testDir)
.filter(t => t[0] != '.' && t.includes('.js'))
for (const test of tests) {
const module = require(resolve(testDir, test))
const { input, output, skip } = module
const fn = module.default
const t = skip ? it.skip : it
t(test.replace('.js', ''), () => {
const actual = fn(input)
const opts = { preserveData: true }
const expected = output.toJSON(opts)
assert.deepEqual(actual.toJSON(opts), expected)
})
}
})
}
})
}
})

View File

@@ -0,0 +1,20 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = (
<text>
<i>Cat</i>
<b> Cute</b>
</text>
)[0]
export default function(t) {
return t.insertText(3, ' is')
}
export const output = (
<text>
<i>Cat</i> is<b> Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = (
<text>
Cat<b> Cute</b>
</text>
)[0]
export default function(t) {
return t.insertText(3, ' is')
}
export const output = (
<text>
Cat is<b> Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = (
<text>
Cat<b> is</b>
</text>
)[0]
export default function(t) {
return t.insertText(6, ' Cute')
}
export const output = (
<text>
Cat<b> is</b> Cute
</text>
)[0]

View File

@@ -0,0 +1,17 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = <b>CatCute</b>[0]
export default function(t) {
return t.insertText(3, ' is ', Set.of(Mark.create('bold')))
}
export const output = (
<b>
<b>Cat is Cute</b>
</b>
)[0]

View File

@@ -0,0 +1,17 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = <text>CatCute</text>[0]
export default function(t) {
return t.insertText(3, ' is ', Set.of(Mark.create('bold')))
}
export const output = (
<text>
Cat<b> is </b>Cute
</text>
)[0]

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = (
<text>
<b>CatCute</b>
</text>
)[0]
export default function(t) {
return t.insertText(3, ' is ')
}
export const output = (
<text>
<b>Cat</b> is <b>Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,11 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = <text>CatCute</text>[0]
export default function(t) {
return t.insertText(3, ' is ')
}
export const output = <text>Cat is Cute</text>[0]

View File

@@ -0,0 +1,17 @@
/** @jsx h */
import { List } from 'immutable'
import { Mark } from '../../../../..'
import h from '../../../../helpers/h'
export const input = <i />[0]
export default function(t) {
return t.insertText(0, 'Cat is Cute', List.of(Mark.create({ type: 'bold' })))
}
export const output = (
<text>
<b>Cat is Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,11 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = <text />[0]
export default function(t) {
return t.insertText(1, 'Cat is Cute')
}
export const output = <text>Cat is Cute</text>[0]

View File

@@ -0,0 +1,11 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = <text />[0]
export default function(t) {
return t.insertText(0, 'Cat is Cute')
}
export const output = <text>Cat is Cute</text>[0]

View File

@@ -0,0 +1,20 @@
/** @jsx h */
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = (
<text>
Cat<b> is Cute</b>
</text>
)[0]
export default function(t) {
return t.addMark(3, 4, Mark.create('bold'))
}
export const output = (
<text>
Cat<b> is Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,21 @@
/** @jsx h */
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = (
<text>
Cat is
<b> Cute</b>
</text>
)[0]
export default function(t) {
return t.addMark(3, 3, Mark.create('bold'))
}
export const output = (
<text>
Cat<b> is Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,25 @@
/** @jsx h */
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = (
<text>
Cat i
<b>s Cute</b>
</text>
)[0]
export default function(t) {
return t.addMark(3, 4, Mark.create('italic'))
}
export const output = (
<text>
Cat<i> i</i>
<i>
<b>s </b>
</i>
<b>Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,20 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = (
<text>
<b>
Cat
<i> is</i> Cute
</b>
</text>
)[0]
export default function(t) {
return t.getActiveMarksBetweenOffsets(0, 6)
}
export const output = Set.of(Mark.create('bold'))

View File

@@ -0,0 +1,17 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
export const input = (
<text>
<b>Cat</b>
<i> is</i> Cute
</text>
)[0]
export default function(t) {
return t.getActiveMarksBetweenOffsets(0, 6)
}
export const output = Set()

View File

@@ -0,0 +1,13 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = <b />[0]
export default function(t) {
return t.getActiveMarksBetweenOffsets(0, 0)
}
export const output = Set.of(Mark.create('bold'))

View File

@@ -0,0 +1,20 @@
/** @jsx h */
import h from '../../../../helpers/h'
import { Set } from 'immutable'
import { Mark } from '../../../../..'
export const input = (
<text>
<b>
<i>Cat is </i>
<i>Cute</i>
</b>
</text>
)[0]
export default function(t) {
return t.getActiveMarks()
}
export const output = Set.of(Mark.create('italic'), Mark.create('bold'))

View File

@@ -0,0 +1,20 @@
/** @jsx h */
import h from '../../../../helpers/h'
import { Set } from 'immutable'
import { Mark } from '../../../../..'
export const input = (
<text>
<b>
<i>Cat is </i>
Cute
</b>
</text>
)[0]
export default function(t) {
return t.getActiveMarks()
}
export const output = Set.of(Mark.create('bold'))

View File

@@ -0,0 +1,18 @@
/** @jsx h */
import h from '../../../../helpers/h'
import { Set } from 'immutable'
export const input = (
<text>
<i>Cat</i>
is
<i>Cute</i>
</text>
)[0]
export default function(t) {
return t.getActiveMarks()
}
export const output = Set()

View File

@@ -0,0 +1,13 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = <b />[0]
export default function(t) {
return t.getMarksAtIndex(0)
}
export const output = Set.of(Mark.create('bold'))

View File

@@ -0,0 +1,18 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = (
<text>
<b>Cat</b>
<i> is</i> Cute
</text>
)[0]
export default function(t) {
return t.getMarksBetweenOffsets(0, 6)
}
export const output = Set.of(Mark.create('bold'), Mark.create('italic'))

View File

@@ -0,0 +1,30 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = (
<text>
<b x={1}>Cat</b>
<i x={1}> is</i>
<b x={2}>Cat</b>
<i x={2}> is</i>
<b x={3}>Cat</b>
<i> is</i>
<b>Cat</b>
<i> is</i>
<b>Cat</b>
</text>
)[0]
export default function(t) {
return t.getMarksBetweenOffsets(0, 12)
}
export const output = Set.of(
Mark.create({ type: 'bold', data: { x: 1 } }),
Mark.create({ type: 'italic', data: { x: 1 } }),
Mark.create({ type: 'bold', data: { x: 2 } }),
Mark.create({ type: 'italic', data: { x: 2 } })
)

View File

@@ -0,0 +1,13 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = <b />[0]
export default function(t) {
return t.getMarksBetweenOffsets(0, 0)
}
export const output = Set.of(Mark.create('bold'))

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import h from '../../../../helpers/h'
import { Set } from 'immutable'
import { Mark } from '../../../../../'
export const input = (
<text>
<i>Cat </i>
is
<b> Cute</b>
</text>
)[0]
export default function(t) {
return t.getMarks()
}
export const output = Set.of(Mark.create('italic'), Mark.create('bold'))

View File

@@ -0,0 +1,13 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../../'
export const input = <b />[0]
export default function(t) {
return t.getMarks()
}
export const output = Set.of(Mark.create('bold'))

View File

@@ -0,0 +1,12 @@
/** @jsx h */
import h from '../../../../helpers/h'
import { Set } from 'immutable'
export const input = <text />[0]
export default function(t) {
return t.getMarks()
}
export const output = Set()

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import { Set } from 'immutable'
import h from '../../../../helpers/h'
import { Mark } from '../../../../..'
export const input = (
<text>
<i>Cat</i>
is
<i>Cute</i>
</text>
)[0]
export default function(t) {
return t.getMarks()
}
export const output = Set.of(Mark.create('italic'))

View File

@@ -0,0 +1,12 @@
/** @jsx h */
import h from '../../../../helpers/h'
import { Set } from 'immutable'
export const input = <text> Cat is Cute </text>[0]
export default function(t) {
return t.getMarks()
}
export const output = Set()

View File

@@ -0,0 +1,20 @@
/** @jsx h */
import { Mark } from '../../../../..'
import h from '../../../../helpers/h'
export const input = (
<text>
<b>Cat is Cute</b>
</text>
)[0]
export default function(t) {
return t.removeMark(0, 3, Mark.create('bold'))
}
export const output = (
<text>
Cat<b> is Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,22 @@
/** @jsx h */
import { Mark } from '../../../../..'
import h from '../../../../helpers/h'
export const input = (
<text>
<b>Cat</b>
<i> is Cute</i>
</text>
)[0]
export default function(t) {
return t.updateMark(0, 6, Mark.create('bold'), { data: { x: 1 } })
}
export const output = (
<text>
<b x={1}>Cat</b>
<i> is Cute</i>
</text>
)[0]

View File

@@ -0,0 +1,17 @@
/** @jsx h */
import { Mark } from '../../../../..'
import h from '../../../../helpers/h'
export const input = <b>Cat is Cute</b>[0]
export default function(t) {
return t.updateMark(0, 3, Mark.create('bold'), { data: { x: 1 } })
}
export const output = (
<text>
<b x={1}>Cat</b>
<b> is Cute</b>
</text>
)[0]

View File

@@ -0,0 +1,12 @@
/** @jsx h */
import { Mark } from '../../../../..'
import h from '../../../../helpers/h'
export const input = <b />[0]
export default function(t) {
return t.updateMark(0, 1, Mark.create('bold'), { data: { x: 1 } })
}
export const output = input

View File

@@ -0,0 +1,12 @@
/** @jsx h */
import { Mark } from '../../../../..'
import h from '../../../../helpers/h'
export const input = <b />[0]
export default function(t) {
return t.updateMark(0, 0, Mark.create('bold'), { data: { x: 1 } })
}
export const output = <b x={1} />[0]

View File

@@ -0,0 +1,11 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = [<i>Some</i>[0], <text />[0]]
export default function(texts) {
return texts[0].mergeText(texts[1])
}
export const output = <i>Some</i>[0]

View File

@@ -0,0 +1,11 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = [<b />[0], <text />[0]]
export default function(texts) {
return texts[0].mergeText(texts[1])
}
export const output = <b />[0]

View File

@@ -0,0 +1,11 @@
/** @jsx h */
import h from '../../../../helpers/h'
export const input = [<b />[0], <text>Some</text>[0]]
export default function(texts) {
return texts[0].mergeText(texts[1])
}
export const output = <text>Some</text>[0]