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.
404 lines (361 loc) • 11.3 kB
JavaScript
import { diff, isNumber } from '../util'
import { DefaultDOMElement } from '../dom'
import { Coordinate } from '../model'
import Component from './Component'
import AnnotatedTextComponent from './AnnotatedTextComponent'
import CursorComponent from './CursorComponent'
import SelectionFragmentComponent from './SelectionFragmentComponent'
/**
Renders a text property. Used internally by different components to render
editable text.
@class
@component
@extends ui/AnnotatedTextComponent
@prop {String[]} path path to a text property
@prop {String} [tagName] specifies which tag should be used - defaults to `div`
@example
```js
$$(TextProperty, {
path: [ 'paragraph-1', 'content']
})
```
*/
class TextPropertyComponent extends AnnotatedTextComponent {
getInitialState() {
const markersManager = this.context.markersManager
let path = this.getPath()
let markers
if (markersManager) {
// register and get initial set of markers
markersManager.register(this)
markers = markersManager.getMarkers(path, {
surfaceId: this.getSurfaceId(),
containerId: this.getContainerId()
})
} else {
const doc = this.getDocument()
markers = doc.getAnnotations(path)
}
return {
markers: markers
}
}
didMount() {
if (this.context.surface && this.context.surface.hasNativeSpellcheck()) {
this.domObserver = new window.MutationObserver(this._onDomMutations.bind(this));
this.domObserver.observe(this.el.getNativeElement(), { subtree: true, characterData: true, characterDataOldValue: true });
}
}
dispose() {
if (this.context.markersManager) {
this.context.markersManager.deregister(this)
}
}
render($$) {
let path = this.getPath()
let el = this._renderContent($$)
.addClass('sc-text-property')
.attr({
'data-path': path.join('.')
})
.css({
'white-space': 'pre-wrap'
})
if (this.isEmpty()) {
el.addClass('sm-empty')
if (this.props.placeholder) {
el.append(
$$('span').addClass('se-placeholder').append(
this.props.placeholder
)
)
}
}
if (!this.props.withoutBreak) {
el.append($$('br'))
}
return el
}
getAnnotations() {
if (this.props.markers) {
return this.state.markers.concat(this.props.markers)
} else {
return this.state.markers
}
}
_renderFragment($$, fragment) {
let node = fragment.node
let id = node.id
let el
if (node.type === 'cursor') {
el = $$(CursorComponent, { collaborator: node.collaborator })
} else if (node.type === 'selection-fragment') {
el = $$(SelectionFragmentComponent, { collaborator: node.collaborator })
} else {
el = super._renderFragment.apply(this, arguments)
if (id) {
el.ref(id + '@' + fragment.counter)
}
}
el.attr('data-offset', fragment.pos)
return el
}
_onDomMutations(mutations) {
// HACK: only detecting mutations that are coming from the native spell-correction
if (mutations.length === 2 && mutations[0].target === mutations[1].target) {
let textEl = DefaultDOMElement.unwrap(mutations[0].target)
if (textEl) {
this._applyTextMutation(textEl, mutations[0].oldValue)
return
}
}
// in all other cases, revert the change by rerendering
this.rerender()
}
_applyTextMutation(textEl, oldText) {
// find the offset
let offset = _getCharPos(textEl, 0)
let newText = textEl.textContent
let changes = diff(oldText, newText, offset)
let editorSession = this.context.editorSession
let path = this.getPath()
editorSession.transaction(function(tx) {
changes.forEach(function(change) {
// NOTE: atomic text replace is not supported currently
if (change.type === 'replace') {
tx.update(path, { type: 'delete', start: change.start, end: change.end })
tx.update(path, { type: 'insert', start: change.start, text: change.text })
} else {
tx.update(path, change)
}
})
})
}
getSurface() {
return this.props.surface || this.context.surface
}
getSurfaceId() {
let surface = this.getSurface()
return surface ? surface.id : null
}
getContainerId() {
let surface = this.getSurface()
return surface ? surface.getContainerId() : null
}
isEditable() {
return this.getSurface().isEditable()
}
isReadonly() {
return this.getSurface().isReadonly()
}
getDOMCoordinate(charPos) {
return this._getDOMCoordinate(this.el, charPos)
}
_finishFragment(fragment, context, parentContext) {
context.attr('data-length', fragment.length)
parentContext.append(context)
}
_getDOMCoordinate(el, charPos) {
let l
let idx = 0
if (charPos === 0) {
return {
container: el.getNativeElement(),
offset: 0
}
}
for (let child = el.getFirstChild(); child; child = child.getNextSibling(), idx++) {
if (child.isTextNode()) {
l = child.textContent.length
if (l >= charPos) {
return {
container: child.getNativeElement(),
offset: charPos
}
} else {
charPos -= l;
}
} else if (child.isElementNode()) {
let length = child.getAttribute('data-length')
if (length) {
l = parseInt(length, 10)
if (l >= charPos) {
// special handling for InlineNodes
if (child.attr('data-inline')) {
let nextSibling = child.getNextSibling()
if (nextSibling && nextSibling.isTextNode()) {
return {
container: nextSibling.getNativeElement(),
offset: 0
}
} else {
return {
container: el.getNativeElement(),
offset: el.getChildIndex(child) + 1
}
}
}
return this._getDOMCoordinate(child, charPos, idx)
} else {
charPos -= l
}
} else {
console.error('FIXME: Can not map to DOM coordinates.')
return null
}
}
}
}
}
TextPropertyComponent.prototype._isTextPropertyComponent = true
// Helpers for DOM selection mapping
/*
Used to map from DOM to model.
Given a root element and a DOM element - which is typically somewhere inside a surface
it tries to find the next TextProperty by walking up the DOM.
If found it computes the character position, counting chars and using the hints, data-length and data-offset,
rendered by the TextPropertyComponent
*/
TextPropertyComponent.getCoordinate = function(root, el, offset) {
let context = _getPropertyContext(root, el, offset)
if (!context) {
return null
}
// EXPERIMENTAL: trying to detect
// TODO: add tests and remove this comment when mature
let charPos
if (el.parentNode && el.parentNode.is('.se-placeholder')) {
charPos = 0
} else {
// in some cases we need to normalize the DOM coordinate
// before we can use it for retrieving charPos
// E.g. observed with #273
charPos = _getCharPos(context.node, context.offset)
}
if (isNumber(charPos)) {
let coor = new Coordinate(context.path, charPos)
coor._comp = context.comp
return coor
} else {
return null
}
}
function _getPropertyContext(root, node, offset) {
let result = {
comp: null,
el: null,
path: null,
node: node,
offset: offset
}
while (node && node !== root) {
if (node.isElementNode()) {
let comp = Component.unwrap(node)
if (comp && comp._isTextPropertyComponent) {
result.comp = comp
result.el = node
result.path = comp.getPath()
return result;
}
// we need to normalize situations where the DOM coordinate
// is inside an inline node, which we have observed
// can actually happen.
if (node.getAttribute('data-inline')) {
result.node = node
if (offset > 0) {
result.offset = 1
}
}
}
node = node.getParent()
}
return null
}
function _getCharPos(node, offset) {
let charPos = offset
let parent, childIdx
/*
In the following implementation we are exploiting two facts
for optimization:
- an element with data-path is assumed to be the text property element
- an element with data-offset is assumed to be an annotation element
Particularly, the data-offset property is helpful to get the character position
in just one iteration.
*/
parent = node.getParent()
if (node.isTextNode()) {
// TextNode is first child
if (node === parent.firstChild) {
// ... we can stop if parent is text property
let parentPath = parent.getAttribute('data-path')
let parentOffset = parent.getAttribute('data-offset')
if (parentPath) {
charPos = offset
}
// ... and we can stop if parent has an offset hint
else if (parentOffset) {
charPos = parseInt(parentOffset, 10) + offset
}
// ... otherwise we count the charPos by recursing up-tree
else {
charPos = _getCharPos(parent, 0) + offset
}
} else {
// the node has a predecessor so we can apply recurse using the child index
childIdx = parent.getChildIndex(node)
charPos = _getCharPos(parent, childIdx) + offset
}
} else if (node.isElementNode()) {
let pathStr = node.getAttribute('data-path')
let offsetStr = node.getAttribute('data-offset')
// if node is the element of a text property, then offset is a child index
// up to which we need to sum up all lengths
if (pathStr) {
charPos = _countCharacters(node, offset)
}
// similar if node is the element of an annotation, and we can use the
// element's offset
else if (offsetStr) {
childIdx = parent.getChildIndex(node)
charPos = parseInt(offsetStr, 10) + _countCharacters(node, offset)
}
// for other elements we need to count characters in the child tree
// adding the offset of this element which needs to be computed by recursing up-tree
else {
childIdx = parent.getChildIndex(node)
charPos = _getCharPos(parent, childIdx) + _countCharacters(node, offset)
}
} else {
// Unsupported case
return null
}
return charPos;
}
function _countCharacters(el, maxIdx) {
let charPos = 0
// inline elements have a length of 1
if (el.getAttribute('data-inline')) {
return maxIdx === 0 ? 0 : 1;
}
let l = el.getChildCount()
if (arguments.length === 1) {
maxIdx = l;
}
maxIdx = Math.min(l, maxIdx)
for (let i=0, child = el.getFirstChild(); i < maxIdx; child = child.getNextSibling(), i++) {
if (child.isTextNode()) {
charPos += child.getTextContent().length
} else if (child.isElementNode()) {
let length = child.getAttribute('data-length')
if (child.getAttribute('data-inline')) {
charPos += 1
} else if (length) {
charPos += parseInt(length, 10)
} else {
charPos += _countCharacters(child)
}
}
}
return charPos
}
export default TextPropertyComponent