/// /** * TypeScript imports */ import throttle = require('per-frame'); import MutationObserver = require('mutation-observer'); import query = require('component-query'); import currentRange = require('current-range'); import currentSelection = require('current-selection'); import matches = require('matches-selector'); import dataset = require('dataset'); import blockElements = require('block-elements'); import inlineElements = require('inline-elements'); import voidElements = require('void-elements'); import DEBUG = require('debug'); import move = require('dom-move'); import hacks = require('../hacks/index'); import is = require('../is/index'); import EditorOverlayManager = require('../editor-overlay/index'); import Editor = require('../editor/index'); import collapse = require('collapse'); var debug = DEBUG('editor:editor-normalizer'); /** * All block elements defined by HTML */ var BLOCK_ELEMENTS = blockElements.join(', '); /** * Inline elements which are non-void */ var NON_VOID_INLINE_ELEMENTS = inlineElements.filter((el) => voidElements.indexOf(el) == -1).join(', '); /** * Formatting elements which are nested */ var NESTED_FORMATTING_ELEMENTS = inlineElements.filter((el) => (voidElements.indexOf(el) == -1) && el != 'span').map((el) => el + ' ' + el).join(', '); /** * Formatting elements which are adjacent */ var ADJACENT_FORMATTING_ELEMENTS = inlineElements.filter((el) => (voidElements.indexOf(el) == -1) && el != 'span').map((el) => el + ' + ' + el).join(', '); /** * Block elements which are non-void */ var NON_VOID_BLOCK_ELEMENTS = blockElements.filter((el) => voidElements.indexOf(el) == -1).join(', '); /** * Elements allowed on the editor root element */ var ROOT_ELEMENTS = 'address, article, aside, blockquote, div, dl, figure, footer, h1, h2, h3, h4, h5, h6, header, hgroup, hr, ol, p, pre, section, table, ul'; /** * Maps some elements to specialized containers * IMPORTANT: we need to use upper case here, as we * look this up later using `element.nodeName` */ var WRAPPER_ELEMENTS = { 'LI': ['UL', 'OL'], 'DD': ['DL'], 'DT': ['DL'], 'FIGCAPTION': ['FIGURE'], }; /** * A list of MutationRecords */ interface MutationRecordList extends Array {} /** * Normalizes the content of the editor */ class EditorNormalizer { private editor: Editor; private observer: MutationObserver; private composition: boolean; private middleware: { (root: HTMLElement, subtree: HTMLElement, context?: string): void }[]; public BEFORE_BUILTINS: (root: HTMLElement, subtree: HTMLElement, context?: string) => void; public AFTER_BUILTINS: (root: HTMLElement, subtree: HTMLElement, context?: string) => void; constructor(editor: Editor) { if (!(this instanceof EditorNormalizer)) return new EditorNormalizer(editor); this.editor = editor; this.observer = new MutationObserver(this.callback.bind(this)); this.composition = false; this.editor.el.addEventListener('compositionstart', () => { this.composition = true }, false); this.editor.el.addEventListener('compositionend', () => { this.composition = false; this.callback([]); }, false); this.middleware = []; this.use(this.BEFORE_BUILTINS = this.updateUnknownRootNodes.bind(this)); this.use(this.updateRootLevelClasslessDivs.bind(this)); this.use(this.updateNewlineParagraphs.bind(this)); this.use(this.updateMeaninglessLineBreaks.bind(this)); this.use(this.updateUnwrappedElements.bind(this)); this.use(this.updateEmptyNonVoidInlineElements.bind(this)); this.use(this.updateEmptyNonVoidBlockElements.bind(this)); this.use(this.updateNestedFormatting.bind(this)); this.use(this.updateAdjacentFormatting.bind(this)); this.use(this.updateListWrappedParagraphs.bind(this)); this.use(this.updateMisplacedRootElements.bind(this)); this.use(this.updateReferencesWithContent.bind(this)); this.use(this.updateJoinHints.bind(this)); this.use(this.AFTER_BUILTINS = this.updateEmptyEditor.bind(this)); this.start(); } /** * Add a new normalization to the normalizer */ public use(middleware: (root: HTMLElement, subtree: HTMLElement, context?: string) => void, ref?: (root: HTMLElement, subtree: HTMLElement, context?: string) => void) { if (!ref) { this.middleware.push(middleware); } else { var index = this.middleware.indexOf(ref); if (index == -1) { this.middleware.push(middleware); } else { this.middleware.splice(index, 0, middleware); } } } /** * Force a normalization of the editor content */ public normalize(context: string, root: HTMLElement = this.editor.el, subtree: HTMLElement = root) { this.middleware.forEach((middleware) => { middleware(root, subtree, context); }); if (hacks.storeSelectionForTextNodeChanges) { var s = currentSelection(document); var r = currentRange(s); if (r && !r['intersectsNode'](subtree)) { r = null; } } subtree.normalize(); // dom subtree normalization if (r) { s.removeAllRanges(); s.addRange(r); } } /** * Starts the mutation observer */ private start(): void { this.observer.observe(this.editor.el, { childList: true, attributes: true, characterData: true, subtree: true }); } /** * Stops the mutation observer */ private stop(): void { this.observer.disconnect(); } /** * Fired whenever mutations occur in the observed DOM node */ private callback(records: MutationRecordList): void { if (this.composition) return debug('ignoring, since composition=%o', this.composition); debug('normalizing %d mutation records', records.length); this.stop(); // pause the observer so that we don't react to our own changes this.editor.transactions.runAndSquash(() => { this.normalize('mutation'); }); // TODO: move overlay updating to it's own thing, // it doesn't really belong here but needs extra logic // to not fight with the normalizations if placed elsewhere this.updateOverlay(null, null); this.start(); // resume the observer } /** * Make sure that the editor is initialized with at least one * paragraph to allow input */ private updateEmptyEditor(root: HTMLElement, subtree: HTMLElement): void { if (!root.firstElementChild) { var p = document.createElement('p'); var br = document.createElement('br'); p.appendChild(br); root.appendChild(p); } } /** * Wraps unknown root nodes in p tags */ private updateUnknownRootNodes(root: HTMLElement, subtree: HTMLElement): void { var nodes = root.childNodes; var el: HTMLElement = null; // a wrapper for moving the elements into, lazily created for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; // check if the node is not an element, or if it's not one of the valid root elements if ((node.nodeType != Node.ELEMENT_NODE) || !matches(node, ROOT_ELEMENTS)) { var wrappers: string[]; // check if the element requires a specialized wrapper element if ((wrappers = WRAPPER_ELEMENTS[node.nodeName])) { if (!el || el.nodeName != wrappers[0]) { // created if no wrapper exists, or if we need a different one el = document.createElement(wrappers[0]); } else { i--; // look again at the same index, since an element will be removed } } else { if (!el) { el = document.createElement('div'); // fallback to a div, which will be processed later at updateRootLevelClasslessDivs } else { i--; // look again at the same index, since an element will be removed } } root.insertBefore(el, node); el.appendChild(node); // element is moved into wrapper that we created // also reset on 'BR' so that multiple lines of text are added to different DIVs if (node.nodeName == 'BR') { el = null; } } else { // reset to make sure we create another wrapper next time el = null; } } } /** * Makes sure classless divs at the root level are converted to paragraphs */ private updateRootLevelClasslessDivs(root: HTMLElement, subtree: HTMLElement): void { var nodes = root.childNodes; for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (node.nodeName == 'DIV' && !(node).className) { var p = document.createElement('p'); while (node && node.nodeName == 'DIV' && !(node).className) { var isBreak = (node.childNodes.length == 1) && node.childNodes[0].nodeName == 'BR'; if (!isBreak) { move( node, p); if (!p.lastChild || p.lastChild.nodeName != 'BR') { p.appendChild(document.createElement('br')); } } var tmp = node; node = node.nextSibling; root.removeChild(tmp); if (isBreak) { if (p.childNodes.length == 0) { p.appendChild(tmp.firstChild); } break; } } root.insertBefore(p, node); } } } /** * Wrap elements that require special wrapping */ private updateUnwrappedElements(root: HTMLElement, subtree: HTMLElement): void { var q = Object.keys(WRAPPER_ELEMENTS).join(', '); var els = query.all(q, subtree); for (var i = 0; i < els.length; i++) { var el = els[i]; if (WRAPPER_ELEMENTS[el.nodeName].indexOf(el.parentNode.nodeName) == -1) { var tmp = document.createElement(WRAPPER_ELEMENTS[el.nodeName][0]); el.parentNode.insertBefore(tmp, el) tmp.appendChild(el); } } } /** * Wrap elements that require special wrapping */ private updateNestedFormatting(root: HTMLElement, subtree: HTMLElement): void { var els = query.all(NESTED_FORMATTING_ELEMENTS, subtree); for (var i = 0; i < els.length; i++) { var el = els[i]; var content = move(el); el.parentNode.replaceChild(content, el); } } /** * Join elements that are touching each other */ private updateAdjacentFormatting(root: HTMLElement, subtree: HTMLElement): void { var els = query.all(ADJACENT_FORMATTING_ELEMENTS, subtree); for (var i = 0; i < els.length; i++) { var el = els[i]; var previous = el.previousSibling; // sanity and attribute checks if (previous && previous.nodeName == el.nodeName && this.checkSameAttributes(el, previous)) { move(el, previous); el.parentNode.removeChild(el); } } } private checkSameAttributes(a: HTMLElement, b: HTMLElement): boolean { var attrs = a.attributes; if (attrs.length != b.attributes.length) return false; for (var i = 0; i < attrs.length; i++) { var attr = attrs[i]; if (b.getAttribute(attr.name) != attr.value) { return false; } } return true; } /** * Normalize `li > p` into just `li`. */ private updateListWrappedParagraphs(root: HTMLElement, subtree: HTMLElement): void { var els = query.all('li > p', subtree); for (var i = 0; i < els.length; i++) { var el = els[i]; var fragment = move(el); el.parentNode.replaceChild(fragment, el); } } /** * Move misplaced root level elements */ private updateMisplacedRootElements(root: HTMLElement, subtree: HTMLElement): void { var ready = false; while (!ready) { ready = true; var els = query.all(ROOT_ELEMENTS, subtree); for (var i = 0; i < els.length; i++) { var el = els[i]; if (el.parentNode != root) { if (el.parentNode.nodeName == 'BLOCKQUOTE') { continue; } if ((el.nodeName == 'UL' || el.nodeName == 'OL') && (el.parentNode.nodeName == 'UL' || el.parentNode.nodeName == 'OL')) { continue; } var wrapper = el.parentNode; while (wrapper && wrapper.parentNode != root) { wrapper = wrapper.parentNode; } if (!wrapper) { ready = false; continue; } var middle = document.createRange(); middle.selectNode(el); var left = document.createRange(); left.selectNode(wrapper); left.setEnd(middle.startContainer, middle.startOffset); var right = document.createRange(); right.selectNode(wrapper); right.setStart(middle.endContainer, middle.endOffset); root.insertBefore(left.cloneContents(), wrapper); root.insertBefore(middle.cloneContents(), wrapper); root.insertBefore(right.cloneContents(), wrapper); root.removeChild(wrapper); ready = false; } } } } /** * Update the editor overlay */ private updateOverlay(root: HTMLElement, subtree: HTMLElement): void { this.editor.overlay.update(); } /** * Delete empty root elements */ private updateEmptyNonVoidBlockElements(root: HTMLElement, subtree: HTMLElement): void { var changed; do { changed = false; // TODO: make non global var res: HTMLElement[] = query.all(NON_VOID_BLOCK_ELEMENTS + ', li', root); for (var i = 0; i < res.length; i++) { var re = res[i]; if (!re.firstChild) { changed = true; re.parentNode.removeChild(re); } } } while (changed); } /** * Delete empty inline, non-void elements */ private updateEmptyNonVoidInlineElements(root: HTMLElement, subtree: HTMLElement): void { var changed; do { changed = false; var els: HTMLElement[] = query.all(NON_VOID_INLINE_ELEMENTS, subtree); for (var i = 0; i < els.length; i++) { var el = els[i]; var empty = !el.firstChild || ((el.firstChild == el.lastChild) && (el.firstChild.nodeType == Node.TEXT_NODE) && (el.firstChild.nodeValue == '')); if (empty && el.className != 'join-hint' && el.className != 'tmp') { el.parentNode.removeChild(el); } } } while (changed); } /** * Transforms paragraphs with newline characters into * paragraphs with BR tags. */ private updateNewlineParagraphs(root: HTMLElement, subtree: HTMLElement): void { // TODO: make non global var ps: HTMLElement[] = query.all('p', root); for (var i = 0; i < ps.length; i++) { var p = ps[i]; if (1 == p.childNodes.length && is.newline(p.firstChild)) { p.removeChild(p.firstChild); p.appendChild(document.createElement('br')); } } } /** * Removes all line breaks are at the end of their containers */ private updateMeaninglessLineBreaks(root: HTMLElement, subtree: HTMLElement): void { var brs: HTMLElement[] = query.all('br', subtree); for (var i = 0; i < brs.length; i++) { var br = brs[i]; if ((!br.nextSibling) && br.previousSibling && br.previousSibling.nodeName != 'BR') { br.parentNode.removeChild(br); } } } /** * Detects overlay references with content and moves their * contents to a new paragraph. (So that if an overlay reference * is focused, and the user types something, it shows up * next to it.) */ private updateReferencesWithContent(root: HTMLElement, subtree: HTMLElement): void { // TODO: make non global var refs: HTMLElement[] = query.all('.overlay-reference', root); for (var i = 0; i < refs.length; i++) { var ref = refs[i]; if (!is.emptyOverlayReference(ref)) { // remove placeholder br. due to content editable differences // this node magically disappears on Chrome and Safari, but is // still present on Firefox. (I'm with Firefox on this one...) if (ref.lastChild.nodeName === 'BR') { ref.removeChild(ref.lastChild); } else if (ref.firstChild.nodeName === 'BR') { ref.removeChild(ref.firstChild); } // extract overlay reference contents to an adjacent paragraph var p = document.createElement('p'); while (ref.firstChild) { p.appendChild(ref.firstChild); } ref.parentNode.insertBefore(p, ref.nextSibling); // restore the placeholder br inside the overlay reference var br = document.createElement('br'); ref.appendChild(br); // place cursor on the end of the newly created paragraph var selection = currentSelection(document); var range = document.createRange(); range.selectNodeContents(p); selection.removeAllRanges(); selection.addRange(range); collapse.toEnd(selection); } } } /** * Joins together paragraphs with matching join hints, and * deletes invalid hints. */ private updateJoinHints(root: HTMLElement, subtree: HTMLElement): void { // TODO: make non global var hints: HTMLElement[] = query.all('span.join-hint', root); for (var i = 0; i < hints.length; i++) { var hint = hints[i]; var parent = hint.parentNode; if (!parent) { // hint has been orphaned by merging with other hint // ignore it } else if (hint == parent.lastChild) { // hint in the end of paragraph var next = parent.nextSibling; if (!next) { // no next node, remove the hint parent.removeChild(hint); } else if (next.nodeType == Node.ELEMENT_NODE) { // next node is an element if (matches(next, '.overlay-reference')) { // ignore overlay references } else { if (is.joinHint(next.firstChild)) { // found another matching hint, merge paragraphs parent.removeChild(hint); next.removeChild(next.firstChild); while (next.childNodes.length) { parent.appendChild(next.firstChild); } next.parentNode.removeChild(next); } else { // a matching hint was not found, remove the current hint parent.removeChild(hint); } } } else { // next node is not an element, ignore it } } else if (hint == parent.firstChild) { // hint in the beginning of paragraph var prev = parent.previousSibling; if (!prev) { // no prev node, remove the hint parent.removeChild(hint); } else if (prev.nodeType == Node.ELEMENT_NODE) { // prev node is an element if (matches(prev, '.overlay-reference')) { // ignore overlay references } else { if (is.joinHint(prev.lastChild)) { // ignore this case, as merging is done on the other side } else { // a matching hint was not found, remove the current hint parent.removeChild(hint); } } } else { // ignore non element nodes } } else { // hint inside the paragraph, remove it parent.removeChild(hint); } } } } export = EditorNormalizer;