0
Fork 0
mirror of https://github.com/fastmail/Squire.git synced 2025-01-03 05:00:13 -05:00

Use mutation observers where possible to detect change

We need to know when the document is modified in order to fire an "input" event
and set the undo/redo state correctly. Observing keyup is imprecise, as it's
hard to tell whether the key press actually modified anything. Newer browsers
support mutation observers, which tell you precisely when something has changed.
For IE9/10, Opera 12 and other older browsers, we fall back to observing keyup
again.

Fixes #26.
This commit is contained in:
Neil Jenkins 2014-12-27 13:48:15 +07:00
parent 5b5d65f684
commit b69a1635de
4 changed files with 112 additions and 46 deletions

View file

@ -36,6 +36,8 @@ var useTextFixer = isIElt11 || isPresto;
var cantFocusEmptyTextNodes = isIElt11 || isWebKit; var cantFocusEmptyTextNodes = isIElt11 || isWebKit;
var losesSelectionOnBlur = isIElt11; var losesSelectionOnBlur = isIElt11;
var canObserveMutations = typeof MutationObserver !== 'undefined';
// Use [^ \t\r\n] instead of \S so that nbsp does not count as white-space // Use [^ \t\r\n] instead of \S so that nbsp does not count as white-space
var notWS = /[^ \t\r\n]/; var notWS = /[^ \t\r\n]/;
@ -1081,6 +1083,8 @@ var instances = [];
function Squire ( doc ) { function Squire ( doc ) {
var win = doc.defaultView; var win = doc.defaultView;
var body = doc.body; var body = doc.body;
var mutation;
this._win = win; this._win = win;
this._doc = doc; this._doc = doc;
this._body = body; this._body = body;
@ -1112,11 +1116,23 @@ function Squire ( doc ) {
this._undoStack = []; this._undoStack = [];
this._undoStackLength = 0; this._undoStackLength = 0;
this._isInUndoState = false; this._isInUndoState = false;
this._ignoreChange = false;
if ( canObserveMutations ) {
mutation = new MutationObserver( this._docWasChanged.bind( this ) );
mutation.observe( body, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
this._mutation = mutation;
} else {
this.addEventListener( 'keyup', this._keyUpDetectChange );
}
this.defaultBlockProperties = undefined; this.defaultBlockProperties = undefined;
this.addEventListener( 'keyup', this._docWasChanged );
// IE sometimes fires the beforepaste event twice; make sure it is not run // IE sometimes fires the beforepaste event twice; make sure it is not run
// again before our after paste function is called. // again before our after paste function is called.
this._awaitingPaste = false; this._awaitingPaste = false;
@ -1238,6 +1254,9 @@ proto.destroy = function () {
doc.removeEventListener( type, this, true ); doc.removeEventListener( type, this, true );
} }
} }
if ( this._mutation ) {
this._mutation.disconnect();
}
var l = instances.length; var l = instances.length;
while ( l-- ) { while ( l-- ) {
if ( instances[l] === this ) { if ( instances[l] === this ) {
@ -1514,26 +1533,34 @@ proto._getRangeAndRemoveBookmark = function ( range ) {
// --- Undo --- // --- Undo ---
proto._docWasChanged = function ( event ) { proto._keyUpDetectChange = function ( event ) {
var code = event && event.keyCode; var code = event.keyCode;
// Presume document was changed if: // Presume document was changed if:
// 1. A modifier key (other than shift) wasn't held down // 1. A modifier key (other than shift) wasn't held down
// 2. The key pressed is not in range 16<=x<=20 (control keys) // 2. The key pressed is not in range 16<=x<=20 (control keys)
// 3. The key pressed is not in range 33<=x<=45 (navigation keys) // 3. The key pressed is not in range 33<=x<=45 (navigation keys)
if ( !event || ( !event.ctrlKey && !event.metaKey && !event.altKey && if ( !event.ctrlKey && !event.metaKey && !event.altKey &&
( code < 16 || code > 20 ) && ( code < 16 || code > 20 ) &&
( code < 33 || code > 45 ) ) ) { ( code < 33 || code > 45 ) ) {
if ( this._isInUndoState ) { this._docWasChanged();
this._isInUndoState = false;
this.fireEvent( 'undoStateChange', {
canUndo: true,
canRedo: false
});
}
this.fireEvent( 'input' );
} }
}; };
proto._docWasChanged = function () {
if ( canObserveMutations && this._ignoreChange ) {
this._ignoreChange = false;
return;
}
if ( this._isInUndoState ) {
this._isInUndoState = false;
this.fireEvent( 'undoStateChange', {
canUndo: true,
canRedo: false
});
}
this.fireEvent( 'input' );
};
// Leaves bookmark // Leaves bookmark
proto._recordUndoState = function ( range ) { proto._recordUndoState = function ( range ) {
// Don't record if we're already in an undo state // Don't record if we're already in an undo state
@ -1564,6 +1591,7 @@ proto.undo = function () {
this._recordUndoState( this.getSelection() ); this._recordUndoState( this.getSelection() );
this._undoIndex -= 1; this._undoIndex -= 1;
this._ignoreChange = true;
this._setHTML( this._undoStack[ this._undoIndex ] ); this._setHTML( this._undoStack[ this._undoIndex ] );
var range = this._getRangeAndRemoveBookmark(); var range = this._getRangeAndRemoveBookmark();
if ( range ) { if ( range ) {
@ -1586,6 +1614,7 @@ proto.redo = function () {
undoStackLength = this._undoStackLength; undoStackLength = this._undoStackLength;
if ( undoIndex + 1 < undoStackLength && this._isInUndoState ) { if ( undoIndex + 1 < undoStackLength && this._isInUndoState ) {
this._undoIndex += 1; this._undoIndex += 1;
this._ignoreChange = true;
this._setHTML( this._undoStack[ this._undoIndex ] ); this._setHTML( this._undoStack[ this._undoIndex ] );
var range = this._getRangeAndRemoveBookmark(); var range = this._getRangeAndRemoveBookmark();
if ( range ) { if ( range ) {
@ -1863,7 +1892,9 @@ proto.changeFormat = function ( add, remove, range, partial ) {
this._updatePath( range, true ); this._updatePath( range, true );
// We're not still in an undo state // We're not still in an undo state
this._docWasChanged(); if ( !canObserveMutations ) {
this._docWasChanged();
}
return this; return this;
}; };
@ -1927,7 +1958,9 @@ proto.forEachBlock = function ( fn, mutates, range ) {
this._updatePath( range, true ); this._updatePath( range, true );
// We're not still in an undo state // We're not still in an undo state
this._docWasChanged(); if ( !canObserveMutations ) {
this._docWasChanged();
}
} }
return this; return this;
}; };
@ -1968,7 +2001,9 @@ proto.modifyBlocks = function ( modify, range ) {
this._updatePath( range, true ); this._updatePath( range, true );
// 7. We're not still in an undo state // 7. We're not still in an undo state
this._docWasChanged(); if ( !canObserveMutations ) {
this._docWasChanged();
}
return this; return this;
}; };
@ -2631,7 +2666,9 @@ proto._onPaste = function ( event ) {
// Insert pasted data // Insert pasted data
if ( doPaste ) { if ( doPaste ) {
insertTreeFragmentIntoRange( range, frag ); insertTreeFragmentIntoRange( range, frag );
self._docWasChanged(); if ( !canObserveMutations ) {
self._docWasChanged();
}
range.collapse( false ); range.collapse( false );
self._ensureBottomLine(); self._ensureBottomLine();
} }
@ -2760,7 +2797,6 @@ var keyHandlers = {
range.collapse( false ); range.collapse( false );
self.setSelection( range ); self.setSelection( range );
self._updatePath( range, true ); self._updatePath( range, true );
self._docWasChanged();
return; return;
} }
@ -2872,9 +2908,6 @@ var keyHandlers = {
body.offsetHeight ) { body.offsetHeight ) {
nodeAfterSplit.scrollIntoView( false ); nodeAfterSplit.scrollIntoView( false );
} }
// We're not still in an undo state
self._docWasChanged();
}, },
backspace: function ( self, event, range ) { backspace: function ( self, event, range ) {
self._removeZWS(); self._removeZWS();

File diff suppressed because one or more lines are too long

View file

@ -31,6 +31,8 @@ var useTextFixer = isIElt11 || isPresto;
var cantFocusEmptyTextNodes = isIElt11 || isWebKit; var cantFocusEmptyTextNodes = isIElt11 || isWebKit;
var losesSelectionOnBlur = isIElt11; var losesSelectionOnBlur = isIElt11;
var canObserveMutations = typeof MutationObserver !== 'undefined';
// Use [^ \t\r\n] instead of \S so that nbsp does not count as white-space // Use [^ \t\r\n] instead of \S so that nbsp does not count as white-space
var notWS = /[^ \t\r\n]/; var notWS = /[^ \t\r\n]/;

View file

@ -5,6 +5,8 @@ var instances = [];
function Squire ( doc ) { function Squire ( doc ) {
var win = doc.defaultView; var win = doc.defaultView;
var body = doc.body; var body = doc.body;
var mutation;
this._win = win; this._win = win;
this._doc = doc; this._doc = doc;
this._body = body; this._body = body;
@ -36,11 +38,23 @@ function Squire ( doc ) {
this._undoStack = []; this._undoStack = [];
this._undoStackLength = 0; this._undoStackLength = 0;
this._isInUndoState = false; this._isInUndoState = false;
this._ignoreChange = false;
if ( canObserveMutations ) {
mutation = new MutationObserver( this._docWasChanged.bind( this ) );
mutation.observe( body, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
this._mutation = mutation;
} else {
this.addEventListener( 'keyup', this._keyUpDetectChange );
}
this.defaultBlockProperties = undefined; this.defaultBlockProperties = undefined;
this.addEventListener( 'keyup', this._docWasChanged );
// IE sometimes fires the beforepaste event twice; make sure it is not run // IE sometimes fires the beforepaste event twice; make sure it is not run
// again before our after paste function is called. // again before our after paste function is called.
this._awaitingPaste = false; this._awaitingPaste = false;
@ -162,6 +176,9 @@ proto.destroy = function () {
doc.removeEventListener( type, this, true ); doc.removeEventListener( type, this, true );
} }
} }
if ( this._mutation ) {
this._mutation.disconnect();
}
var l = instances.length; var l = instances.length;
while ( l-- ) { while ( l-- ) {
if ( instances[l] === this ) { if ( instances[l] === this ) {
@ -438,26 +455,34 @@ proto._getRangeAndRemoveBookmark = function ( range ) {
// --- Undo --- // --- Undo ---
proto._docWasChanged = function ( event ) { proto._keyUpDetectChange = function ( event ) {
var code = event && event.keyCode; var code = event.keyCode;
// Presume document was changed if: // Presume document was changed if:
// 1. A modifier key (other than shift) wasn't held down // 1. A modifier key (other than shift) wasn't held down
// 2. The key pressed is not in range 16<=x<=20 (control keys) // 2. The key pressed is not in range 16<=x<=20 (control keys)
// 3. The key pressed is not in range 33<=x<=45 (navigation keys) // 3. The key pressed is not in range 33<=x<=45 (navigation keys)
if ( !event || ( !event.ctrlKey && !event.metaKey && !event.altKey && if ( !event.ctrlKey && !event.metaKey && !event.altKey &&
( code < 16 || code > 20 ) && ( code < 16 || code > 20 ) &&
( code < 33 || code > 45 ) ) ) { ( code < 33 || code > 45 ) ) {
if ( this._isInUndoState ) { this._docWasChanged();
this._isInUndoState = false;
this.fireEvent( 'undoStateChange', {
canUndo: true,
canRedo: false
});
}
this.fireEvent( 'input' );
} }
}; };
proto._docWasChanged = function () {
if ( canObserveMutations && this._ignoreChange ) {
this._ignoreChange = false;
return;
}
if ( this._isInUndoState ) {
this._isInUndoState = false;
this.fireEvent( 'undoStateChange', {
canUndo: true,
canRedo: false
});
}
this.fireEvent( 'input' );
};
// Leaves bookmark // Leaves bookmark
proto._recordUndoState = function ( range ) { proto._recordUndoState = function ( range ) {
// Don't record if we're already in an undo state // Don't record if we're already in an undo state
@ -488,6 +513,7 @@ proto.undo = function () {
this._recordUndoState( this.getSelection() ); this._recordUndoState( this.getSelection() );
this._undoIndex -= 1; this._undoIndex -= 1;
this._ignoreChange = true;
this._setHTML( this._undoStack[ this._undoIndex ] ); this._setHTML( this._undoStack[ this._undoIndex ] );
var range = this._getRangeAndRemoveBookmark(); var range = this._getRangeAndRemoveBookmark();
if ( range ) { if ( range ) {
@ -510,6 +536,7 @@ proto.redo = function () {
undoStackLength = this._undoStackLength; undoStackLength = this._undoStackLength;
if ( undoIndex + 1 < undoStackLength && this._isInUndoState ) { if ( undoIndex + 1 < undoStackLength && this._isInUndoState ) {
this._undoIndex += 1; this._undoIndex += 1;
this._ignoreChange = true;
this._setHTML( this._undoStack[ this._undoIndex ] ); this._setHTML( this._undoStack[ this._undoIndex ] );
var range = this._getRangeAndRemoveBookmark(); var range = this._getRangeAndRemoveBookmark();
if ( range ) { if ( range ) {
@ -787,7 +814,9 @@ proto.changeFormat = function ( add, remove, range, partial ) {
this._updatePath( range, true ); this._updatePath( range, true );
// We're not still in an undo state // We're not still in an undo state
this._docWasChanged(); if ( !canObserveMutations ) {
this._docWasChanged();
}
return this; return this;
}; };
@ -851,7 +880,9 @@ proto.forEachBlock = function ( fn, mutates, range ) {
this._updatePath( range, true ); this._updatePath( range, true );
// We're not still in an undo state // We're not still in an undo state
this._docWasChanged(); if ( !canObserveMutations ) {
this._docWasChanged();
}
} }
return this; return this;
}; };
@ -892,7 +923,9 @@ proto.modifyBlocks = function ( modify, range ) {
this._updatePath( range, true ); this._updatePath( range, true );
// 7. We're not still in an undo state // 7. We're not still in an undo state
this._docWasChanged(); if ( !canObserveMutations ) {
this._docWasChanged();
}
return this; return this;
}; };
@ -1555,7 +1588,9 @@ proto._onPaste = function ( event ) {
// Insert pasted data // Insert pasted data
if ( doPaste ) { if ( doPaste ) {
insertTreeFragmentIntoRange( range, frag ); insertTreeFragmentIntoRange( range, frag );
self._docWasChanged(); if ( !canObserveMutations ) {
self._docWasChanged();
}
range.collapse( false ); range.collapse( false );
self._ensureBottomLine(); self._ensureBottomLine();
} }
@ -1684,7 +1719,6 @@ var keyHandlers = {
range.collapse( false ); range.collapse( false );
self.setSelection( range ); self.setSelection( range );
self._updatePath( range, true ); self._updatePath( range, true );
self._docWasChanged();
return; return;
} }
@ -1796,9 +1830,6 @@ var keyHandlers = {
body.offsetHeight ) { body.offsetHeight ) {
nodeAfterSplit.scrollIntoView( false ); nodeAfterSplit.scrollIntoView( false );
} }
// We're not still in an undo state
self._docWasChanged();
}, },
backspace: function ( self, event, range ) { backspace: function ( self, event, range ) {
self._removeZWS(); self._removeZWS();