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.
349 lines (313 loc) • 9.81 kB
JavaScript
import { forEach, deleteFromArray, ArrayTree } from '../util'
import { Marker } from '../model'
/*
MarkersManager keeps track of any Markers, which are annotations
owned by the application, as opposed to real annotations part
of the document.
In addition to that, MarkersManager tracks changes to any text properties
scheduling rerenderings of dirty properties together with any changed
annotations.
NOTE: this functionality is somewhat misplaced, but still here it
is the best place we have so far. In a flux way of thinking, this instance
derives the app state regarding text property states
and dispatches them to all relevant components.
*/
class MarkersManager {
constructor(editorSession) {
this.editorSession = editorSession
// registry
this._textProperties = {}
this._dirtyProps = {}
this._markers = new MarkersIndex(this)
// keep markers up-to-date, and record which text properties
// are affected by a change
editorSession.onUpdate(this._onChange, this)
// trigger rerendering of 'dirty' text properties
editorSession.onRender(this._updateProperties, this)
}
dispose() {
this.editorSession.off(this)
this._markers.dispose()
}
setMarkers(key, markers) {
this.clearMarkers(key)
markers.forEach(m => this.addMarker(key, m))
}
addMarker(key, marker) {
marker._key = key
if (!marker._isMarker) {
marker = new Marker(this.editorSession.getDocument(), marker)
}
this._markers.add(marker)
}
clearMarkers(key) {
this._markers.clear(key)
}
/*
Used internally by TextPropertyComponent to register for updates.
*/
register(textProperyComponent) {
let path = String(textProperyComponent.getPath())
// console.log('registering property', path)
let textProperties = this._textProperties[path]
if (!textProperties) {
textProperties = this._textProperties[path] = []
}
textProperties.push(textProperyComponent)
}
deregister(textProperyComponent) {
let path = String(textProperyComponent.getPath())
// console.log('deregistering property', path)
let textProperties = this._textProperties[path]
if (!textProperties) {
// FIXME: happens in test suite
return
}
deleteFromArray(this._textProperties[path], textProperyComponent)
if (textProperties.length === 0) {
delete this._textProperties[path]
}
}
getMarkers(path, opts) {
opts = opts || {}
let doc = this.editorSession.getDocument()
let annos = doc.getAnnotations(path) || []
let markers = this._markers.get(path, opts.surfaceId, opts.containerId)
return annos.concat(markers)
}
_onChange(editorSession) {
if (editorSession.hasDocumentChanged()) {
const change = editorSession.getChange()
this._markers._onDocumentChange(change)
this._recordDirtyTextProperties(change)
}
}
_recordDirtyTextProperties(change) {
// mark all updated props per se as dirty
forEach(change.updated, (val, id) => {
this._dirtyProps[id] = true
})
}
/*
Trigger rerendering of all dirty text properties.
*/
_updateProperties() {
// console.log('MarkersManager._updateProperties()')
Object.keys(this._dirtyProps).forEach((path) => {
let textProperties = this._textProperties[path]
if (textProperties) {
textProperties.forEach(this._updateTextProperty.bind(this))
}
})
this._dirtyProps = {}
}
/*
Here a dirty text property is rerendered via calling setState()
*/
_updateTextProperty(textPropertyComponent) {
let path = textPropertyComponent.getPath()
let markers = this.getMarkers(path, {
surfaceId: textPropertyComponent.getSurfaceId(),
containerId: textPropertyComponent.getContainerId()
})
// console.log('## providing %s markers for %s', markers.length, path)
textPropertyComponent.setState({
markers: markers
})
}
}
/*
A DocumentIndex implementation for keeping track of markers
*/
class MarkersIndex {
constructor(manager) {
this._manager = manager
this._byKey = new ArrayTree()
this._documentMarkers = new ArrayTree()
this._surfaceMarkers = {}
this._containerMarkers = {}
}
get(path, surfaceId) {
let markers = this._documentMarkers[path] || []
if (surfaceId && this._surfaceMarkers[surfaceId]) {
let surfaceMarkers = this._surfaceMarkers[surfaceId][path]
if (surfaceMarkers) markers = markers.concat(surfaceMarkers)
}
// TODO support container scoped markers
return markers
}
add(marker) {
const key = marker._key
this._byKey.add(key, marker)
this._add(marker)
}
// used to remove a single marker when invalidated
remove(marker) {
const key = marker._key
this._byKey.remove(key, marker)
this._remove(marker)
}
// remove all markers for a given key
clear(key) {
let markers = this._byKey.get(key)
markers.forEach((marker) => {
this._remove(marker)
})
}
_add(marker) {
const dirtyProps = this._manager._dirtyProps
// console.log('Indexing marker', marker)
const scope = marker.scope || 'document'
switch (scope) {
case 'document': {
const path = marker.start.path
// console.log('Adding marker for path', path, marker)
dirtyProps[path] = true
this._documentMarkers.add(path, marker)
break
}
case 'surface': {
if (!this._surfaceMarkers[marker.surfaceId]) {
this._surfaceMarkers[marker.surfaceId] = new ArrayTree()
}
const path = marker.start.path
dirtyProps[path] = true
this._surfaceMarkers[marker.surfaceId].add(path, marker)
break
}
case 'container': {
console.warn('Container scoped markers are not supported yet')
break
}
default:
console.error('Invalid marker scope.')
}
}
_remove(marker) {
const dirtyProps = this._manager._dirtyProps
const scope = marker.scope || 'document'
switch (scope) {
case 'document': {
const path = marker.start.path
dirtyProps[path] = true
this._documentMarkers.remove(path, marker)
break
}
case 'surface': {
if (!this._surfaceMarkers[marker.surfaceId]) {
this._surfaceMarkers[marker.surfaceId] = new ArrayTree()
}
const path = marker.start.path
dirtyProps[path] = true
this._surfaceMarkers[marker.surfaceId].remove(path, marker)
break
}
case 'container': {
console.warn('Container scoped markers are not supported yet')
break
}
default:
console.error('Invalid marker scope.')
}
}
// used for applying transformations
_getAllCustomMarkers(path) {
let markers = this._documentMarkers[path] || []
for(let surfaceId in this._surfaceMarkers) {
if (!this._surfaceMarkers.hasOwnProperty(surfaceId)) continue
let surfaceMarkers = this._surfaceMarkers[surfaceId][path]
if (surfaceMarkers) markers = markers.concat(surfaceMarkers)
}
// TODO: support container markers
return markers
}
_onDocumentChange(change) {
change.ops.forEach((op) => {
if (op.type === 'update' && op.diff._isTextOperation) {
let markers = this._getAllCustomMarkers(op.path)
let diff = op.diff
switch (diff.type) {
case 'insert':
this._transformInsert(markers, diff)
break
case 'delete':
this._transformDelete(markers, diff)
break
default:
//
}
}
})
}
_transformInsert(markers, op) {
const pos = op.pos
const length = op.str.length
if (length === 0) return
markers.forEach((marker) => {
// console.log('Transforming marker after insert')
var start = marker.start.offset;
var end = marker.end.offset;
var newStart = start;
var newEnd = end;
if (pos >= end) return
if (pos <= start) {
newStart += length
newEnd += length
marker.start.offset = newStart
marker.end.offset = newEnd
return
}
if (pos < end) {
newEnd += length;
marker.end.offset = newEnd
// NOTE: right now, any change inside a marker
// removes the marker, as opposed to changes before
// which shift the marker
this._remove(marker)
}
})
}
_transformDelete(markers, op) {
const pos1 = op.pos
const length = op.str.length
const pos2 = pos1 + length
if (pos1 === pos2) return
markers.forEach((marker) => {
var start = marker.start.offset;
var end = marker.end.offset;
var newStart = start;
var newEnd = end;
if (pos2 <= start) {
newStart -= length;
newEnd -= length;
marker.start.offset = newStart
marker.end.offset = newEnd
} else if (pos1 >= end) {
// nothing
}
// the marker needs to be changed
// now, there might be cases where the marker gets invalid, such as a spell-correction
else {
if (pos1 <= start) {
newStart = start - Math.min(pos2-pos1, start-pos1);
}
if (pos1 <= end) {
newEnd = end - Math.min(pos2-pos1, end-pos1);
}
// TODO: we should do something special when the change occurred inside the marker
if (start !== end && newStart === newEnd) {
this._remove(marker)
return
}
if (start !== newStart) {
marker.start.offset = newStart
}
if (end !== newEnd) {
marker.end.offset = newEnd
}
this._remove(marker)
}
})
}
}
export default MarkersManager