UNPKG

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 systems.

321 lines (296 loc) 9.55 kB
import { forEach, isString } from '../util' const ENTER = 1 const EXIT = -1 const ANCHOR = -2 // Fragmenter // -------- // // An algorithm that is used to fragment overlapping structure elements // following a priority rule set. // E.g., we use this for creating DOM elements for annotations. The annotations // can partially be overlapping. However this is not allowed in general for DOM elements // or other hierarchical structures. // // Example: For the annotation use case consider a 'comment' spanning partially // over an 'emphasis' annotation. // 'The <comment>quick brown <bold>fox</comment> jumps over</bold> the lazy dog.' // We want to be able to create a valid XML structure: // 'The <comment>quick brown <bold>fox</bold></comment><bold> jumps over</bold> the lazy dog.' // // For that one would choose // // { // 'comment': 0, // 'bold': 1 // } // // as priority levels. // In case of structural violations as in the example, elements with a higher level // would be fragmented and those with lower levels would be preserved as one piece. // // TODO: If a violation for nodes of the same level occurs an Error should be thrown. // Currently, in such cases the first element that is opened earlier is preserved. class Fragmenter { constructor(options) { Object.assign(this, options) } start(rootContext, text, annotations) { if (!isString(text)) { throw new Error("Illegal argument: 'text' must be a String, but was " + text) } this._start(rootContext, text, annotations) } onText(context, text, entry) { // eslint-disable-line } // should return the created user context onEnter(entry, parentContext) { // eslint-disable-line return null } onExit(entry, context, parentContext) { // eslint-disable-line } _enter(entry, parentContext) { entry.counter++ return this.onEnter(entry, parentContext) } _exit(entry, context, parentContext) { this.onExit(entry, context, parentContext) } _createText(context, text, entry) { this.onText(context, text, entry) } _start(rootContext, text, annotations) { var entries = _extractEntries.call(this, annotations) var stack = [{context: rootContext, entry: null}] var pos = 0 for (var i = 0; i < entries.length; i++) { var entry = entries[i] var textFragment = text.substring(pos, entry.pos) if (textFragment) { // add the last text to the current element this._createText(stack[stack.length-1].context, textFragment, entry) } pos = entry.pos var stackLevel, idx, _entry if (entry.mode === ENTER || entry.mode === ANCHOR) { // find the correct position and insert an entry for (stackLevel = 1; stackLevel < stack.length; stackLevel++) { if (entry.level < stack[stackLevel].entry.level) { break } } // create elements which are open, and are now stacked ontop of the // entered entry for (idx = stack.length-1; idx >= stackLevel; idx--) { _entry = stack[idx].entry // compute number of characters since last 'enter' _entry.length = pos - _entry.pos this._exit(_entry, stack[idx].context, stack[idx-1].context) } stack.splice(stackLevel, 0, {entry: entry}) // create new elements for all lower entries for (idx = stackLevel; idx < stack.length; idx++) { _entry = stack[idx].entry // bump 'enter' pos _entry.pos = pos stack[idx].context = this._enter(_entry, stack[idx-1].context) } } if (entry.mode === EXIT || entry.mode === ANCHOR) { // find the according entry and remove it from the stack for (stackLevel = 1; stackLevel < stack.length; stackLevel++) { if (stack[stackLevel].entry.node === entry.node) { break } } for (idx = stack.length-1; idx >= stackLevel; idx--) { _entry = stack[idx].entry // compute number of characters since last 'enter' _entry.length = pos - _entry.pos this._exit(_entry, stack[idx].context, stack[idx-1].context) } stack.splice(stackLevel, 1) // create new elements for all lower entries for (idx = stackLevel; idx < stack.length; idx++) { _entry = stack[idx].entry // bump 'enter' pos _entry.pos = pos stack[idx].context = this._enter(_entry, stack[idx-1].context) } } } // Finally append a trailing text node var trailingText = text.substring(pos) if (trailingText) { this._createText(rootContext, trailingText) } } } Fragmenter.SHOULD_NOT_SPLIT = 0 Fragmenter.NORMAL = 10 Fragmenter.ANY = 100 Fragmenter.ALWAYS_ON_TOP = Number.MAX_VALUE // This is a sweep algorithm wich uses a set of ENTER/EXIT entries // to manage a stack of active elements. // Whenever a new element is entered it will be appended to its parent element. // The stack is ordered by the annotation types. // // Examples: // // - simple case: // // [top] -> ENTER(idea1) -> [top, idea1] // // Creates a new 'idea' element and appends it to 'top' // // - stacked ENTER: // // [top, idea1] -> ENTER(bold1) -> [top, idea1, bold1] // // Creates a new 'bold' element and appends it to 'idea1' // // - simple EXIT: // // [top, idea1] -> EXIT(idea1) -> [top] // // Removes 'idea1' from stack. // // - reordering ENTER: // // [top, bold1] -> ENTER(idea1) -> [top, idea1, bold1] // // Inserts 'idea1' at 2nd position, creates a new 'bold1', and appends itself to 'top' // // - reordering EXIT // // [top, idea1, bold1] -> EXIT(idea1)) -> [top, bold1] // // Removes 'idea1' from stack and creates a new 'bold1' // function _extractEntries(annotations) { var openers = [] var closers = [] forEach(annotations, function(a) { var isAnchor = (a.isAnchor ? a.isAnchor() : false) // special treatment for zero-width annos such as ContainerAnnotation.Anchors if (isAnchor) { openers.push({ mode: ANCHOR, pos: a.offset, id: a.id, level: Fragmenter.ALWAYS_ON_TOP, type: 'anchor', node: a, counter: -1, length: 0 }) } else { // TODO better naming, `Node.level` does not say enough // Better would be `Node.fragmentation = Fragmenter.SHOULD_NOT_SPLIT;` // meaning, that the fragmenter should try to render the fragment as one single // element, and not splitting it up on different stack levels. // E.g. When bold an link are overlapping // the fragmenter should not split the link element such as A<b>B<a>CD</a></b><a>EF</a>GH // but should instead A<b>B</b><a><b>CD</b><a>EF</a>GH // use a weak default level when not given var l = Fragmenter.NORMAL var isInline = (a.isInline ? a.isInline() : false) if (isInline) { l = Number.MAX_VALUE } else if (a.constructor.hasOwnProperty('fragmentation')) { l = a.constructor.fragmentation } else if (a.hasOwnProperty('fragmentationHint')) { l = a.fragmentationHint } var startOffset = Math.min(a.start.offset, a.end.offset) var endOffset = Math.max(a.start.offset, a.end.offset) var opener = { pos: startOffset, mode: ENTER, level: l, id: a.id, type: a.type, node: a, length: 0, counter: -1, } openers.push(opener) closers.push({ pos: endOffset, mode: EXIT, level: l, id: a.id, type: a.type, node: a, opener: opener }) } }) // sort the openers openers.sort(_compareOpeners) // store indexes for openers for (var i = openers.length - 1; i >= 0; i--) { openers[i].idx = i } closers.sort(_compareClosers) // merge openers and closers, sorted by pos var entries = new Array(openers.length+closers.length) var idx = 0 var idx1 = 0 var idx2 = 0 var opener = openers[idx1] var closer = closers[idx2] while(opener || closer) { if (opener && closer) { // close before open if (closer.pos <= opener.pos && closer.opener !== opener) { entries[idx] = closer idx2++ } else { entries[idx] = opener idx1++ } } else if (opener) { entries[idx] = opener idx1++ } else if (closer) { entries[idx] = closer idx2++ } opener = openers[idx1] closer = closers[idx2] idx++ } return entries } function _compareOpeners(a, b) { if (a.pos < b.pos) return -1 if (a.pos > b.pos) return 1 if (a.mode < b.mode) return -1 if (a.mode > b.mode) return 1 if (a.mode === b.mode) { if (a.level < b.level) return -1 if (a.level > b.level) return 1 } return 0 } // sort in inverse order of openers function _compareClosers(a, b) { if (a.pos < b.pos) return -1 if (a.pos > b.pos) return 1 // this makes closer be sorted in inverse order of openers // to reduce stack sice // HACK: a bit trial error. When we have to collapsed annotations // at the same position then we want the closers in the same order // as the openers. if (a.pos === a.opener.pos && b.pos === b.opener.pos) { if (a.opener.idx < b.opener.idx) { return -1 } else { return 1 } } if (a.opener.idx > b.opener.idx) return -1 if (a.opener.idx < b.opener.idx) return 1 return 0 } export default Fragmenter