Mercurial Hosting > sceditor
comparison src/plugins/undo.js @ 4:b7725dab7482
move /development/* to /
author | Franklin Schmidt <fschmidt@gmail.com> |
---|---|
date | Thu, 04 Aug 2022 17:59:02 -0600 |
parents | src/development/plugins/undo.js@4c4fc447baea |
children | ea32a44b5a6e |
comparison
equal
deleted
inserted
replaced
3:ec68006a495e | 4:b7725dab7482 |
---|---|
1 (function (sceditor) { | |
2 'use strict'; | |
3 | |
4 sceditor.plugins.undo = function () { | |
5 var base = this; | |
6 var sourceEditor; | |
7 var editor; | |
8 var body; | |
9 var lastInputType = ''; | |
10 var charChangedCount = 0; | |
11 var isInPatchedFn = false; | |
12 /** | |
13 * If currently restoring a state | |
14 * Should ignore events while it's happening | |
15 */ | |
16 var isApplying = false; | |
17 /** | |
18 * If current selection change event has already been stored | |
19 */ | |
20 var isSelectionChangeHandled = false; | |
21 | |
22 var undoLimit = 50; | |
23 var undoStates = []; | |
24 var redoPosition = 0; | |
25 var lastState; | |
26 | |
27 /** | |
28 * Sets the editor to the specified state. | |
29 * @param {Object} state | |
30 * @private | |
31 */ | |
32 function applyState(state) { | |
33 isApplying = true; | |
34 editor.sourceMode(state.sourceMode); | |
35 | |
36 if (state.sourceMode) { | |
37 editor.val(state.value, false); | |
38 editor.sourceEditorCaret(state.caret); | |
39 } else { | |
40 editor.getBody().innerHTML = state.value; | |
41 | |
42 var range = editor.getRangeHelper().selectedRange(); | |
43 setRangePositions(range, state.caret); | |
44 editor.getRangeHelper().selectRange(range); | |
45 } | |
46 | |
47 editor.focus(); | |
48 isApplying = false; | |
49 }; | |
50 | |
51 /** | |
52 * Patches a function on the object to call store() after invocation | |
53 * @param {Object} obj | |
54 * @param {string} fn | |
55 */ | |
56 function patch(obj, fn) { | |
57 var origFn = obj[fn]; | |
58 obj[fn] = function () { | |
59 // sourceMode calls other patched methods so need to ignore them | |
60 var ignore = isInPatchedFn; | |
61 | |
62 // Store caret position before any change is made | |
63 if (!ignore && !isApplying && lastState && | |
64 editor.getRangeHelper().hasSelection()) { | |
65 updateLastState(); | |
66 } | |
67 | |
68 isInPatchedFn = true; | |
69 origFn.apply(this, arguments); | |
70 | |
71 if (!ignore) { | |
72 isInPatchedFn = false; | |
73 | |
74 if (!isApplying) { | |
75 storeState(); | |
76 lastInputType = ''; | |
77 } | |
78 } | |
79 }; | |
80 } | |
81 | |
82 /** | |
83 * Stores the editors current state | |
84 */ | |
85 function storeState() { | |
86 if (redoPosition) { | |
87 undoStates.length -= redoPosition; | |
88 redoPosition = 0; | |
89 } | |
90 | |
91 if (undoLimit > 0 && undoStates.length > undoLimit) { | |
92 undoStates.shift(); | |
93 } | |
94 | |
95 lastState = {}; | |
96 updateLastState(); | |
97 undoStates.push(lastState); | |
98 } | |
99 | |
100 /** | |
101 * Updates the last saved state with the editors current state | |
102 */ | |
103 function updateLastState() { | |
104 var sourceMode = editor.sourceMode(); | |
105 lastState.caret = sourceMode ? editor.sourceEditorCaret() : | |
106 getRangePositions(editor.getRangeHelper().selectedRange()); | |
107 lastState.sourceMode = sourceMode; | |
108 lastState.value = sourceMode ? | |
109 editor.getSourceEditorValue(false) : | |
110 editor.getBody().innerHTML; | |
111 } | |
112 | |
113 base.init = function () { | |
114 // The this variable will be set to the instance of the editor | |
115 // calling it, hence why the plugins "this" is saved to the base | |
116 // variable. | |
117 editor = this; | |
118 | |
119 undoLimit = editor.undoLimit || undoLimit; | |
120 | |
121 editor.addShortcut('ctrl+z', base.undo); | |
122 editor.addShortcut('ctrl+shift+z', base.redo); | |
123 editor.addShortcut('ctrl+y', base.redo); | |
124 }; | |
125 | |
126 function documentSelectionChangeHandler() { | |
127 if (sourceEditor === document.activeElement) { | |
128 base.signalSelectionchangedEvent(); | |
129 } | |
130 } | |
131 | |
132 base.signalReady = function () { | |
133 sourceEditor = editor.getContentAreaContainer().nextSibling; | |
134 body = editor.getBody(); | |
135 | |
136 // Store initial state | |
137 storeState(); | |
138 | |
139 // Patch methods that allow inserting content into the editor | |
140 // programmatically | |
141 // TODO: remove this when there is a built in event to handle it | |
142 patch(editor, 'setWysiwygEditorValue'); | |
143 patch(editor, 'setSourceEditorValue'); | |
144 patch(editor, 'sourceEditorInsertText'); | |
145 patch(editor.getRangeHelper(), 'insertNode'); | |
146 patch(editor, 'toggleSourceMode'); | |
147 | |
148 /** | |
149 * Handles the before input event so can override built in | |
150 * undo / redo | |
151 * @param {InputEvent} e | |
152 */ | |
153 function beforeInputHandler(e) { | |
154 if (e.inputType === 'historyUndo') { | |
155 base.undo(); | |
156 e.preventDefault(); | |
157 } else if (e.inputType === 'historyRedo') { | |
158 base.redo(); | |
159 e.preventDefault(); | |
160 } | |
161 } | |
162 | |
163 body.addEventListener('beforeinput', beforeInputHandler); | |
164 sourceEditor.addEventListener('beforeinput', beforeInputHandler); | |
165 | |
166 /** | |
167 * Should always store state at the end of composing | |
168 */ | |
169 function compositionHandler() { | |
170 lastInputType = ''; | |
171 storeState(); | |
172 } | |
173 body.addEventListener('compositionend', compositionHandler); | |
174 sourceEditor.addEventListener('compositionend', compositionHandler); | |
175 | |
176 // Chrome doesn't trigger selectionchange on textarea so need to | |
177 // listen to global event | |
178 document.addEventListener('selectionchange', | |
179 documentSelectionChangeHandler); | |
180 }; | |
181 | |
182 base.destroy = function () { | |
183 document.removeEventListener('selectionchange', | |
184 documentSelectionChangeHandler); | |
185 }; | |
186 | |
187 base.undo = function () { | |
188 lastState = null; | |
189 | |
190 if (redoPosition < undoStates.length - 1) { | |
191 redoPosition++; | |
192 applyState(undoStates[undoStates.length - 1 - redoPosition]); | |
193 } | |
194 | |
195 return false; | |
196 }; | |
197 | |
198 base.redo = function () { | |
199 if (redoPosition > 0) { | |
200 redoPosition--; | |
201 applyState(undoStates[undoStates.length - 1 - redoPosition]); | |
202 } | |
203 | |
204 return false; | |
205 }; | |
206 | |
207 /** | |
208 * Handle the selectionchanged event so can store the last caret | |
209 * position before the input so undoing places it in the right place | |
210 */ | |
211 base.signalSelectionchangedEvent = function () { | |
212 if (isApplying || isSelectionChangeHandled) { | |
213 isSelectionChangeHandled = false; | |
214 return; | |
215 } | |
216 if (lastState) { | |
217 updateLastState(); | |
218 } | |
219 lastInputType = ''; | |
220 }; | |
221 | |
222 /** | |
223 * Handles the input event | |
224 * @param {InputEvent} e | |
225 */ | |
226 base.signalInputEvent = function (e) { | |
227 // InputType is one of | |
228 // https://rawgit.com/w3c/input-events/v1/index.html#interface-InputEvent-Attributes | |
229 // Most should cause a full undo item to be added so only need to | |
230 // handle a few of them | |
231 var inputType = e.inputType; | |
232 | |
233 // Should ignore selection changes that occur because of input | |
234 // events as already handling them | |
235 isSelectionChangeHandled = true; | |
236 | |
237 // inputType should be supported by all supported browsers | |
238 // except IE 11 in runWithoutWysiwygSupport. Shouldn't be an issue | |
239 // as native handling will mostly work there. | |
240 // Ignore if composing as will handle composition end instead | |
241 if (!inputType || e.isComposing) { | |
242 return; | |
243 } | |
244 | |
245 switch (e.inputType) { | |
246 case 'deleteContentBackward': | |
247 if (lastState && lastInputType === inputType && | |
248 charChangedCount < 20) { | |
249 updateLastState(); | |
250 } else { | |
251 storeState(); | |
252 charChangedCount = 0; | |
253 } | |
254 | |
255 lastInputType = inputType; | |
256 break; | |
257 | |
258 case 'insertText': | |
259 charChangedCount += e.data ? e.data.length : 1; | |
260 | |
261 if (lastState && lastInputType === inputType && | |
262 charChangedCount < 20 && !/\s$/.test(e.data)) { | |
263 updateLastState(); | |
264 } else { | |
265 storeState(); | |
266 charChangedCount = 0; | |
267 } | |
268 | |
269 lastInputType = inputType; | |
270 break; | |
271 default: | |
272 lastInputType = 'sce-misc'; | |
273 charChangedCount = 0; | |
274 storeState(); | |
275 break; | |
276 } | |
277 }; | |
278 | |
279 /** | |
280 * Creates a positions object form passed range | |
281 * @param {Range} range | |
282 * @return {Object<string, Array<number>} | |
283 */ | |
284 function getRangePositions(range) { | |
285 // Merge any adjacent text nodes as it will be done by innerHTML | |
286 // which would cause positions to be off if not done | |
287 body.normalize(); | |
288 | |
289 return { | |
290 startPositions: | |
291 nodeToPositions(range.startContainer, range.startOffset), | |
292 endPositions: | |
293 nodeToPositions(range.endContainer, range.endOffset) | |
294 }; | |
295 } | |
296 | |
297 /** | |
298 * Sets the range start/end based on the positions object | |
299 * @param {Range} range | |
300 * @param {Object<string, Array<number>>} positions | |
301 */ | |
302 function setRangePositions(range, positions) { | |
303 try { | |
304 var startPositions = positions.startPositions; | |
305 var endPositions = positions.endPositions; | |
306 | |
307 range.setStart(positionsToNode(body, startPositions), | |
308 startPositions[0]); | |
309 range.setEnd(positionsToNode(body, endPositions), | |
310 endPositions[0]); | |
311 } catch (e) { | |
312 if (console && console.warn) { | |
313 console.warn('[SCEditor] Undo plugin lost caret', e); | |
314 } | |
315 } | |
316 } | |
317 | |
318 /** | |
319 * Converts the passed container and offset into positions array | |
320 * @param {Node} container | |
321 * @param {number} offset | |
322 * @returns {Array<number>} | |
323 */ | |
324 function nodeToPositions(container, offset) { | |
325 var positions = [offset]; | |
326 var node = container; | |
327 | |
328 while (node && node.tagName !== 'BODY') { | |
329 positions.push(nodeIndex(node)); | |
330 node = node.parentNode; | |
331 } | |
332 | |
333 return positions; | |
334 } | |
335 | |
336 /** | |
337 * Returns index of passed node | |
338 * @param {Node} node | |
339 * @returns {number} | |
340 */ | |
341 function nodeIndex(node) { | |
342 var i = 0; | |
343 while ((node = node.previousSibling)) { | |
344 i++; | |
345 } | |
346 return i; | |
347 } | |
348 | |
349 /** | |
350 * Gets the container node from the positions array | |
351 * @param {Node} node | |
352 * @param {Array<number>} positions | |
353 * @returns {Node} | |
354 */ | |
355 function positionsToNode(node, positions) { | |
356 for (var i = positions.length - 1; node && i > 0; i--) { | |
357 node = node.childNodes[positions[i]]; | |
358 } | |
359 return node; | |
360 } | |
361 }; | |
362 }(sceditor)); |