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 system. It is developed to power our online editing platform [Substance](http://substance.io).

210 lines (195 loc) 7 kB
import isFunction from '../util/isFunction' import encodeXMLEntities from '../util/encodeXMLEntities' import Registry from '../util/Registry' import Fragmenter from './Fragmenter' export default class DOMExporter { constructor (params, options = {}) { if (!params.converters) { throw new Error('params.converters is mandatory') } // NOTE: Subclasses (HTMLExporter and XMLExporter) must initialize this // with a proper DOMElement instance which is used to create new elements. if (!params.elementFactory) { throw new Error("'elementFactory' is mandatory") } this.converters = new Registry() params.converters.forEach(Converter => { const converter = isFunction(Converter) ? new Converter() : Converter if (!converter.type) { console.error('Converter must provide the type of the associated node.', converter) return } this.converters.add(converter.type, converter) }) this.elementFactory = params.elementFactory this.idAttribute = params.idAttribute || 'id' this.state = { doc: null } this.options = options this.$$ = this.createElement.bind(this) } exportDocument (doc) { // TODO: this is no left without much functionality // still, it would be good to have a consistent top-level API // i.e. converter.importDocument(el) and converter.exportDocument(doc) // On the other side, the 'internal' API methods are named this.convert*. return this.convertDocument(doc) } /** * @param {Document} * @returns {DOMElement|DOMElement[]} The exported document as DOM or an array of elements * if exported as partial, which depends on the actual implementation * of `this.convertDocument()`. * * @abstract * @example * * convertDocument(doc) { * let container = doc.get('body') * let elements = this.convertContainer(container) * let out = elements.map(el => { * return el.outerHTML * }) * return out.join('') * } */ convertDocument(doc) { // eslint-disable-line throw new Error('This method is abstract') } convertContainer (doc, containerPath) { if (!containerPath) { throw new Error('Illegal arguments: containerPath is mandatory.') } this.state.doc = doc const ids = doc.get(containerPath) const elements = ids.map(id => { const node = doc.get(id) return this.convertNode(node) }) return elements } convertNode (node) { this.state.doc = node.getDocument() let converter = this.getNodeConverter(node) // special treatment for annotations, i.e. if someone calls // `exporter.convertNode(anno)` if (node.isPropertyAnnotation() && (!converter || !converter.export)) { return this._convertPropertyAnnotation(node) } if (!converter) { converter = this.getDefaultBlockConverter() } let el if (converter.tagName) { el = this.$$(converter.tagName) } else { el = this.$$('div') } el.attr(this.idAttribute, node.id) if (converter.export) { el = converter.export(node, el, this) || el } else { el = this.getDefaultBlockConverter().export(node, el, this) || el } return el } convertProperty (doc, path, options) { this.state.doc = doc this.initialize(doc, options) const wrapper = this.$$('div') .append(this.annotatedText(path)) return wrapper.innerHTML } annotatedText (path, doc) { doc = doc || this.state.doc const text = doc.get(path) const annotations = doc.getIndex('annotations').get(path) return this._annotatedText(text, annotations) } getNodeConverter (node) { return this.converters.get(node.type) } getDefaultBlockConverter () { throw new Error('This method is abstract.') } getDefaultPropertyAnnotationConverter () { throw new Error('This method is abstract.') } getDocument () { return this.state.doc } createElement (str) { return this.elementFactory.createElement(str) } _annotatedText (text, annotations) { const annotator = new Fragmenter() annotator.onText = (context, text) => { if (text) { // ATTENTION: only encode if this is desired, e.g. '"' would be encoded as '"' but as Clipboard HTML this is not understood by // other applications such as Word. if (this.options.ENCODE_ENTITIES_IN_TEXT) { text = encodeXMLEntities(text) } context.children.push(text) } } annotator.onOpen = function (fragment) { return { children: [] } } annotator.onClose = (fragment, context, parentContext) => { const anno = fragment.node let converter = this.getNodeConverter(anno) if (!converter) { converter = this.getDefaultPropertyAnnotationConverter() } let el if (converter.tagName) { el = this.$$(converter.tagName) } else { el = this.$$('span') } el.attr(this.idAttribute, anno.id) // inline nodes are special, because they are like an island in the text: // In a Substance TextNode, an InlineNode is anchored on an invisible character. // In the XML presentation, however, this character must not be inserted, instead the element // converted and then inserted at the very same location. if (anno.isInlineNode()) { if (converter.export) { el = converter.export(anno, el, this) || el } else { el = this.convertNode(anno) || el } } else if (anno.isAnnotation()) { // allowing to provide a custom exporter // ATTENTION: a converter for the children of an annotation must not be if (converter.export) { el = converter.export(anno, el, this) || el if (el.children.length) { throw new Error('A converter for an annotation type must not convert children. The content of an annotation is owned by their TextNode.') } } el.append(context.children) } else { // TODO: this should not be possible from the beginning. Seeing this error here, is pretty late. throw new Error('Illegal element type: only inline nodes and annotations are allowed within a TextNode') } parentContext.children.push(el) } const wrapper = { children: [] } annotator.start(wrapper, text, annotations) return wrapper.children } /* This is used when someone calls `exporter.convertNode(anno)` Usually, annotations are converted by calling exporter.annotatedText(path). Still it makes sense to be able to export just a fragment containing just the annotation element. */ _convertPropertyAnnotation (anno) { // take only the annotations within the range of the anno const wrapper = this.$$('div').append(this.annotatedText(anno.path)) const el = wrapper.find('[' + this.idAttribute + '="' + anno.id + '"]') return el } }