1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-11 09:43:58 +02:00

Operations update decorations (#1778)

* initial simple decorations (mark-like), many tests added

* allow decorators to be set by focus, anchor tags - add tests

* handle one more edge case with decorations in hyperscript

* apply prettier cleanup

* apply linting rules

* update changelog

* ensure always normalize decoration ranges

* reapply prettier after latest adjustments

* all operations apply now update decorations with selection

* ranges can now be 'atomic', will invalidate if contents change

* lint, prettier cleanups

* add atomic invalidation tests, update hyperscript usage

* fix linter errors

* minor cleanup

* slight refactor for simplicity

* remove a couple superfluous lines

* update in response to review

* drop unnecessarily committed add'l file

* remove the need for explicit anchor, focus prop on decoration tags

* update hyperscript use to match latest syntax in #1777

* atomic -> isAtomic
This commit is contained in:
jasonphillips
2018-06-14 21:33:02 -05:00
committed by Ian Storm Taylor
parent 63eae6b48b
commit c500becf81
15 changed files with 502 additions and 109 deletions

View File

@@ -56,6 +56,7 @@ class SearchHighlighting extends React.Component {
focusKey: key, focusKey: key,
focusOffset: offset, focusOffset: offset,
marks: [{ type: 'highlight' }], marks: [{ type: 'highlight' }],
atomic: true,
}) })
} }

View File

@@ -19,9 +19,12 @@ const FOCUS = {}
*/ */
class DecoratorPoint { class DecoratorPoint {
constructor(key, marks) { constructor({ key, data }, marks) {
this._key = key this._key = key
this.marks = marks this.marks = marks
this.attribs = data || {}
this.isAtomic = !!this.attribs.atomic
delete this.attribs.atomic
return this return this
} }
withPosition = offset => { withPosition = offset => {
@@ -45,6 +48,8 @@ class DecoratorPoint {
anchorOffset: this.offset, anchorOffset: this.offset,
focusOffset: focus.offset, focusOffset: focus.offset,
marks: this.marks, marks: this.marks,
isAtomic: this.isAtomic,
...this.attribs,
}) })
} }
} }
@@ -97,7 +102,7 @@ const CREATORS = {
decoration(tagName, attributes, children) { decoration(tagName, attributes, children) {
if (attributes.key) { if (attributes.key) {
return new DecoratorPoint(attributes.key, [{ type: tagName }]) return new DecoratorPoint(attributes, [{ type: tagName }])
} }
const nodes = createChildren(children, { key: attributes.key }) const nodes = createChildren(children, { key: attributes.key })
@@ -106,6 +111,7 @@ const CREATORS = {
anchorOffset: 0, anchorOffset: 0,
focusOffset: nodes.reduce((len, n) => len + n.text.length, 0), focusOffset: nodes.reduce((len, n) => len + n.text.length, 0),
marks: [{ type: tagName }], marks: [{ type: tagName }],
isAtomic: !!attributes.data.atomic,
}, },
]) ])
return nodes return nodes

View File

