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).
262 lines (248 loc) • 8.26 kB
JavaScript
import isString from '../util/isString'
const OPEN = 1
const CLOSE = -1
const ANCHOR = -2
export default class Fragmenter {
onText (context, text, fragment) {}
onOpen (fragment, parentContext) { return {} }
onClose (fragment, context, parentContext) {}
start (rootContext, text, annotations) {
if (!isString(text)) {
throw new Error("Illegal argument: 'text' must be a String, but was " + text)
}
const state = this._init(rootContext, text, annotations)
const B = state.boundaries
const S = state.stack
const TOP = () => S[S.length - 1]
let currentPos = 0
let __runs = 0
const MAX_RUNS = B.length * 2
while (B.length > 0) {
__runs++
if (__runs > MAX_RUNS) throw new Error('FIXME: infinity loop in Fragmenter implementation')
const b = B.shift()
const topContext = TOP().context
if (b.offset > currentPos) {
const textFragment = text.slice(currentPos, b.offset)
this.onText(topContext, textFragment)
currentPos = b.offset
}
switch (b.type) {
case ANCHOR: {
const parentContext = topContext
const anchorContext = this.onOpen(b, parentContext)
this._close(b, anchorContext, parentContext)
break
}
case CLOSE: {
// ATTENTION: we have to make sure that closers are sorted correctly
const { context, entry } = TOP()
if (entry.node !== b.node) {
B.unshift(b)
this._fixOrderOfClosers(S, B, 0)
// restart this iteration
continue
}
S.pop()
const parentContext = TOP().context
this._close(b, context, parentContext)
break
}
case OPEN: {
const a = TOP().entry
if (!a || a.endOffset >= b.endOffset) {
b.stackLevel = S.length
const context = this.onOpen(b, topContext)
S.push({ context, entry: b })
} else {
// splitting annotation b
if (b.weight <= a.weight) {
b.stackLevel = S.length
// new closer at the splitting pos
const closer = {
type: CLOSE,
offset: a.endOffset,
node: b.node,
opener: b
}
// and re-opening with fragment counter increased
const opener = {
type: OPEN,
offset: a.endOffset,
node: b.node,
fragmentCount: b.fragmentCount + 1,
endOffset: b.endOffset,
weight: b.weight,
// attaching the original closer
closer: b.closer
}
// and vice-versa
b.closer.opener = opener
// and fixing b for sake of consistency
b.closer = closer
b.endOffset = a.endOffset
this._insertBoundary(B, closer)
this._insertBoundary(B, opener)
const context = this.onOpen(b, topContext)
S.push({ context, entry: b })
// splitting annotation a
} else {
// In this case we put boundary back
// and instead insert boundaries splitting annotation a
B.unshift(b)
// new closer at the splitting pos
const closer = {
type: CLOSE,
offset: b.offset,
node: a.node,
opener: a
}
// and re-opening with fragment counter increased
const opener = {
type: OPEN,
offset: b.offset,
node: a.node,
fragmentCount: a.fragmentCount + 1,
endOffset: a.endOffset,
weight: a.weight,
// attaching the original closer
closer: a.closer
}
// .. and vice-versa
a.closer.opener = opener
// and fixing b for sake of consistency
a.closer = closer
a.endOffset = b.offset
this._insertBoundary(B, closer)
this._insertBoundary(B, opener)
continue
}
}
break
}
default:
//
}
}
// Finally append a trailing text node
const trailingText = text.substring(currentPos)
if (trailingText) {
this.onText(rootContext, trailingText)
}
}
_init (rootContext, text, annotations) {
const boundaries = []
annotations.forEach(a => {
if (a.isAnchor() || a.start.offset === a.end.offset) {
boundaries.push({
type: ANCHOR,
offset: a.start.offset,
endOffset: a.start.offset,
length: 0,
node: a
})
} else {
const opener = {
type: OPEN,
offset: a.start.offset,
node: a,
fragmentCount: 0,
endOffset: a.end.offset,
weight: a._getFragmentWeight()
}
const closer = {
type: CLOSE,
offset: a.end.offset,
node: a,
opener
}
opener.closer = closer
boundaries.push(opener)
boundaries.push(closer)
}
})
boundaries.sort(this._compareBoundaries.bind(this))
const state = {
stack: [{ context: rootContext, entry: null }],
boundaries
}
return state
}
_close (fragment, context, parentContext) {
if (fragment.type === CLOSE) {
fragment = fragment.opener
fragment.length = fragment.endOffset - fragment.offset
}
this.onClose(fragment, context, parentContext)
}
_compareBoundaries (a, b) {
if (a.offset < b.offset) return -1
if (a.offset > b.offset) return 1
if (a.type < b.type) return -1
if (a.type > b.type) return 1
if (a.type === OPEN) {
if (a.endOffset > b.endOffset) return -1
if (a.endOffset < b.endOffset) return 1
if (a.weight > b.weight) return -1
if (a.weight < b.weight) return 1
if (a.stackLevel && b.stackLevel) {
return a.stackLevel - b.stackLevel
}
return 0
} else if (a.type === CLOSE) {
return -this._compareBoundaries(a.opener, b.opener)
} else {
return 0
}
}
_insertBoundary (B, b, startIndex = 0) {
for (let idx = startIndex, l = B.length; idx < l; idx++) {
if (this._compareBoundaries(b, B[idx]) === -1) {
B.splice(idx, 0, b)
return idx
}
}
// if not inserted before, append
B.push(b)
return B.length - 1
}
// Note: due to fragmentation of overlapping nodes, the original
// order of closers might become invalid
_fixOrderOfClosers (S, B, startIndex) {
const activeOpeners = {}
const first = B[startIndex]
const closers = [first]
for (let idx = startIndex + 1, l = B.length; idx < l; idx++) {
const b = B[startIndex + idx]
if (b.type !== CLOSE || b.offset !== first.offset) break
closers.push(b)
}
for (let idx = S.length - 1; idx >= 1; idx--) {
const opener = S[idx].entry
activeOpeners[opener.node.id] = opener
}
for (let idx = 0, l = closers.length; idx < l; idx++) {
const closer = closers[idx]
const opener = activeOpeners[closer.node.id]
if (!opener) {
throw new Error('Fragmenter Error: there is no opener for closer')
}
closer.opener = opener
}
closers.sort(this._compareBoundaries.bind(this))
const _checkClosers = () => {
for (let idx = 0; idx < closers.length; idx++) {
if (S[S.length - 1 - idx].entry.node !== closers[idx].node) return false
}
return true
}
console.assert(_checkClosers(), 'Fragmenter: closers should be alligned with the current stack of elements')
B.splice(startIndex, closers.length, ...closers)
}
// Fragment weight values that are used to influence how fragments
// get stacked when they are overlapping
static get MUST_NOT_SPLIT () { return Number.MAX_VALUE }
static get SHOULD_NOT_SPLIT () { return 1000 }
static get NORMAL () { return 100 }
static get ALWAYS_ON_TOP () { return 0 }
}