substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing system. It is developed to power our online editing platform [Substance](http://substance.io).
367 lines (329 loc) • 10 kB
JavaScript
import isArrayEqual from '../util/isArrayEqual'
import isNumber from '../util/isNumber'
import Selection from './Selection'
import Coordinate from './Coordinate'
/**
* A selection which is bound to a property. Implements {@link model/Selection}.
*
* @example
*
* ```js
* var propSel = doc.createSelection({
* type: 'property',
* path: ['p1', 'content'],
* startOffset: 3,
* endOffset: 6
* })
*/
export default class PropertySelection extends Selection {
/**
* @param {array} path
* @param {int} startOffset
* @param {int} endOffset
* @param {bool} reverse
* @param {string} [containerPath]
* @param {string} [surfaceId]
*/
constructor (path, startOffset, endOffset, reverse, containerPath, surfaceId) {
super()
if (arguments.length === 1) {
const data = arguments[0]
path = data.path
startOffset = data.startOffset
endOffset = data.endOffset
reverse = data.reverse
containerPath = data.containerPath
surfaceId = data.surfaceId
}
if (!path || !isNumber(startOffset)) {
throw new Error('Invalid arguments: `path` and `startOffset` are mandatory')
}
this.start = new Coordinate(path, startOffset)
this.end = new Coordinate(path, isNumber(endOffset) ? endOffset : startOffset)
/**
Selection direction.
@type {Boolean}
*/
this.reverse = Boolean(reverse)
this.containerPath = containerPath
/**
Identifier of the surface this selection should be active in.
@type {String}
*/
this.surfaceId = surfaceId
}
get path () {
return this.start.path
}
get startOffset () {
return this.start.offset
}
get endOffset () {
return this.end.offset
}
/**
Convert container selection to JSON.
@returns {Object}
*/
toJSON () {
return {
type: 'property',
path: this.start.path,
startOffset: this.start.offset,
endOffset: this.end.offset,
reverse: this.reverse,
containerPath: this.containerPath,
surfaceId: this.surfaceId
}
}
isPropertySelection () {
return true
}
getType () {
return 'property'
}
isNull () {
return false
}
isCollapsed () {
return this.start.offset === this.end.offset
}
isReverse () {
return this.reverse
}
equals (other) {
return (
Selection.prototype.equals.call(this, other) &&
(this.start.equals(other.start) && this.end.equals(other.end))
)
}
toString () {
/* istanbul ignore next */
return [
'PropertySelection(', JSON.stringify(this.path), ', ',
this.start.offset, ' -> ', this.end.offset,
(this.reverse ? ', reverse' : ''),
(this.surfaceId ? (', ' + this.surfaceId) : ''),
')'
].join('')
}
/**
Collapse a selection to chosen direction.
@param {String} direction either left of right
@returns {PropertySelection}
*/
collapse (direction) {
var offset
if (direction === 'left') {
offset = this.start.offset
} else {
offset = this.end.offset
}
return this.createWithNewRange(offset, offset)
}
// Helper Methods
// ----------------------
/**
* Get path of a selection, e.g. target property where selected data is stored.
*
* @returns {String[]} path
*/
getPath () {
return this.start.path
}
getNodeId () {
return this.start.path[0]
}
getPropertyName () {
return this.start.path[1]
}
/**
* Checks if this selection is inside another one.
*
* @param {Selection} other
* @param {Boolean} [strict] true if should check that it is strictly inside the other
* @returns {Boolean}
*/
isInsideOf (other, strict) {
if (other.isNull()) return false
if (other.isContainerSelection()) {
return other.contains(this, strict)
}
if (strict) {
return (isArrayEqual(this.path, other.path) &&
this.start.offset > other.start.offset &&
this.end.offset < other.end.offset)
} else {
return (isArrayEqual(this.path, other.path) &&
this.start.offset >= other.start.offset &&
this.end.offset <= other.end.offset)
}
}
/**
* Checks if this selection contains another one.
*
* @param {Selection} other
* @param {Boolean} [strict] true if should check that it is strictly contains the other
* @returns {Boolean}
*/
contains (other, strict) {
if (other.isNull()) return false
return other.isInsideOf(this, strict)
}
/**
* Checks if this selection overlaps another one.
*
* @param {Selection} other
* @param {Boolean} [strict] true if should check that it is strictly overlaps the other
* @returns {Boolean}
*/
overlaps (other, strict) {
if (other.isNull()) return false
if (other.isContainerSelection()) {
// console.log('PropertySelection.overlaps: delegating to ContainerSelection.overlaps...')
return other.overlaps(this)
}
if (!isArrayEqual(this.path, other.path)) return false
if (strict) {
return (!(this.start.offset >= other.end.offset || this.end.offset <= other.start.offset))
} else {
return (!(this.start.offset > other.end.offset || this.end.offset < other.start.offset))
}
}
/**
* Checks if this selection has the right boundary in common with another one.
*
* @param {Selection} other
* @returns {Boolean}
*/
isRightAlignedWith (other) {
if (other.isNull()) return false
if (other.isContainerSelection()) {
// console.log('PropertySelection.isRightAlignedWith: delegating to ContainerSelection.isRightAlignedWith...')
return other.isRightAlignedWith(this)
}
return (isArrayEqual(this.path, other.path) &&
this.end.offset === other.end.offset)
}
/**
* Checks if this selection has the left boundary in common with another one.
*
* @param {Selection} other
* @returns {Boolean}
*/
isLeftAlignedWith (other) {
if (other.isNull()) return false
if (other.isContainerSelection()) {
// console.log('PropertySelection.isLeftAlignedWith: delegating to ContainerSelection.isLeftAlignedWith...')
return other.isLeftAlignedWith(this)
}
return (isArrayEqual(this.path, other.path) &&
this.start.offset === other.start.offset)
}
/**
* Expands selection to include another selection.
*
* @param {Selection} other
* @returns {Selection} a new selection
*/
expand (other) {
if (other.isNull()) return this
// if the other is a ContainerSelection
// we delegate to that implementation as it is more complex
// and can deal with PropertySelections, too
if (other.isContainerSelection()) {
return other.expand(this)
}
if (!isArrayEqual(this.path, other.path)) {
throw new Error('Can not expand PropertySelection to a different property.')
}
var newStartOffset = Math.min(this.start.offset, other.start.offset)
var newEndOffset = Math.max(this.end.offset, other.end.offset)
return this.createWithNewRange(newStartOffset, newEndOffset)
}
/**
* Creates a new selection by truncating this one by another selection.
*
* @param {Selection} other
* @returns {Selection} a new selection
*/
truncateWith (other) {
if (other.isNull()) return this
if (other.isInsideOf(this, 'strict')) {
// the other selection should overlap only on one side
throw new Error('Can not truncate with a contained selections')
}
if (!this.overlaps(other)) {
return this
}
let otherStartOffset, otherEndOffset
if (other.isPropertySelection()) {
otherStartOffset = other.start.offset
otherEndOffset = other.end.offset
} else if (other.isContainerSelection()) {
// either the startPath or the endPath must be the same
if (isArrayEqual(other.start.path, this.start.path)) {
otherStartOffset = other.start.offset
} else {
otherStartOffset = this.start.offset
}
if (isArrayEqual(other.end.path, this.start.path)) {
otherEndOffset = other.end.offset
} else {
otherEndOffset = this.end.offset
}
} else {
return this
}
let newStartOffset
let newEndOffset
if (this.start.offset > otherStartOffset && this.end.offset > otherEndOffset) {
newStartOffset = otherEndOffset
newEndOffset = this.end.offset
} else if (this.start.offset < otherStartOffset && this.end.offset < otherEndOffset) {
newStartOffset = this.start.offset
newEndOffset = otherStartOffset
} else if (this.start.offset === otherStartOffset) {
if (this.end.offset <= otherEndOffset) {
return Selection.nullSelection
} else {
newStartOffset = otherEndOffset
newEndOffset = this.end.offset
}
} else if (this.end.offset === otherEndOffset) {
if (this.start.offset >= otherStartOffset) {
return Selection.nullSelection
} else {
newStartOffset = this.start.offset
newEndOffset = otherStartOffset
}
} else if (other.contains(this)) {
return Selection.nullSelection
} else {
// FIXME: if this happens, we have a bug somewhere above
throw new Error('Illegal state.')
}
return this.createWithNewRange(newStartOffset, newEndOffset)
}
/**
* Creates a new selection with given range and same path.
*
* @param {Number} startOffset
* @param {Number} endOffset
* @returns {Selection} a new selection
*/
createWithNewRange (startOffset, endOffset) {
var sel = new PropertySelection(this.path, startOffset, endOffset, false, this.containerPath, this.surfaceId)
var doc = this._internal.doc
if (doc) {
sel.attach(doc)
}
return sel
}
_clone () {
return new PropertySelection(this.start.path, this.start.offset, this.end.offset, this.reverse, this.containerPath, this.surfaceId)
}
static fromJSON (json) {
return new PropertySelection(json)
}
}