@@ -19,6 +19,7 @@ const DEFAULTS = {
isBackward: null, isBackward: null,
isFocused: false, isFocused: false,
marks: null, marks: null,
isAtomic: false,
} }
/** /**
@@ -84,6 +85,7 @@ class Range extends Record(DEFAULTS) {
isBackward: attrs.isBackward, isBackward: attrs.isBackward,
isFocused: attrs.isFocused, isFocused: attrs.isFocused,
marks: attrs.marks, marks: attrs.marks,
isAtomic: attrs.isAtomic,
} }
} }
@@ -99,6 +101,7 @@ class Range extends Record(DEFAULTS) {
if ('isFocused' in attrs) props.isFocused = attrs.isFocused if ('isFocused' in attrs) props.isFocused = attrs.isFocused
if ('marks' in attrs) if ('marks' in attrs)
props.marks = attrs.marks == null ? null : Mark.createSet(attrs.marks) props.marks = attrs.marks == null ? null : Mark.createSet(attrs.marks)
if ('isAtomic' in attrs) props.isAtomic = attrs.isAtomic
return props return props
} }
@@ -123,6 +126,7 @@ class Range extends Record(DEFAULTS) {
isBackward = null, isBackward = null,
isFocused = false, isFocused = false,
marks = null, marks = null,
isAtomic = false,
} = object } = object
const range = new Range({ const range = new Range({
@@ -133,6 +137,7 @@ class Range extends Record(DEFAULTS) {
isBackward, isBackward,
isFocused, isFocused,
marks: marks == null ? null : new Set(marks.map(Mark.fromJSON)), marks: marks == null ? null : new Set(marks.map(Mark.fromJSON)),
isAtomic,
}) })
return range return range
@@ -779,6 +784,7 @@ class Range extends Record(DEFAULTS) {
isFocused: this.isFocused, isFocused: this.isFocused,
marks: marks:
this.marks == null ? null : this.marks.toArray().map(m => m.toJSON()), this.marks == null ? null : this.marks.toArray().map(m => m.toJSON()),
isAtomic: this.isAtomic,
} }
return object return object

View File

@@ -675,6 +675,24 @@ class Value extends Record(DEFAULTS) {
delete object.selection.focusKey delete object.selection.focusKey
} }
if (
options.preserveDecorations &&
object.decorations &&
!options.preserveKeys
) {
const { document } = this
object.decorations = object.decorations.map(decoration => {
const withPath = {
...decoration,
anchorPath: document.getPath(decoration.anchorKey),
focusPath: document.getPath(decoration.focusKey),
}
delete withPath.anchorKey
delete withPath.focusKey
return withPath
})
}
return object return object
} }

View File

@@ -10,6 +10,65 @@ import Operation from '../models/operation'
const debug = Debug('slate:operation:apply') const debug = Debug('slate:operation:apply')
/**
* Apply adjustments to affected ranges (selections, decorations);
* accepts (value, checking function(range) -> bool, applying function(range) -> range)
* returns value with affected ranges updated
*
* @param {Value} value
* @param {Function} checkAffected
* @param {Function} adjustRange
* @return {Value}
*/
function applyRangeAdjustments(value, checkAffected, adjustRange) {
// check selection, apply adjustment if affected
if (value.selection && checkAffected(value.selection)) {
value = value.set('selection', adjustRange(value.selection))
}
if (!value.decorations) return value
// check all ranges, apply adjustment if affected
const decorations = value.decorations
.map(
decoration =>
checkAffected(decoration) ? adjustRange(decoration) : decoration
)
.filter(decoration => decoration.anchorKey !== null)
return value.set('decorations', decorations)
}
/**
* clear any atomic ranges (in decorations) if they contain the point (key, offset, offset-end?)
* specified
*
* @param {Value} value
* @param {String} key
* @param {Number} offset
* @param {Number?} offsetEnd
* @return {Value}
*/
function clearAtomicRangesIfContains(value, key, offset, offsetEnd = null) {
return applyRangeAdjustments(
value,
range => {
if (!range.isAtomic) return false
const { startKey, startOffset, endKey, endOffset } = range
return (
(startKey == key &&
startOffset < offset &&
(endKey != key || endOffset > offset)) ||
(offsetEnd &&
startKey == key &&
startOffset < offsetEnd &&
(endKey != key || endOffset > offsetEnd))
)
},
range => range.deselect()
)
}
/** /**
* Applying functions. * Applying functions.
* *
@@ -65,23 +124,37 @@ const APPLIERS = {
insert_text(value, operation) { insert_text(value, operation) {
const { path, offset, text, marks } = operation const { path, offset, text, marks } = operation
let { document, selection } = value let { document } = value
const { anchorKey, focusKey, anchorOffset, focusOffset } = selection
let node = document.assertPath(path) let node = document.assertPath(path)
// Update the document // Update the document
node = node.insertText(offset, text, marks) node = node.insertText(offset, text, marks)
document = document.updateNode(node) document = document.updateNode(node)
// Update the selection value = value.set('document', document)
if (anchorKey == node.key && anchorOffset >= offset) {
selection = selection.moveAnchor(text.length) // if insert happens within atomic ranges, clear
} value = clearAtomicRangesIfContains(value, node.key, offset)
if (focusKey == node.key && focusOffset >= offset) {
selection = selection.moveFocus(text.length) // Update the selection, decorations
} value = applyRangeAdjustments(
value,
({ anchorKey, anchorOffset, isBackward, isAtomic }) =>
anchorKey == node.key &&
(anchorOffset > offset ||
(anchorOffset == offset && (!isAtomic || !isBackward))),
range => range.moveAnchor(text.length)
)
value = applyRangeAdjustments(
value,
({ focusKey, focusOffset, isBackward, isAtomic }) =>
focusKey == node.key &&
(focusOffset > offset ||
(focusOffset == offset && (!isAtomic || isBackward))),
range => range.moveFocus(text.length)
)
value = value.set('document', document).set('selection', selection)
return value return value
}, },
@@ -98,7 +171,7 @@ const APPLIERS = {
const withPath = path const withPath = path
.slice(0, path.length - 1) .slice(0, path.length - 1)
.concat([path[path.length - 1] - 1]) .concat([path[path.length - 1] - 1])
let { document, selection } = value let { document } = value
const one = document.assertPath(withPath) const one = document.assertPath(withPath)
const two = document.assertPath(path) const two = document.assertPath(path)
let parent = document.getParent(one.key) let parent = document.getParent(one.key)
@@ -108,36 +181,31 @@ const APPLIERS = {
// Perform the merge in the document. // Perform the merge in the document.
parent = parent.mergeNode(oneIndex, twoIndex) parent = parent.mergeNode(oneIndex, twoIndex)
document = document.updateNode(parent) document = document.updateNode(parent)
value = value.set('document', document)
// 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.object == 'text') { if (one.object == 'text') {
const { anchorKey, anchorOffset, focusKey, focusOffset } = selection value = applyRangeAdjustments(
let normalize = false value,
// If the nodes are text nodes and the range is inside the second node:
if (anchorKey == two.key) { ({ anchorKey, focusKey }) =>
selection = selection.moveAnchorTo( anchorKey == two.key || focusKey == two.key,
one.key, // update it to refer to the first node instead:
one.text.length + anchorOffset range => {
) if (range.anchorKey == two.key)
normalize = true range = range.moveAnchorTo(
} one.key,
one.text.length + range.anchorOffset
if (focusKey == two.key) { )
selection = selection.moveFocusTo( if (range.focusKey == two.key)
one.key, range = range.moveFocusTo(
one.text.length + focusOffset one.key,
) one.text.length + range.focusOffset
normalize = true )
} return range.normalize(document)
}
if (normalize) { )
selection = selection.normalize(document)
}
} }
// Update the document and selection.
value = value.set('document', document).set('selection', selection)
return value return value
}, },
@@ -222,44 +290,38 @@ const APPLIERS = {
remove_node(value, operation) { remove_node(value, operation) {
const { path } = operation const { path } = operation
let { document, selection } = value let { document, selection } = value
const { startKey, endKey } = selection
const node = document.assertPath(path) const node = document.assertPath(path)
// If the selection is set, check to see if it needs to be updated. if (selection.isSet || value.decorations !== null) {
if (selection.isSet) {
const hasStartNode = node.hasNode(startKey)
const hasEndNode = node.hasNode(endKey)
const first = node.object == 'text' ? node : node.getFirstText() || node const first = node.object == 'text' ? node : node.getFirstText() || node
const last = node.object == 'text' ? node : node.getLastText() || node const last = node.object == 'text' ? node : node.getLastText() || node
const prev = document.getPreviousText(first.key) const prev = document.getPreviousText(first.key)
const next = document.getNextText(last.key) const next = document.getNextText(last.key)
// If the start point was in this node, update it to be just before/after. value = applyRangeAdjustments(
if (hasStartNode) { value,
if (prev) { // If the start or end point was in this node
selection = selection.moveStartTo(prev.key, prev.text.length) ({ startKey, endKey }) =>
} else if (next) { node.hasNode(startKey) || node.hasNode(endKey),
selection = selection.moveStartTo(next.key, 0) // update it to be just before/after
} else { range => {
selection = selection.deselect() const { startKey, endKey } = range
}
}
// If the end point was in this node, update it to be just before/after. if (node.hasNode(startKey)) {
if (selection.isSet && hasEndNode) { range = prev
if (prev) { ? range.moveStartTo(prev.key, prev.text.length)
selection = selection.moveEndTo(prev.key, prev.text.length) : next ? range.moveStartTo(next.key, 0) : range.deselect()
} else if (next) { }
selection = selection.moveEndTo(next.key, 0) if (node.hasNode(endKey)) {
} else { range = prev
selection = selection.deselect() ? range.moveEndTo(prev.key, prev.text.length)
: next ? range.moveEndTo(next.key, 0) : range.deselect()
}
// If the range wasn't deselected, normalize it.
if (range.isSet) return range.normalize(document)
return range
} }
} )
// If the selection wasn't deselected, normalize it.
if (selection.isSet) {
selection = selection.normalize(document)
}
} }
// Remove the node from the document. // Remove the node from the document.
@@ -268,8 +330,8 @@ const APPLIERS = {
parent = parent.removeNode(index) parent = parent.removeNode(index)
document = document.updateNode(parent) document = document.updateNode(parent)
// Update the document and selection. // Update the document and range.
value = value.set('document', document).set('selection', selection) value = value.set('document', document)
return value return value
}, },
@@ -285,29 +347,47 @@ const APPLIERS = {
const { path, offset, text } = operation const { path, offset, text } = operation
const { length } = text const { length } = text
const rangeOffset = offset + length const rangeOffset = offset + length
let { document, selection } = value let { document } = value
const { anchorKey, focusKey, anchorOffset, focusOffset } = selection
let node = document.assertPath(path) let node = document.assertPath(path)
if (anchorKey == node.key) { // if insert happens within atomic ranges, clear
if (anchorOffset >= rangeOffset) { value = clearAtomicRangesIfContains(
selection = selection.moveAnchor(-length) value,
} else if (anchorOffset > offset) { node.key,
selection = selection.moveAnchorTo(anchorKey, offset) offset,
} offset + length
} )
if (focusKey == node.key) { value = applyRangeAdjustments(
if (focusOffset >= rangeOffset) { value,
selection = selection.moveFocus(-length) // if anchor of range is here
} else if (focusOffset > offset) { ({ anchorKey }) => anchorKey == node.key,
selection = selection.moveFocusTo(focusKey, offset) // adjust if it is in or past the removal range
} range =>
} range.anchorOffset >= rangeOffset
? range.moveAnchor(-length)
: range.anchorOffset > offset
? range.moveAnchorTo(range.anchorKey, offset)
: range
)
value = applyRangeAdjustments(
value,
// if focus of range is here
({ focusKey }) => focusKey == node.key,
// adjust if it is in or past the removal range
range =>
range.focusOffset >= rangeOffset
? range.moveFocus(-length)
: range.focusOffset > offset
? range.moveFocusTo(range.focusKey, offset)
: range
)
node = node.removeText(offset, length) node = node.removeText(offset, length)
document = document.updateNode(node) document = document.updateNode(node)
value = value.set('document', document).set('selection', selection) value = value.set('document', document)
return value return value
}, },
@@ -400,7 +480,7 @@ const APPLIERS = {
split_node(value, operation) { split_node(value, operation) {
const { path, position, properties } = operation const { path, position, properties } = operation
let { document, selection } = value let { document } = value
// Calculate a few things... // Calculate a few things...
const node = document.assertPath(path) const node = document.assertPath(path)
@@ -416,32 +496,37 @@ const APPLIERS = {
} }
} }
document = document.updateNode(parent) document = document.updateNode(parent)
// Determine whether we need to update the selection...
const { startKey, endKey, startOffset, endOffset } = selection
const next = document.getNextText(node.key) const next = document.getNextText(node.key)
let normalize = false
// If the start point is after or equal to the split, update it. value = applyRangeAdjustments(
if (node.key == startKey && position <= startOffset) { value,
selection = selection.moveStartTo(next.key, startOffset - position) // check if range is affected
normalize = true ({ startKey, startOffset, endKey, endOffset }) =>
} (node.key == startKey && position <= startOffset) ||
(node.key == endKey && position <= endOffset),
// update its start / end as needed
range => {
const { startKey, startOffset, endKey, endOffset } = range
let normalize = false
// If the end point is after or equal to the split, update it. if (node.key == startKey && position <= startOffset) {
if (node.key == endKey && position <= endOffset) { range = range.moveStartTo(next.key, startOffset - position)
selection = selection.moveEndTo(next.key, endOffset - position) normalize = true
normalize = true }
}
// Normalize the selection if we changed it, since the methods we use might if (node.key == endKey && position <= endOffset) {
// leave it in a non-normalized value. range = range.moveEndTo(next.key, endOffset - position)
if (normalize) { normalize = true
selection = selection.normalize(document) }
}
// Normalize the selection if we changed it
if (normalize) return range.normalize(document)
return range
}
)
// Return the updated value. // Return the updated value.
value = value.set('document', document).set('selection', selection) value = value.set('document', document)
return value return value
}, },
} }

View File

@@ -34,6 +34,9 @@ const h = createHyperscript({
u: 'underline', u: 'underline',
fontSize: 'font-size', fontSize: 'font-size',
}, },
decorators: {
highlight: 'highlight',
},
}) })
/** /**

View File

@@ -0,0 +1,61 @@
/** @jsx h */
import h from '../../../helpers/h'
export default [
{
type: 'remove_text',
path: [0, 0],
offset: 13,
text: 'on',
marks: [],
},
{
type: 'remove_text',
path: [1, 0],
offset: 0,
text: 'This ',
marks: [],
},
{
type: 'remove_text',
path: [2, 0],
offset: 10,
text: 'ation',
marks: [],
},
]
export const input = (
<value>
<document>
<paragraph>
This <highlight atomic>decoration</highlight> should be invalid,{' '}
<highlight atomic>this</highlight> one shouldn't.
</paragraph>
<paragraph>
This <highlight atomic>decoration</highlight> will be fine.
</paragraph>
<paragraph>
This <highlight>decoration</highlight> can be altered, since non-atomic.
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
This decorati should be invalid, <highlight atomic>this</highlight> one
shouldn't.
</paragraph>
<paragraph>
<highlight atomic>decoration</highlight> will be fine.
</paragraph>
<paragraph>
This <highlight>decor</highlight> can be altered, since non-atomic.
</paragraph>
</document>
</value>
)

