diff --git a/src/scripts/containers/FormatterContainer.js b/src/scripts/containers/FormatterContainer.js index 31d0b0a..ce6dd45 100644 --- a/src/scripts/containers/FormatterContainer.js +++ b/src/scripts/containers/FormatterContainer.js @@ -26,6 +26,7 @@ import ListFormatter from '../modules/ListFormatter'; import LinkFormatter from '../modules/LinkFormatter'; import Commands from '../modules/Commands'; import Paste from '../modules/Paste'; +import Undo from '../modules/Undo'; /** * @constructor FormatterContainer @@ -63,6 +64,9 @@ const FormatterContainer = Container({ }, { class: Paste + }, + { + class: Undo } ] }); diff --git a/src/scripts/modules/BaseFormatter.js b/src/scripts/modules/BaseFormatter.js index d1071ab..dc84d3e 100644 --- a/src/scripts/modules/BaseFormatter.js +++ b/src/scripts/modules/BaseFormatter.js @@ -30,7 +30,9 @@ let validTags, blockTags, listTags; const BaseFormatter = Module({ name: 'BaseFormatter', - props: {}, + props: { + cachedRangeCoordinates: null + }, handlers: { requests: {}, commands: { @@ -62,6 +64,8 @@ const BaseFormatter = Module({ const { mediator } = this; const rootElement = mediator.get('selection:rootelement'); const canvasBody = mediator.get('canvas:body'); + + mediator.emit('export:to:canvas:start'); this.injectHooks(rootElement); const rangeCoordinates = mediator.get('selection:range:coordinates'); @@ -84,6 +88,8 @@ const BaseFormatter = Module({ const { mediator } = this; const canvasBody = mediator.get('canvas:body'); + mediator.emit('import:from:canvas:start'); + mediator.exec('canvas:cache:selection'); mediator.exec('format:clean', canvasBody); if (opts.importFilter) { @@ -124,19 +130,24 @@ const BaseFormatter = Module({ this.ensureRootElems(rootElem); this.removeStyleAttributes(rootElem); this.removeEmptyNodes(rootElem, { recursive: true }); + this.removeZeroWidthSpaces(rootElem); + DOM.trimNodeText(rootElem); + + // ----- + + // this.removeBrNodes(rootElem); + // // this.removeEmptyNodes(rootElem); + // this.removeFontTags(rootElem); + // this.removeStyledSpans(rootElem); + // this.clearEntities(rootElem); + // this.removeZeroWidthSpaces(rootElem); + // this.defaultOrphanedTextNodes(rootElem); + // this.removeEmptyNodes(rootElem, { recursive: true }); }, /** * PRIVATE METHODS: */ - cloneNodes (rootElement) { - let clonedNodes = []; - rootElement.childNodes.forEach((node) => { - clonedNodes.push(node.cloneNode(true)); - }); - return clonedNodes; - }, - injectHooks (rootElement) { while (!/\w+/.test(rootElement.firstChild.textContent)) { DOM.removeNode(rootElement.firstChild); diff --git a/src/scripts/modules/ContentEditable.js b/src/scripts/modules/ContentEditable.js index cfa8b09..119201e 100644 --- a/src/scripts/modules/ContentEditable.js +++ b/src/scripts/modules/ContentEditable.js @@ -40,7 +40,13 @@ const ContentEditable = Module({ name: 'ContentEditable', props: { styles: null, - cleanupTimeout: null + cleanupTimeout: null, + observer: null, + observerConfig: { + attributes: false, + childList: true, + subtree: true + } }, dom: {}, handlers: { @@ -53,6 +59,9 @@ const ContentEditable = Module({ 'contenteditable:refocus' : 'reFocus', 'contenteditable:cleanup' : 'cleanup' }, + events: { + 'app:destroy': 'destroy' + }, domEvents: { 'focus' : 'handleFocus', 'keydown' : 'handleKeydown', @@ -75,6 +84,7 @@ const ContentEditable = Module({ this.ensureEditable(); this.updatePlaceholderState(); this.updateValue(); + this.initObserver(); }, appendStyles () { @@ -122,6 +132,19 @@ const ContentEditable = Module({ } }, + initObserver () { + const { dom, props } = this; + const rootEl = dom.el[0]; + + props.observer = new MutationObserver(this.observerCallback); + props.observer.observe(rootEl, props.observerConfig); + }, + + observerCallback () { + const { mediator } = this; + mediator.emit('contenteditable:mutation:observed'); + }, + ensureDefaultBlock () { const { dom, mediator } = this; const rootEl = dom.el[0]; @@ -206,6 +229,11 @@ const ContentEditable = Module({ } }, + destroy () { + const { props } = this; + props.observer.disconnect(); + }, + // DOM Event Handlers /** @@ -215,10 +243,18 @@ const ContentEditable = Module({ * @fires contenteditable:focus */ handleFocus () { - const { mediator } = this; + const { mediator, dom } = this; this.clearCleanupTimeout(); this.ensureDefaultBlock(); this.updatePlaceholderState(); + + // Trim out orphaned empty root level text nodes. Should maybe move this somewhere else. + dom.el[0].childNodes.forEach((childNode) => { + if (childNode.nodeType === Node.TEXT_NODE && !childNode.textContent.trim().length) { + DOM.removeNode(childNode); + } + }); + mediator.emit('contenteditable:focus'); }, diff --git a/src/scripts/modules/Selection.js b/src/scripts/modules/Selection.js index a452d81..b9d7284 100644 --- a/src/scripts/modules/Selection.js +++ b/src/scripts/modules/Selection.js @@ -340,12 +340,12 @@ const Selection = Module({ startCoordinates.unshift(startOffset); endCoordinates.unshift(endOffset); - while (!this.isContentEditable(startContainer)) { + while (startContainer && !this.isContentEditable(startContainer)) { startCoordinates.unshift(DOM.childIndex(startContainer)); startContainer = startContainer.parentNode; } - while (!this.isContentEditable(endContainer)) { + while (endContainer && !this.isContentEditable(endContainer)) { endCoordinates.unshift(DOM.childIndex(endContainer)); endContainer = endContainer.parentNode; } diff --git a/src/scripts/modules/Undo.js b/src/scripts/modules/Undo.js new file mode 100644 index 0000000..98adf47 --- /dev/null +++ b/src/scripts/modules/Undo.js @@ -0,0 +1,151 @@ +import Module from '../core/Module'; +import DOM from '../utils/DOM'; + +const Undo = Module({ + name: 'Undo', + props: { + contentEditableElem: null, + currentHistoryIndex: -1, + history: [], + ignoreSelectionChanges: false + }, + + handlers: { + events: { + 'contenteditable:mutation:observed': 'handleMutation', + 'contenteditable:focus': 'handleFocus', + 'import:from:canvas:start': 'handleImportStart', + 'import:from:canvas:complete': 'handleImportComplete', + 'selection:change': 'handleSelectionChange', + 'export:to:canvas:start': 'handleExportStart' + } + }, + + methods: { + setup () {}, + init () {}, + + handleMutation () { + const { props, mediator } = this; + const { history, currentHistoryIndex } = props; + const states = { + currentHistoryIndex, + current: this.createHistoryState(), + previous: history[currentHistoryIndex], + beforePrevious: history[currentHistoryIndex - 1], + next: history[currentHistoryIndex + 1], + afterNext: history[currentHistoryIndex + 2] + }; + + const { + isUndo, + isRedo, + noChange + } = this.analyzeStates(states); + + if (noChange) { + return; + } else if (!isUndo && !isRedo) { + props.history.length = currentHistoryIndex + 1; + props.history.push(states.current); + props.currentHistoryIndex += 1; + } else if (isUndo) { + props.currentHistoryIndex -= 1; + mediator.exec('format:clean', props.contentEditableElem); + mediator.exec('selection:select:coordinates', states.beforePrevious.selectionRangeCoordinates); + } else if (isRedo) { + props.currentHistoryIndex += 1; + mediator.exec('format:clean', props.contentEditableElem); + mediator.exec('selection:select:coordinates', states.next.selectionRangeCoordinates); + } + }, + + handleFocus () { + const { mediator, props } = this; + const contentEditableElem = mediator.get('contenteditable:element'); + + if (props.contentEditableElem !== contentEditableElem) { + setTimeout(() => { + props.contentEditableElem = contentEditableElem; + props.history = [this.createHistoryState()]; + props.currentHistoryIndex = 0; + }, 150); + } + }, + + handleImportStart () { + const { props } = this; + props.ignoreSelectionChanges = true; + }, + + handleImportComplete () { + const { props } = this; + props.ignoreSelectionChanges = false; + }, + + handleExportStart () { + this.updateCurrentHistoryState(); + }, + + handleSelectionChange () { + const { props } = this; + if (!props.ignoreSelectionChanges) { + this.updateCurrentHistoryState(); + } + }, + + updateCurrentHistoryState () { + const { props } = this; + const { history, currentHistoryIndex } = props; + const currentHistoryState = history[currentHistoryIndex]; + + if (currentHistoryState) { + this.cacheSelectionRangeOnState(currentHistoryState); + } + }, + + createHistoryState () { + const { props } = this; + + if (!props.contentEditableElem) { return; } + + const editableContentString = DOM.nodesToHTMLString(DOM.cloneNodes(props.contentEditableElem, { trim: true })).replace(/\u200B/g, ''); + const historyState = { + editableContentString, + }; + + this.cacheSelectionRangeOnState(historyState); + + return historyState; + }, + + cacheSelectionRangeOnState (state) { + const { mediator } = this; + state.selectionRangeCoordinates = mediator.get('selection:range:coordinates'); + }, + + analyzeStates (states) { + const { + current, + previous, + beforePrevious, + next + } = states; + let isUndo = beforePrevious && current.editableContentString === beforePrevious.editableContentString; + let isRedo = next && current.editableContentString === next.editableContentString; + let noChange = previous && current.editableContentString === previous.editableContentString; + + isUndo = isUndo || false; + isRedo = isRedo || false; + noChange = noChange || false; + + return { + isUndo, + isRedo, + noChange + }; + } + } +}); + +export default Undo; diff --git a/src/scripts/utils/DOM.js b/src/scripts/utils/DOM.js index 6c683e1..c0f9e6b 100644 --- a/src/scripts/utils/DOM.js +++ b/src/scripts/utils/DOM.js @@ -590,7 +590,9 @@ const DOM = { nodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { - HTMLString += node.textContent; + if(node.textContent.match(/\w+/)) { + HTMLString += node.textContent; + } } else { HTMLString += node.outerHTML; } diff --git a/test/server/html/index.html b/test/server/html/index.html index 903b301..851332f 100644 --- a/test/server/html/index.html +++ b/test/server/html/index.html @@ -177,7 +177,11 @@