1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-27 09:04:31 +02:00

add support for collapsed cursor marks, fixes #82

This commit is contained in:
Ian Storm Taylor
2016-07-14 09:22:41 -07:00
parent e12e2757a8
commit 87ea0887bf
32 changed files with 662 additions and 208 deletions

View File

@@ -1,10 +1,11 @@
import Document from './document'
import Mark from './mark'
import Selection from './selection'
import Transform from './transform'
import uid from '../utils/uid'
import { Record, Stack } from 'immutable'
import { Record, Set, Stack } from 'immutable'
/**
* History.
@@ -20,6 +21,7 @@ const History = new Record({
*/
const DEFAULTS = {
cursorMarks: new Set(),
document: new Document(),
selection: new Selection(),
history: new History(),
@@ -283,7 +285,10 @@ class State extends new Record(DEFAULTS) {
*/
get marks() {
return this.document.getMarksAtRange(this.selection)
const set = this.document.getMarksAtRange(this.selection)
return this.selection.isExpanded
? set
: set.union(this.cursorMarks)
}
/**
@@ -337,210 +342,6 @@ class State extends new Record(DEFAULTS) {
return new Transform({ state })
}
/**
* Delete at the current selection.
*
* @return {State} state
*/
delete() {
let state = this
let { document, selection } = state
// When collapsed, there's nothing to do.
if (selection.isCollapsed) return state
// Otherwise, delete and update the selection.
document = document.deleteAtRange(selection)
selection = selection.collapseToStart()
state = state.merge({ document, selection })
return state
}
/**
* Delete backward `n` characters at the current selection.
*
* @param {Number} n (optional)
* @return {State} state
*/
deleteBackward(n = 1) {
let state = this
let { document, selection } = state
let after = selection
// Determine what the selection should be after deleting.
const { startKey } = selection
const startNode = document.getDescendant(startKey)
if (selection.isExpanded) {
after = selection.collapseToStart()
}
else if (selection.isAtStartOf(document)) {
after = selection
}
else if (selection.isAtStartOf(startNode)) {
const parent = document.getParent(startNode)
const previous = document.getPreviousSibling(parent).nodes.first()
after = selection.collapseToEndOf(previous)
}
else {
after = selection.moveBackward(n)
}
// Delete backward and then update the selection.
document = document.deleteBackwardAtRange(selection)
selection = after
state = state.merge({ document, selection })
return state
}
/**
* Delete forward `n` characters at the current selection.
*
* @param {Number} n (optional)
* @return {State} state
*/
deleteForward(n = 1) {
let state = this
let { document, selection } = state
let { startKey } = selection
let after = selection
// Determine what the selection should be after deleting.
const block = document.getClosestBlock(startKey)
const inline = document.getClosestInline(startKey)
if (selection.isExpanded) {
after = selection.collapseToStart()
}
else if ((block && block.isVoid) || (inline && inline.isVoid)) {
const next = document.getNextText(startKey)
const previous = document.getPreviousText(startKey)
after = next
? selection.collapseToStartOf(next)
: selection.collapseToEndOf(previous)
}
// Delete forward and then update the selection.
document = document.deleteForwardAtRange(selection)
selection = after
state = state.merge({ document, selection })
return state
}
/**
* Insert a `fragment` at the current selection.
*
* @param {List} fragment
* @return {State} state
*/
insertFragment(fragment) {
let state = this
let { document, selection } = state
let after = selection
// If there's nothing in the fragment, do nothing.
if (!fragment.length) return state
// Lookup some nodes for determining the selection next.
const texts = fragment.getTextNodes()
const lastText = texts.last()
const lastInline = fragment.getClosestInline(lastText)
const startText = document.getDescendant(selection.startKey)
const startBlock = document.getClosestBlock(startText)
const startInline = document.getClosestInline(startText)
const nextText = document.getNextText(startText)
const nextBlock = nextText ? document.getClosestBlock(nextText) : null
const nextNextText = nextText ? document.getNextText(nextText) : null
const docTexts = document.getTextNodes()
// Insert the fragment.
document = document.insertFragmentAtRange(selection, fragment)
// Determine what the selection should be after inserting.
const keys = docTexts.map(text => text.key)
const text = document.getTextNodes().findLast(n => !keys.includes(n.key))
after = text
? selection.collapseToStartOf(text).moveForward(lastText.length)
: selection.collapseToStart().moveForward(lastText.length)
// Update the document and selection.
selection = after
state = state.merge({ document, selection })
return state
}
/**
* Insert a `text` string at the current selection.
*
* @param {String} text
* @return {State} state
*/
insertText(text) {
let state = this
let { document, selection } = state
let after = selection
// Determine what the selection should be after inserting.
if (selection.isExpanded) {
after = selection.collapseToStart()
}
// Insert the text and update the selection.
document = document.insertTextAtRange(selection, text)
selection = after
selection = selection.moveForward(text.length)
state = state.merge({ document, selection })
return state
}
/**
* Add a `mark` to the characters in the current selection.
*
* @param {Mark} mark
* @return {State} state
*/
mark(mark) {
let state = this
let { document, selection } = state
document = document.markAtRange(selection, mark)
state = state.merge({ document })
return state
}
/**
* Move the selection to a specific anchor and focus point.
*
* @param {Object} properties
* @return {State} state
*/
moveTo(properties) {
let state = this
let { document, selection } = state
// Pass in properties, and force `isBackward` to be re-resolved.
selection = selection.merge({
...properties,
isBackward: null
})
selection = selection.normalize(document)
state = state.merge({ selection })
return state
}
/**
* Move the selection to the start of the previous block.
*
@@ -717,6 +518,233 @@ class State extends new Record(DEFAULTS) {
return state
}
/**
* Delete at the current selection.
*
* @return {State} state
*/
delete() {
let state = this
let { document, selection } = state
// When collapsed, there's nothing to do.
if (selection.isCollapsed) return state
// Otherwise, delete and update the selection.
document = document.deleteAtRange(selection)
selection = selection.collapseToStart()
state = state.merge({ document, selection })
return state
}
/**
* Delete backward `n` characters at the current selection.
*
* @param {Number} n (optional)
* @return {State} state
*/
deleteBackward(n = 1) {
let state = this
let { document, selection } = state
let after = selection
// Determine what the selection should be after deleting.
const { startKey } = selection
const startNode = document.getDescendant(startKey)
if (selection.isExpanded) {
after = selection.collapseToStart()
}
else if (selection.isAtStartOf(document)) {
after = selection
}
else if (selection.isAtStartOf(startNode)) {
const parent = document.getParent(startNode)
const previous = document.getPreviousSibling(parent).nodes.first()
after = selection.collapseToEndOf(previous)
}
else {
after = selection.moveBackward(n)
}
// Delete backward and then update the selection.
document = document.deleteBackwardAtRange(selection)
selection = after
state = state.merge({ document, selection })
return state
}
/**
* Delete forward `n` characters at the current selection.
*
* @param {Number} n (optional)
* @return {State} state
*/
deleteForward(n = 1) {
let state = this
let { document, selection } = state
let { startKey } = selection
let after = selection
// Determine what the selection should be after deleting.
const block = document.getClosestBlock(startKey)
const inline = document.getClosestInline(startKey)
if (selection.isExpanded) {
after = selection.collapseToStart()
}
else if ((block && block.isVoid) || (inline && inline.isVoid)) {
const next = document.getNextText(startKey)
const previous = document.getPreviousText(startKey)
after = next
? selection.collapseToStartOf(next)
: selection.collapseToEndOf(previous)
}
// Delete forward and then update the selection.
document = document.deleteForwardAtRange(selection)
selection = after
state = state.merge({ document, selection })
return state
}
/**
* Insert a `fragment` at the current selection.
*
* @param {List} fragment
* @return {State} state
*/
insertFragment(fragment) {
let state = this
let { document, selection } = state
let after = selection
// If there's nothing in the fragment, do nothing.
if (!fragment.length) return state
// Lookup some nodes for determining the selection next.
const texts = fragment.getTextNodes()
const lastText = texts.last()
const lastInline = fragment.getClosestInline(lastText)
const startText = document.getDescendant(selection.startKey)
const startBlock = document.getClosestBlock(startText)
const startInline = document.getClosestInline(startText)
const nextText = document.getNextText(startText)
const nextBlock = nextText ? document.getClosestBlock(nextText) : null
const nextNextText = nextText ? document.getNextText(nextText) : null
const docTexts = document.getTextNodes()
// Insert the fragment.
document = document.insertFragmentAtRange(selection, fragment)
// Determine what the selection should be after inserting.
const keys = docTexts.map(text => text.key)
const text = document.getTextNodes().findLast(n => !keys.includes(n.key))
after = text
? selection.collapseToStartOf(text).moveForward(lastText.length)
: selection.collapseToStart().moveForward(lastText.length)
// Update the document and selection.
selection = after
state = state.merge({ document, selection })
return state
}
/**
* Insert a `text` string at the current selection.
*
* @param {String} text
* @return {State} state
*/
insertText(text) {
let state = this
let { cursorMarks, document, selection } = state
let after = selection
// Determine what the selection should be after inserting.
if (selection.isExpanded) {
after = selection
.collapseToStart()
.moveForward(text.length)
}
else {
after = selection.moveForward(text.length)
}
// Insert the text.
document = document.insertTextAtRange(selection, text)
// If there are any marks on the cursor, apply them.
if (cursorMarks.size) {
const range = after.extendBackward(text.length).normalize(document)
cursorMarks.forEach((mark) => {
document = document.markAtRange(range, mark)
})
}
// Update the selection and the state.
selection = after
state = state.merge({ document, selection })
return state
}
/**
* Add a `mark` to the characters in the current selection.
*
* @param {Mark} mark
* @return {State} state
*/
mark(mark) {
let state = this
let { cursorMarks, document, selection } = state
// If the selection is collapsed, add the mark to the cursor instead.
if (selection.isCollapsed) {
if (typeof mark == 'string') mark = new Mark({ type: mark })
cursorMarks = cursorMarks.add(mark)
return state.merge({ cursorMarks })
}
document = document.markAtRange(selection, mark)
state = state.merge({ document })
return state
}
/**
* Move the selection to a specific anchor and focus point.
*
* @param {Object} properties
* @return {State} state
*/
moveTo(properties) {
let state = this
let { document, selection } = state
// Pass in properties, and force `isBackward` to be re-resolved.
selection = selection.merge({
...properties,
isBackward: null
})
selection = selection.normalize(document)
state = state.merge({ selection })
return state
}
/**
* Set `properties` of the block nodes in the current selection.
*
@@ -806,7 +834,15 @@ class State extends new Record(DEFAULTS) {
unmark(mark) {
let state = this
let { document, selection } = state
let { cursorMarks, document, selection } = state
// If the selection is collapsed, remove the mark to the cursor instead.
if (selection.isCollapsed) {
if (typeof mark == 'string') mark = new Mark({ type: mark })
cursorMarks = cursorMarks.remove(mark)
return state.merge({ cursorMarks })
}
document = document.unmarkAtRange(selection, mark)
state = state.merge({ document })
return state

View File

@@ -145,7 +145,7 @@ class Transform extends new Record(DEFAULT_PROPERTIES) {
apply(options = {}) {
const transform = this
let { state, steps } = transform
let { history } = state
let { cursorMarks, history, selection } = state
let { undos, redos } = history
// Determine whether we need to create a new snapshot.
@@ -186,6 +186,12 @@ class Transform extends new Record(DEFAULT_PROPERTIES) {
// Apply each of the steps in the transform, arriving at a new state.
state = steps.reduce((memo, step) => this.applyStep(memo, step), state)
// If the selection has changed, clear any existing cursor marks.
if (state.selection != selection) {
cursorMarks = cursorMarks.clear()
state = state.merge({ cursorMarks })
}
// Apply the "isNative" flag, which is used to allow for natively-handled
// content changes to skip rerendering the editor for performance.
state = state.merge({

View File

@@ -0,0 +1,18 @@
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
const second = texts.last()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: 2,
focusKey: second.key,
focusOffset: 2
})
.mark('bold')
.apply()
}

View File

@@ -0,0 +1,14 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: word
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: another

View File

@@ -0,0 +1,20 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: wo
- text: rd
marks:
- type: bold
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: an
marks:
- type: bold
- text: other

View File

@@ -0,0 +1,18 @@
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
const second = texts.last()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: 2,
focusKey: second.key,
focusOffset: 2
})
.mark('bold')
.apply()
}

View File

@@ -0,0 +1,20 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: inline
type: link
nodes:
- kind: text
ranges:
- text: word
- kind: block
type: paragraph
nodes:
- kind: inline
type: link
nodes:
- kind: text
ranges:
- text: another

View File

@@ -0,0 +1,26 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: inline
type: link
nodes:
- kind: text
ranges:
- text: wo
- text: rd
marks:
- type: bold
- kind: block
type: paragraph
nodes:
- kind: inline
type: link
nodes:
- kind: text
ranges:
- text: an
marks:
- type: bold
- text: other

View File

@@ -0,0 +1,18 @@
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: 0,
focusKey: first.key,
focusOffset: 0
})
.mark('bold')
.insertText('a')
.apply()
}

View File

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

View File

@@ -0,0 +1,11 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: a
marks:
- type: bold
- text: word

View File

@@ -0,0 +1,17 @@
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: 0,
focusKey: first.key,
focusOffset: 2
})
.mark('bold')
.apply()
}

View File

@@ -0,0 +1,10 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: word
marks:
- type: italic

View File

@@ -0,0 +1,14 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: wo
marks:
- type: italic
- type: bold
- text: rd
marks:
- type: italic

View File

@@ -0,0 +1,17 @@
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: 0,
focusKey: first.key,
focusOffset: 1
})
.mark('bold')
.apply()
}

View File

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

View File

@@ -0,0 +1,11 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: w
marks:
- type: bold
- text: ord

View File

@@ -0,0 +1,17 @@
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: first.length - 1,
focusKey: first.key,
focusOffset: first.length
})
.mark('bold')
.apply()
}

View File

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

View File

@@ -0,0 +1,11 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: wor
- text: d
marks:
- type: bold

View File

@@ -0,0 +1,17 @@
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: 1,
focusKey: first.key,
focusOffset: 2
})
.mark('bold')
.apply()
}

View File

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

View File

@@ -0,0 +1,12 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: w
- text: o
marks:
- type: bold
- text: rd

View File

@@ -0,0 +1,17 @@
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: 0,
focusKey: first.key,
focusOffset: first.length
})
.mark('bold')
.apply()
}

View File

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

View File

@@ -0,0 +1,10 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: word
marks:
- type: bold

View File

@@ -0,0 +1,17 @@
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: 0,
focusKey: first.key,
focusOffset: 1
})
.mark('bold', { key: 'value' })
.apply()
}

View File

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

View File

@@ -0,0 +1,11 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: w
marks:
- type: bold
- text: ord

View File

@@ -0,0 +1,19 @@
import { Data } from '../../../../..'
export default function (state) {
const { document, selection } = state
const texts = document.getTextNodes()
const first = texts.first()
return state
.transform()
.moveTo({
anchorKey: first.key,
anchorOffset: 0,
focusKey: first.key,
focusOffset: 1
})
.mark('bold', Data.create({ key: 'value' }))
.apply()
}

View File

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

View File

@@ -0,0 +1,11 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
ranges:
- text: w
marks:
- type: bold
- text: ord