View File

@@ -0,0 +1,61 @@
/** @jsx h */
import h from '../../../helpers/h'
export default [
{
type: 'insert_text',
path: [0, 0],
offset: 6,
text: 'x',
marks: [],
},
{
type: 'insert_text',
path: [1, 0],
offset: 5,
text: 'small ',
marks: [],
},
{
type: 'remove_text',
path: [2, 0],
offset: 10,
text: 'ation',
marks: [],
},
]
export const input = (
<value>
<document>
<paragraph>
This <highlight atomic>decoration</highlight> should be invalid,{' '}
<highlight atomic>this</highlight> one shouldn't.
</paragraph>
<paragraph>
This <highlight atomic>decoration</highlight> will be fine.
</paragraph>
<paragraph>
This <highlight>decoration</highlight> can be altered, since non-atomic.
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
This dxecoration should be invalid, <highlight atomic>this</highlight>{' '}
one shouldn't.
</paragraph>
<paragraph>
This small <highlight atomic>decoration</highlight> will be fine.
</paragraph>
<paragraph>
This <highlight>decor</highlight> can be altered, since non-atomic.
</paragraph>
</document>
</value>
)

View File

@@ -0,0 +1,33 @@
/** @jsx h */
import h from '../../../helpers/h'
export default [
{
type: 'remove_text',
path: [0, 0],
offset: 2,
text: ' there',
marks: [],
},
]
export const input = (
<value>
<document>
<paragraph>
Hi<cursor /> there <highlight>you</highlight> person
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
Hi<cursor /> <highlight>you</highlight> person
</paragraph>
</document>
</value>
)

