1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-22 06:53:25 +02:00

Merge branch 'master' into schema-normalize

This commit is contained in:
Soreine
2016-10-27 10:36:58 +02:00
15 changed files with 279 additions and 75 deletions

View File

@@ -51,9 +51,9 @@ An array of rules to initialize the `Html` serializer with, defining your schema
Deserialize an HTML `string` into a [`State`](../models/state.md). How the string is deserialized will be determined by the rules that the `Html` serializer was constructed with. Deserialize an HTML `string` into a [`State`](../models/state.md). How the string is deserialized will be determined by the rules that the `Html` serializer was constructed with.
### `Html.serialize` ### `Html.serialize`
`Html.serialize(state: State) => String` `Html.serialize(state: State, [options: Object]) => String || Array`
Serialize a `state` into an HTML string. How the string is serialized will be determined by the rules that the `Html` serializer was constructed with. Serialize a `state` into an HTML string. How the string is serialized will be determined by the rules that the `Html` serializer was constructed with. If you pass `render: false` as an option, the return value will instead be an iterable list of the top-level React elements, to be rendered as children in your own React component.
## Rules ## Rules

View File

@@ -1,7 +1,7 @@
{ {
"name": "slate", "name": "slate",
"description": "A completely customizable framework for building rich text editors.", "description": "A completely customizable framework for building rich text editors.",
"version": "0.14.15", "version": "0.14.16",
"license": "MIT", "license": "MIT",
"repository": "git://github.com/ianstormtaylor/slate.git", "repository": "git://github.com/ianstormtaylor/slate.git",
"main": "./lib/index.js", "main": "./lib/index.js",

View File

@@ -128,7 +128,7 @@ class Void extends React.Component {
renderLeaf = () => { renderLeaf = () => {
const { node, schema, state } = this.props const { node, schema, state } = this.props
const child = node.getTexts().first() const child = node.getFirstText()
const ranges = child.getRanges() const ranges = child.getRanges()
const text = '' const text = ''
const marks = Mark.createSet() const marks = Mark.createSet()

View File

@@ -101,23 +101,53 @@ const Node = {
}, },
/** /**
* Recursively find all ancestor nodes by `iterator`. * Recursively find all descendant nodes by `iterator`. Breadth first.
* *
* @param {Function} iterator * @param {Function} iterator
* @return {Node} node * @return {Node or Null} node
*/ */
findDescendant(iterator) { findDescendant(iterator) {
return ( const found = this.nodes.find(iterator)
this.nodes.find(iterator) || if (found) return found
this.nodes
.map(node => node.kind == 'text' ? null : node.findDescendant(iterator)) let descendantFound = null
.find(exists => exists) this.nodes.find(node => {
) if (node.kind != 'text') {
descendantFound = node.findDescendant(iterator)
return descendantFound
} else {
return false
}
})
return descendantFound
}, },
/** /**
* Recursively filter all ancestor nodes with `iterator`. * Recursively find all descendant nodes by `iterator`. Depth first.
*
* @param {Function} iterator
* @return {Node or Null} node
*/
findDescendantDeep(iterator) {
let descendantFound = null
const found = this.nodes.find(node => {
if (node.kind != 'text') {
descendantFound = node.findDescendantDeep(iterator)
return descendantFound || iterator(node)
}
return iterator(node) ? node : null
})
return descendantFound || found
},
/**
* Recursively filter all descendant nodes with `iterator`.
* *
* @param {Function} iterator * @param {Function} iterator
* @return {List} nodes * @return {List} nodes
@@ -132,7 +162,7 @@ const Node = {
}, },
/** /**
* Recursively filter all ancestor nodes with `iterator`, depth-first. * Recursively filter all descendant nodes with `iterator`, depth-first.
* *
* @param {Function} iterator * @param {Function} iterator
* @return {List} nodes * @return {List} nodes
@@ -282,14 +312,13 @@ const Node = {
*/ */
getClosest(key, iterator) { getClosest(key, iterator) {
let node = this.assertDescendant(key) let ancestors = this.getAncestors(key)
if (!ancestors) {
while (node = this.getParent(node)) { throw new Error(`Could not find a descendant node with key "${key}".`)
if (node == this) return null
if (iterator(node)) return node
} }
return null // Exclude this node itself
return ancestors.rest().findLast(iterator)
}, },
/** /**
@@ -406,16 +435,7 @@ const Node = {
getDescendant(key) { getDescendant(key) {
key = Normalize.key(key) key = Normalize.key(key)
let child = this.getChild(key) return this.findDescendantDeep(node => node.key == key)
if (child) return child
this.nodes.find((node) => {
if (node.kind == 'text') return false
child = node.getDescendant(key)
return child
})
return child
}, },
/** /**
@@ -649,10 +669,10 @@ const Node = {
let last let last
if (child.kind == 'block') { if (child.kind == 'block') {
last = child.getTexts().last() last = child.getLastText()
} else { } else {
const block = this.getClosestBlock(key) const block = this.getClosestBlock(key)
last = block.getTexts().last() last = block.getLastText()
} }
const next = this.getNextText(last) const next = this.getNextText(last)
@@ -743,10 +763,13 @@ const Node = {
let node = null let node = null
this.nodes.forEach((child) => { this.nodes.find((child) => {
if (child.kind == 'text') return if (child.kind == 'text') {
const match = child.getParent(key) return false
if (match) node = match } else {
node = child.getParent(key)
return node
}
}) })
return node return node
@@ -755,7 +778,7 @@ const Node = {
/** /**
* Get the path of a descendant node by `key`. * Get the path of a descendant node by `key`.
* *
* @param {String || Node} node * @param {String || Node} key
* @return {Array} * @return {Array}
*/ */
@@ -764,17 +787,50 @@ const Node = {
if (key == this.key) return [] if (key == this.key) return []
let child = this.assertDescendant(key)
let path = [] let path = []
let childKey = key
let parent let parent
while (parent = this.getParent(child)) { // Efficient with getParent memoization
const index = parent.nodes.indexOf(child) while (parent = this.getParent(childKey)) {
const index = parent.nodes.findIndex(n => n.key === childKey)
path.unshift(index) path.unshift(index)
child = parent childKey = parent.key
} }
if (childKey === key) {
// Did not loop once, meaning we could not find the child
throw new Error(`Could not find a descendant node with key "${key}".`)
} else {
return path return path
}
},
/**
* Get the path of ancestors of a descendant node by `key`.
*
* @param {String || Node} node
* @return {List<Node> or Null}
*/
getAncestors(key) {
key = Normalize.key(key)
if (key == this.key) return List()
if (this.hasChild(key)) return List([this])
let ancestors
this.nodes.find((node) => {
if (node.kind == 'text') return false
ancestors = node.getAncestors(key)
return ancestors
})
if (ancestors) {
return ancestors.unshift(this)
} else {
return null
}
}, },
/** /**
@@ -819,10 +875,10 @@ const Node = {
let first let first
if (child.kind == 'block') { if (child.kind == 'block') {
first = child.getTexts().first() first = child.getFirstText()
} else { } else {
const block = this.getClosestBlock(key) const block = this.getClosestBlock(key)
first = block.getTexts().first() first = block.getFirstText()
} }
const previous = this.getPreviousText(first) const previous = this.getPreviousText(first)
@@ -876,6 +932,34 @@ const Node = {
}, Block.createList()) }, Block.createList())
}, },
/**
* Get the first child text node.
*
* @return {Node || Null} node
*/
getFirstText() {
return this.findDescendantDeep(node => node.kind == 'text')
},
/**
* Get the last child text node.
*
* @return {Node} node
*/
getLastText() {
let descendantFound = null
const found = this.nodes.findLast((node) => {
if (node.kind == 'text') return true
descendantFound = node.getLastText()
return descendantFound
})
return descendantFound || found
},
/** /**
* Get all of the text nodes in a `range`. * Get all of the text nodes in a `range`.
* *
@@ -1070,10 +1154,13 @@ const Node = {
*/ */
removeDescendant(key) { removeDescendant(key) {
key = Normalize.key(key)
let node = this let node = this
const desc = node.assertDescendant(key) let parent = node.getParent(key)
let parent = node.getParent(desc) if (!parent) throw new Error(`Could not find a descendant node with key "${key}".`)
const index = parent.nodes.indexOf(desc)
const index = parent.nodes.findIndex(n => n.key === key)
const isParent = node == parent const isParent = node == parent
const nodes = parent.nodes.splice(index, 1) const nodes = parent.nodes.splice(index, 1)
@@ -1183,8 +1270,22 @@ const Node = {
*/ */
updateDescendant(node) { updateDescendant(node) {
this.assertDescendant(node) let found = false
return this.mapDescendants(d => d.key == node.key ? node : d)
const result = this.mapDescendants(d => {
if (d.key == node.key) {
found = true
return node
} else {
return d
}
})
if (!found) {
throw new Error(`Could not update descendant node with key "${node.key}".`)
} else {
return result
}
}, },
/** /**
@@ -1210,6 +1311,8 @@ memoize(Node, [
'filterDescendants', 'filterDescendants',
'filterDescendantsDeep', 'filterDescendantsDeep',
'findDescendant', 'findDescendant',
'findDescendantDeep',
'getAncestors',
'getBlocks', 'getBlocks',
'getBlocksAtRange', 'getBlocksAtRange',
'getCharactersAtRange', 'getCharactersAtRange',
@@ -1228,6 +1331,7 @@ memoize(Node, [
'getDepth', 'getDepth',
'getDescendant', 'getDescendant',
'getDescendantDecorators', 'getDescendantDecorators',
'getFirstText',
'getFragmentAtRange', 'getFragmentAtRange',
'getFurthest', 'getFurthest',
'getFurthestBlock', 'getFurthestBlock',
@@ -1235,6 +1339,7 @@ memoize(Node, [
'getHighestChild', 'getHighestChild',
'getHighestOnlyChildParent', 'getHighestOnlyChildParent',
'getInlinesAtRange', 'getInlinesAtRange',
'getLastText',
'getMarksAtRange', 'getMarksAtRange',
'getNextBlock', 'getNextBlock',
'getNextSibling', 'getNextSibling',

View File

@@ -149,7 +149,7 @@ class Selection extends new Record(DEFAULTS) {
hasAnchorAtStartOf(node) { hasAnchorAtStartOf(node) {
if (this.anchorOffset != 0) return false if (this.anchorOffset != 0) return false
const first = node.kind == 'text' ? node : node.getTexts().first() const first = node.kind == 'text' ? node : node.getFirstText()
return this.anchorKey == first.key return this.anchorKey == first.key
} }
@@ -161,7 +161,7 @@ class Selection extends new Record(DEFAULTS) {
*/ */
hasAnchorAtEndOf(node) { hasAnchorAtEndOf(node) {
const last = node.kind == 'text' ? node : node.getTexts().last() const last = node.kind == 'text' ? node : node.getLastText()
return this.anchorKey == last.key && this.anchorOffset == last.length return this.anchorKey == last.key && this.anchorOffset == last.length
} }
@@ -203,7 +203,7 @@ class Selection extends new Record(DEFAULTS) {
*/ */
hasFocusAtEndOf(node) { hasFocusAtEndOf(node) {
const last = node.kind == 'text' ? node : node.getTexts().last() const last = node.kind == 'text' ? node : node.getLastText()
return this.focusKey == last.key && this.focusOffset == last.length return this.focusKey == last.key && this.focusOffset == last.length
} }
@@ -216,7 +216,7 @@ class Selection extends new Record(DEFAULTS) {
hasFocusAtStartOf(node) { hasFocusAtStartOf(node) {
if (this.focusOffset != 0) return false if (this.focusOffset != 0) return false
const first = node.kind == 'text' ? node : node.getTexts().first() const first = node.kind == 'text' ? node : node.getFirstText()
return this.focusKey == first.key return this.focusKey == first.key
} }
@@ -261,7 +261,7 @@ class Selection extends new Record(DEFAULTS) {
const { isExpanded, startKey, startOffset } = this const { isExpanded, startKey, startOffset } = this
if (isExpanded) return false if (isExpanded) return false
if (startOffset != 0) return false if (startOffset != 0) return false
const first = node.kind == 'text' ? node : node.getTexts().first() const first = node.kind == 'text' ? node : node.getFirstText()
return startKey == first.key return startKey == first.key
} }
@@ -275,7 +275,7 @@ class Selection extends new Record(DEFAULTS) {
isAtEndOf(node) { isAtEndOf(node) {
const { endKey, endOffset, isExpanded } = this const { endKey, endOffset, isExpanded } = this
if (isExpanded) return false if (isExpanded) return false
const last = node.kind == 'text' ? node : node.getTexts().last() const last = node.kind == 'text' ? node : node.getLastText()
return endKey == last.key && endOffset == last.length return endKey == last.key && endOffset == last.length
} }

View File

@@ -45,7 +45,7 @@ class State extends new Record(DEFAULTS) {
let selection = Selection.create(properties.selection) let selection = Selection.create(properties.selection)
if (selection.isUnset) { if (selection.isUnset) {
const text = document.getTexts().first() const text = document.getFirstText()
selection = selection.collapseToStartOf(text) selection = selection.collapseToStartOf(text)
} }

View File

@@ -12,6 +12,8 @@ It doesn't hardcode any information about the schema itself (like which tag mean
It handles all of the heavy lifting of actually parsing the HTML, and iterating over the elements, and all you have to supply it is a `serialize()` and `deserialize()` function for each type of [`Node`](../models#node) or [`Mark`](../models/#mark) you want it to handle. It handles all of the heavy lifting of actually parsing the HTML, and iterating over the elements, and all you have to supply it is a `serialize()` and `deserialize()` function for each type of [`Node`](../models#node) or [`Mark`](../models/#mark) you want it to handle.
If called with `{render: false}` as the optional second argument, the serializer will return an iterable list of the top-level React elements generated, instead of automatically rendering these to a markup string.
#### Raw #### Raw
@@ -20,4 +22,3 @@ The `Raw` serializer is the simplest serializer, which translates a [`State`](..
It doesn't just use Immutable.js's [`.toJSON()`](https://facebook.github.io/immutable-js/docs/#/List/toJS) method. Instead, it performs a little bit of "minifying" logic to reduce unnecessary information from being in the raw output. It doesn't just use Immutable.js's [`.toJSON()`](https://facebook.github.io/immutable-js/docs/#/List/toJS) method. Instead, it performs a little bit of "minifying" logic to reduce unnecessary information from being in the raw output.
It also transforms [`Text`](../models#text) nodes's content from being organized by [`Characters`](../models#character) into the concept of "ranges", which have a unique set of [`Marks`](../models#mark). It also transforms [`Text`](../models#text) nodes's content from being organized by [`Characters`](../models#character) into the concept of "ranges", which have a unique set of [`Marks`](../models#mark).

View File

@@ -225,12 +225,16 @@ class Html {
* Serialize a `state` object into an HTML string. * Serialize a `state` object into an HTML string.
* *
* @param {State} state * @param {State} state
* @return {String} html * @param {Object} options
* @property {Boolean} render
* @return {String|Array} html
*/ */
serialize = (state) => { serialize = (state, options = {}) => {
const { document } = state const { document } = state
const elements = document.nodes.map(this.serializeNode) const elements = document.nodes.map(this.serializeNode)
if (options.render === false) return elements
const html = ReactDOMServer.renderToStaticMarkup(<body>{elements}</body>) const html = ReactDOMServer.renderToStaticMarkup(<body>{elements}</body>)
const inner = html.slice(6, -7) const inner = html.slice(6, -7)
return inner return inner

View File

@@ -70,7 +70,7 @@ export function _delete(transform) {
after = selection.collapseToEndOf(previous) after = selection.collapseToEndOf(previous)
} }
} else { } else {
const last = previous.getTexts().last() const last = previous.getLastText()
after = selection.collapseToEndOf(last) after = selection.collapseToEndOf(last)
} }
} }
@@ -113,6 +113,7 @@ export function deleteBackward(transform, n = 1) {
export function deleteForward(transform, n = 1) { export function deleteForward(transform, n = 1) {
const { state } = transform const { state } = transform
const { selection } = state const { selection } = state
return transform return transform
.deleteForwardAtRange(selection, n) .deleteForwardAtRange(selection, n)
.collapseToEnd() .collapseToEnd()
@@ -156,9 +157,10 @@ export function insertFragment(transform, fragment) {
if (!fragment.length) return transform if (!fragment.length) return transform
const lastText = fragment.getTexts().last() const lastText = fragment.getLastText()
const lastInline = fragment.getClosestInline(lastText) const lastInline = fragment.getClosestInline(lastText)
const beforeTexts = document.getTexts() const beforeTexts = document.getTexts()
const appending = selection.hasEdgeAtEndOf(document.getDescendant(selection.endKey))
transform.unsetSelection() transform.unsetSelection()
transform.insertFragmentAtRange(selection, fragment) transform.insertFragmentAtRange(selection, fragment)
@@ -167,17 +169,13 @@ export function insertFragment(transform, fragment) {
const keys = beforeTexts.map(text => text.key) const keys = beforeTexts.map(text => text.key)
const news = document.getTexts().filter(n => !keys.includes(n.key)) const news = document.getTexts().filter(n => !keys.includes(n.key))
const text = news.size ? news.takeLast(2).first() : null const text = appending ? news.last() : news.takeLast(2).first()
let after let after
if (text && lastInline) { if (text && lastInline) {
after = selection.collapseToEndOf(text) after = selection.collapseToEndOf(text)
} }
else if (text && lastInline) {
after = selection.collapseToStart()
}
else if (text) { else if (text) {
after = selection after = selection
.collapseToStartOf(text) .collapseToStartOf(text)
@@ -495,7 +493,7 @@ export function wrapInline(transform, properties) {
} }
else if (selection.startOffset == 0) { else if (selection.startOffset == 0) {
const text = previous ? document.getNextText(previous) : document.getTexts().first() const text = previous ? document.getNextText(previous) : document.getFirstText()
after = selection.moveToRangeOf(text) after = selection.moveToRangeOf(text)
} }

View File

@@ -65,6 +65,8 @@ export function deleteAtRange(transform, range, options = {}) {
let { state } = transform let { state } = transform
let { document } = state let { document } = state
// split the nodes at range, within the common ancestor
let ancestor = document.getCommonAncestor(startKey, endKey) let ancestor = document.getCommonAncestor(startKey, endKey)
let startChild = ancestor.getHighestChild(startKey) let startChild = ancestor.getHighestChild(startKey)
let endChild = ancestor.getHighestChild(endKey) let endChild = ancestor.getHighestChild(endKey)
@@ -76,18 +78,16 @@ export function deleteAtRange(transform, range, options = {}) {
state = transform.state state = transform.state
document = state.document document = state.document
ancestor = document.getCommonAncestor(startKey, endKey)
const startBlock = document.getClosestBlock(startKey) const startBlock = document.getClosestBlock(startKey)
const endBlock = document.getClosestBlock(document.getNextText(endKey)) const endBlock = document.getClosestBlock(document.getNextText(endKey))
// remove all of the nodes between range
ancestor = document.getCommonAncestor(startKey, endKey)
startChild = ancestor.getHighestChild(startKey) startChild = ancestor.getHighestChild(startKey)
endChild = ancestor.getHighestChild(endKey) endChild = ancestor.getHighestChild(endKey)
const startIndex = ancestor.nodes.indexOf(startChild) const startIndex = ancestor.nodes.indexOf(startChild)
const endIndex = ancestor.nodes.indexOf(endChild) const endIndex = ancestor.nodes.indexOf(endChild)
const middles = ancestor.nodes.slice( const middles = ancestor.nodes.slice(startIndex + 1, endIndex + 1)
startIndex + 1,
endIndex + 1
)
if (middles.size) { if (middles.size) {
// remove first nodes directly so the document is not normalized // remove first nodes directly so the document is not normalized
@@ -110,6 +110,9 @@ export function deleteAtRange(transform, range, options = {}) {
if (normalize) { if (normalize) {
transform.normalizeNodeByKey(ancestor.key) transform.normalizeNodeByKey(ancestor.key)
} }
transform.normalizeDocument()
return transform return transform
} }

View File

@@ -1,11 +1,14 @@
import assert from 'assert' import assert from 'assert'
import type from 'type-of'
import fs from 'fs' import fs from 'fs'
import readMetadata from 'read-metadata' import readMetadata from 'read-metadata'
import strip from '../helpers/strip-dynamic' import strip from '../helpers/strip-dynamic'
import { Html, Json, Plain, Raw } from '../..' import { Html, Json, Plain, Raw } from '../..'
import { equal, strictEqual } from '../helpers/assert-json' import { equal, strictEqual } from '../helpers/assert-json'
import { resolve } from 'path' import { resolve } from 'path'
import React from 'react'
import { Iterable } from 'immutable'
/** /**
* Tests. * Tests.
@@ -46,6 +49,14 @@ describe('serializers', () => {
strictEqual(serialized, expected.trim()) strictEqual(serialized, expected.trim())
}) })
} }
it('optionally returns an iterable list of React elements', () => {
const html = new Html(require('./fixtures/html/serialize/block-nested').default)
const input = require('./fixtures/html/serialize/block-nested/input.js').default
const serialized = html.serialize(input, { render: false })
assert(Iterable.isIterable(serialized), 'did not return an interable list')
assert(React.isValidElement(serialized.first()), 'did not return valid React elements')
})
}) })
}) })

View File

@@ -0,0 +1,17 @@
nodes:
- kind: block
type: list-item
nodes:
- kind: text
text: fragment
- kind: block
type: list-item
nodes:
- kind: text
text: second fragment
- kind: block
type: list-item
nodes:
- kind: text
text: third fragment

View File

@@ -0,0 +1,41 @@
import assert from 'assert'
import path from 'path'
import readMetadata from 'read-metadata'
import { Raw } from '../../../../../..'
export default function (state) {
const file = path.resolve(__dirname, 'fragment.yaml')
const raw = readMetadata.sync(file)
const fragment = Raw.deserialize(raw, { terse: true }).document
const { document, selection } = state
const texts = document.getTexts()
const first = texts.first()
const range = selection.merge({
anchorKey: first.key,
anchorOffset: first.length,
focusKey: first.key,
focusOffset: first.length
})
const next = state
.transform()
.moveTo(range)
.insertFragment(fragment)
.apply()
const last = next.document.getTexts().last()
assert.deepEqual(
next.selection.toJS(),
range.merge({
anchorKey: last.key,
anchorOffset: last.length,
focusKey: last.key,
focusOffset: last.length
}).toJS()
)
return next
}

View File

@@ -0,0 +1,7 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
text: word

View File

@@ -0,0 +1,17 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
text: wordfragment
- kind: block
type: list-item
nodes:
- kind: text
text: second fragment
- kind: block
type: list-item
nodes:
- kind: text
text: third fragment