Skip to content

Commit cad7da2

Browse files
authored
Merge pull request #43 from typecode/issue-32
#32 - Attempt to reset selection on undo/redo
2 parents daa0bb8 + 26b11b7 commit cad7da2

File tree

8 files changed

+249
-34
lines changed

8 files changed

+249
-34
lines changed

src/scripts/containers/FormatterContainer.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import ListFormatter from '../modules/ListFormatter';
2626
import LinkFormatter from '../modules/LinkFormatter';
2727
import Commands from '../modules/Commands';
2828
import Paste from '../modules/Paste';
29+
import Undo from '../modules/Undo';
2930

3031
/**
3132
* @constructor FormatterContainer
@@ -63,6 +64,9 @@ const FormatterContainer = Container({
6364
},
6465
{
6566
class: Paste
67+
},
68+
{
69+
class: Undo
6670
}
6771
]
6872
});

src/scripts/modules/BaseFormatter.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ let validTags, blockTags, listTags;
3030

3131
const BaseFormatter = Module({
3232
name: 'BaseFormatter',
33-
props: {},
33+
props: {
34+
cachedRangeCoordinates: null
35+
},
3436
handlers: {
3537
requests: {},
3638
commands: {
@@ -62,6 +64,8 @@ const BaseFormatter = Module({
6264
const { mediator } = this;
6365
const rootElement = mediator.get('selection:rootelement');
6466
const canvasBody = mediator.get('canvas:body');
67+
68+
mediator.emit('export:to:canvas:start');
6569
this.injectHooks(rootElement);
6670

6771
const rangeCoordinates = mediator.get('selection:range:coordinates');
@@ -84,6 +88,8 @@ const BaseFormatter = Module({
8488
const { mediator } = this;
8589
const canvasBody = mediator.get('canvas:body');
8690

91+
mediator.emit('import:from:canvas:start');
92+
8793
mediator.exec('canvas:cache:selection');
8894
mediator.exec('format:clean', canvasBody);
8995
if (opts.importFilter) {
@@ -124,19 +130,24 @@ const BaseFormatter = Module({
124130
this.ensureRootElems(rootElem);
125131
this.removeStyleAttributes(rootElem);
126132
this.removeEmptyNodes(rootElem, { recursive: true });
133+
this.removeZeroWidthSpaces(rootElem);
134+
DOM.trimNodeText(rootElem);
135+
136+
// -----
137+
138+
// this.removeBrNodes(rootElem);
139+
// // this.removeEmptyNodes(rootElem);
140+
// this.removeFontTags(rootElem);
141+
// this.removeStyledSpans(rootElem);
142+
// this.clearEntities(rootElem);
143+
// this.removeZeroWidthSpaces(rootElem);
144+
// this.defaultOrphanedTextNodes(rootElem);
145+
// this.removeEmptyNodes(rootElem, { recursive: true });
127146
},
128147

129148
/**
130149
* PRIVATE METHODS:
131150
*/
132-
cloneNodes (rootElement) {
133-
let clonedNodes = [];
134-
rootElement.childNodes.forEach((node) => {
135-
clonedNodes.push(node.cloneNode(true));
136-
});
137-
return clonedNodes;
138-
},
139-
140151
injectHooks (rootElement) {
141152
while (!/\w+/.test(rootElement.firstChild.textContent)) {
142153
DOM.removeNode(rootElement.firstChild);

src/scripts/modules/ContentEditable.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ const ContentEditable = Module({
4040
name: 'ContentEditable',
4141
props: {
4242
styles: null,
43-
cleanupTimeout: null
43+
cleanupTimeout: null,
44+
observer: null,
45+
observerConfig: {
46+
attributes: false,
47+
childList: true,
48+
subtree: true
49+
}
4450
},
4551
dom: {},
4652
handlers: {
@@ -53,6 +59,9 @@ const ContentEditable = Module({
5359
'contenteditable:refocus' : 'reFocus',
5460
'contenteditable:cleanup' : 'cleanup'
5561
},
62+
events: {
63+
'app:destroy': 'destroy'
64+
},
5665
domEvents: {
5766
'focus' : 'handleFocus',
5867
'keydown' : 'handleKeydown',
@@ -75,6 +84,7 @@ const ContentEditable = Module({
7584
this.ensureEditable();
7685
this.updatePlaceholderState();
7786
this.updateValue();
87+
this.initObserver();
7888
},
7989

8090
appendStyles () {
@@ -122,6 +132,19 @@ const ContentEditable = Module({
122132
}
123133
},
124134

135+
initObserver () {
136+
const { dom, props } = this;
137+
const rootEl = dom.el[0];
138+
139+
props.observer = new MutationObserver(this.observerCallback);
140+
props.observer.observe(rootEl, props.observerConfig);
141+
},
142+
143+
observerCallback () {
144+
const { mediator } = this;
145+
mediator.emit('contenteditable:mutation:observed');
146+
},
147+
125148
ensureDefaultBlock () {
126149
const { dom, mediator } = this;
127150
const rootEl = dom.el[0];
@@ -206,6 +229,11 @@ const ContentEditable = Module({
206229
}
207230
},
208231

232+
destroy () {
233+
const { props } = this;
234+
props.observer.disconnect();
235+
},
236+
209237
// DOM Event Handlers
210238

211239
/**
@@ -215,10 +243,18 @@ const ContentEditable = Module({
215243
* @fires contenteditable:focus
216244
*/
217245
handleFocus () {
218-
const { mediator } = this;
246+
const { mediator, dom } = this;
219247
this.clearCleanupTimeout();
220248
this.ensureDefaultBlock();
221249
this.updatePlaceholderState();
250+
251+
// Trim out orphaned empty root level text nodes. Should maybe move this somewhere else.
252+
dom.el[0].childNodes.forEach((childNode) => {
253+
if (childNode.nodeType === Node.TEXT_NODE && !childNode.textContent.trim().length) {
254+
DOM.removeNode(childNode);
255+
}
256+
});
257+
222258
mediator.emit('contenteditable:focus');
223259
},
224260

src/scripts/modules/Selection.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,12 +340,12 @@ const Selection = Module({
340340
startCoordinates.unshift(startOffset);
341341
endCoordinates.unshift(endOffset);
342342

343-
while (!this.isContentEditable(startContainer)) {
343+
while (startContainer && !this.isContentEditable(startContainer)) {
344344
startCoordinates.unshift(DOM.childIndex(startContainer));
345345
startContainer = startContainer.parentNode;
346346
}
347347

348-
while (!this.isContentEditable(endContainer)) {
348+
while (endContainer && !this.isContentEditable(endContainer)) {
349349
endCoordinates.unshift(DOM.childIndex(endContainer));
350350
endContainer = endContainer.parentNode;
351351
}

src/scripts/modules/Undo.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import Module from '../core/Module';
2+
import DOM from '../utils/DOM';
3+
4+
const Undo = Module({
5+
name: 'Undo',
6+
props: {
7+
contentEditableElem: null,
8+
currentHistoryIndex: -1,
9+
history: [],
10+
ignoreSelectionChanges: false
11+
},
12+
13+
handlers: {
14+
events: {
15+
'contenteditable:mutation:observed': 'handleMutation',
16+
'contenteditable:focus': 'handleFocus',
17+
'import:from:canvas:start': 'handleImportStart',
18+
'import:from:canvas:complete': 'handleImportComplete',
19+
'selection:change': 'handleSelectionChange',
20+
'export:to:canvas:start': 'handleExportStart'
21+
}
22+
},
23+
24+
methods: {
25+
setup () {},
26+
init () {},
27+
28+
handleMutation () {
29+
const { props, mediator } = this;
30+
const { history, currentHistoryIndex } = props;
31+
const states = {
32+
currentHistoryIndex,
33+
current: this.createHistoryState(),
34+
previous: history[currentHistoryIndex],
35+
beforePrevious: history[currentHistoryIndex - 1],
36+
next: history[currentHistoryIndex + 1],
37+
afterNext: history[currentHistoryIndex + 2]
38+
};
39+
40+
const {
41+
isUndo,
42+
isRedo,
43+
noChange
44+
} = this.analyzeStates(states);
45+
46+
if (noChange) {
47+
return;
48+
} else if (!isUndo && !isRedo) {
49+
props.history.length = currentHistoryIndex + 1;
50+
props.history.push(states.current);
51+
props.currentHistoryIndex += 1;
52+
} else if (isUndo) {
53+
props.currentHistoryIndex -= 1;
54+
mediator.exec('format:clean', props.contentEditableElem);
55+
mediator.exec('selection:select:coordinates', states.beforePrevious.selectionRangeCoordinates);
56+
} else if (isRedo) {
57+
props.currentHistoryIndex += 1;
58+
mediator.exec('format:clean', props.contentEditableElem);
59+
mediator.exec('selection:select:coordinates', states.next.selectionRangeCoordinates);
60+
}
61+
},
62+
63+
handleFocus () {
64+
const { mediator, props } = this;
65+
const contentEditableElem = mediator.get('contenteditable:element');
66+
67+
if (props.contentEditableElem !== contentEditableElem) {
68+
setTimeout(() => {
69+
props.contentEditableElem = contentEditableElem;
70+
props.history = [this.createHistoryState()];
71+
props.currentHistoryIndex = 0;
72+
}, 150);
73+
}
74+
},
75+
76+
handleImportStart () {
77+
const { props } = this;
78+
props.ignoreSelectionChanges = true;
79+
},
80+
81+
handleImportComplete () {
82+
const { props } = this;
83+
props.ignoreSelectionChanges = false;
84+
},
85+
86+
handleExportStart () {
87+
this.updateCurrentHistoryState();
88+
},
89+
90+
handleSelectionChange () {
91+
const { props } = this;
92+
if (!props.ignoreSelectionChanges) {
93+
this.updateCurrentHistoryState();
94+
}
95+
},
96+
97+
updateCurrentHistoryState () {
98+
const { props } = this;
99+
const { history, currentHistoryIndex } = props;
100+
const currentHistoryState = history[currentHistoryIndex];
101+
102+
if (currentHistoryState) {
103+
this.cacheSelectionRangeOnState(currentHistoryState);
104+
}
105+
},
106+
107+
createHistoryState () {
108+
const { props } = this;
109+
110+
if (!props.contentEditableElem) { return; }
111+
112+
const editableContentString = DOM.nodesToHTMLString(DOM.cloneNodes(props.contentEditableElem, { trim: true })).replace(/\u200B/g, '');
113+
const historyState = {
114+
editableContentString,
115+
};
116+
117+
this.cacheSelectionRangeOnState(historyState);
118+
119+
return historyState;
120+
},
121+
122+
cacheSelectionRangeOnState (state) {
123+
const { mediator } = this;
124+
state.selectionRangeCoordinates = mediator.get('selection:range:coordinates');
125+
},
126+
127+
analyzeStates (states) {
128+
const {
129+
current,
130+
previous,
131+
beforePrevious,
132+
next
133+
} = states;
134+
let isUndo = beforePrevious && current.editableContentString === beforePrevious.editableContentString;
135+
let isRedo = next && current.editableContentString === next.editableContentString;
136+
let noChange = previous && current.editableContentString === previous.editableContentString;
137+
138+
isUndo = isUndo || false;
139+
isRedo = isRedo || false;
140+
noChange = noChange || false;
141+
142+
return {
143+
isUndo,
144+
isRedo,
145+
noChange
146+
};
147+
}
148+
}
149+
});
150+
151+
export default Undo;

src/scripts/utils/DOM.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,9 @@ const DOM = {
590590

591591
nodes.forEach((node) => {
592592
if (node.nodeType === Node.TEXT_NODE) {
593-
HTMLString += node.textContent;
593+
if(node.textContent.match(/\w+/)) {
594+
HTMLString += node.textContent;
595+
}
594596
} else {
595597
HTMLString += node.outerHTML;
596598
}

test/server/html/index.html

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,11 @@ <h1>Typester test server</h1>
177177
node.childNodes.forEach(function (childNode) {
178178
if (childNode.nodeType === Node.TEXT_NODE) {
179179
if (childNode.textContent.trim().length) {
180-
appendHtmlText(childNode.textContent.replace(/\s/g, '\u00B7'), opts.indentation + 1, true);
180+
if (childNode.textContent.match(/\u200B/g)) {
181+
appendHtmlText('<selection-hook />', opts.indentation, true);
182+
} else {
183+
appendHtmlText(childNode.textContent.replace(/\s/g, '\u00B7'), opts.indentation + 1, true);
184+
}
181185
}
182186
} else {
183187
appendHtmlText(generateHtmlText(childNode, {
@@ -208,10 +212,17 @@ <h1>Typester test server</h1>
208212
generateHtmlText(targetEl);
209213
contentInspector.innerText = generateHtmlText(targetEl);
210214
hljs.highlightBlock(contentInspector);
211-
requestAnimationFrame(updateInspector);
215+
// requestAnimationFrame(updateInspector);
212216
};
213217

214-
requestAnimationFrame(updateInspector);
218+
const observerConfig = { attributes: true, childList: true, subtree: true };
219+
const editorObserver = new MutationObserver(updateInspector);
220+
const canvasObserver = new MutationObserver(updateInspector);
221+
222+
editorObserver.observe(contentEditable, observerConfig);
223+
canvasObserver.observe(document.querySelector('.typester-canvas'), observerConfig);
224+
updateInspector();
225+
// requestAnimationFrame(updateInspector);
215226
})();
216227
</script>
217228
</body>

0 commit comments

Comments
 (0)