View File

@@ -0,0 +1,33 @@
/** @jsx h */
import h from '../../../helpers/h'
export default [
{
type: 'insert_text',
path: [0, 0],
offset: 2,
text: ' added',
marks: [],
},
]
export const input = (
<value>
<document>
<paragraph>
Hi<cursor /> there <highlight>you</highlight>
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
Hi added<cursor /> there <highlight>you</highlight>
</paragraph>
</document>
</value>
)

View File

@@ -0,0 +1,45 @@
/** @jsx h */
import h from '../../../helpers/h'
export default [
{
type: 'merge_node',
path: [1],
position: 1,
properties: {},
target: null,
},
// also merge the resulting leaves
{
type: 'merge_node',
path: [0, 1],
position: 1,
properties: {},
target: null,
},
]
export const input = (
<value>
<document>
<paragraph>
The decoration begins<highlight key="1" /> in this paragraph
</paragraph>
<paragraph>
And ends in this soon-to-be merged<highlight key="1" /> one.
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
The decoration begins<highlight key="1" /> in this paragraphAnd ends in
this soon-to-be merged<highlight key="1" /> one.
</paragraph>
</document>
</value>
)

View File

@@ -0,0 +1,34 @@
/** @jsx h */
import h from '../../../helpers/h'
export default [
{
type: 'remove_node',
path: [0],
},
]
export const input = (
<value>
<document>
<paragraph>
The decoration begins<highlight key="1" /> in this soon deleted
paragraph
</paragraph>
<paragraph>
And ends in this <highlight key="1" />one.
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
<highlight key="1" />And ends in this <highlight key="1" />one.
</paragraph>
</document>
</value>
)

View File

@@ -33,9 +33,14 @@ describe('operations', async () => {
const operations = module.default const operations = module.default
const change = input.change() const change = input.change()
change.applyOperations(operations) change.applyOperations(operations)
const opts = { preserveSelection: true, preserveData: true } const opts = {
preserveSelection: true,
preserveDecorations: true,
preserveData: true,
}
const actual = change.value.toJSON(opts) const actual = change.value.toJSON(opts)
const expected = output.toJSON(opts) const expected = output.toJSON(opts)
assert.deepEqual(actual, expected) assert.deepEqual(actual, expected)
}) })
} }

View File

@@ -48,6 +48,7 @@ export const output = {
isBackward: false, isBackward: false,
isFocused: false, isFocused: false,
marks: null, marks: null,
isAtomic: false,
}, },
} }

View File

@@ -45,6 +45,7 @@ export const output = {
isBackward: false, isBackward: false,
isFocused: false, isFocused: false,
marks: null, marks: null,
isAtomic: false,
}, },
} }