jenkins-bot has submitted this change and it was merged.

Change subject: De-Houdini-fy the handling of observed DOM changes
......................................................................


De-Houdini-fy the handling of observed DOM changes

The SurfaceObserver does far less change calculation now; it simply passes
the old and new RangeState objects to ve.ce.Surface#handleObservedChanges ,
which performs the modifications for contentChanged, selectionChanged and
branchNodeChanged in a single method.

The calculation of a ve.dm.Transaction based on a content change is moved
to a pure function ve.ce.dmChangeFromContentChange.

The cursor focus's annotation sidedness is checked when polling RangeState,
instead of at keyDown, which is better since IMEs may emit no keyDown event.

Bug: T113797
Bug: T113875
Change-Id: I420c14f56187bb881c4b636a746d4383d9ba3146
---
M src/ce/ve.ce.RangeState.js
M src/ce/ve.ce.Surface.js
M src/ce/ve.ce.SurfaceObserver.js
M src/ce/ve.ce.js
M tests/ce/ve.ce.Surface.test.js
5 files changed, 315 insertions(+), 326 deletions(-)

Approvals:
  Esanders: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/src/ce/ve.ce.RangeState.js b/src/ce/ve.ce.RangeState.js
index d0bedb6..22047d5 100644
--- a/src/ce/ve.ce.RangeState.js
+++ b/src/ce/ve.ce.RangeState.js
@@ -27,6 +27,9 @@
 
        /**
         * @property {boolean} contentChanged Whether the content changed
+        *
+        * This is only set to true if both the old and new states have the
+        * same current branch node, whose content has changed
         */
        this.contentChanged = false;
 
@@ -49,6 +52,11 @@
         * @property {string|null} DOM Hash of current branch node
         */
        this.hash = null;
+
+       /**
+        * @property {boolean|null} focusIsAfterAnnotationBoundary Focus lies 
after annotation tag
+        */
+       this.focusIsAfterAnnotationBoundary = null;
 
        this.saveState( old, documentNode, selectionOnly );
 };
@@ -133,6 +141,17 @@
                        ( old && old.text ) !== this.text
                );
 
+       if ( old && !this.selectionChanged && !this.contentChanged ) {
+               this.focusIsAfterAnnotationBoundary = 
old.focusIsAfterAnnotationBoundary;
+       } else {
+               // Will be null if there is no selection
+               this.focusIsAfterAnnotationBoundary = selection.focusNode &&
+                       ve.ce.isAfterAnnotationBoundary(
+                               selection.focusNode,
+                               selection.focusOffset
+                       );
+       }
+
        // Save selection for future comparisons. (But it is not properly 
frozen, because the nodes
        // are live and mutable, and therefore the offsets may come to point to 
places that are
        // misleadingly different from when the selection was saved).
diff --git a/src/ce/ve.ce.Surface.js b/src/ce/ve.ce.Surface.js
index 7028c4d..ecbc785 100644
--- a/src/ce/ve.ce.Surface.js
+++ b/src/ce/ve.ce.Surface.js
@@ -75,8 +75,7 @@
        // Snapshot updated at keyDown. See storeKeyDownState.
        this.keyDownState = {
                event: null,
-               selection: null,
-               focusIsAfterAnnotationBoundary: null
+               selection: null
        };
 
        this.cursorDirectionality = null;
@@ -2552,60 +2551,83 @@
  *
  * These are normally caused by the user interacting directly with the 
contenteditable.
  *
- * @param {Object|null} contentChange Observed change in a CE node's content
- * @param {ve.ce.Node} contentChange.node CE node the change occurred in
- * @param {Object} contentChange.previous Old data
- * @param {Object} contentChange.next New data
- *
- * @param {Object|null} branchNodeChange Change of the selected BranchNode
- * @param {ve.ce.BranchNode} branchNodeChange.oldBranchNode Node from which 
the range anchor has just moved
- * @param {ve.ce.BranchNode} branchNodeChange.newBranchNode Node into which 
the range anchor has just moved
- *
- * @param {Object|null} rangeChange Change of the DOM selection
- * @param {ve.Range|null} rangeChange.oldRange The old DM range
- * @param {ve.Range|null} rangeChange.newRange The new DM range
- *
- * TODO: Clean up this interface: it's a minimal incremental step from the old 
emit-based interface
+ * @param {ve.ce.RangeState|null} oldState The prior range state, if any
+ * @param {ve.ce.RangeState} newState The changed range state
  */
 
-ve.ce.Surface.prototype.handleObservedChanges = function ( contentChange, 
branchNodeChange, rangeChange ) {
-       if ( contentChange ) {
-               this.onObservedContentChange(
-                       contentChange.node,
-                       contentChange.previous,
-                       contentChange.next
-               );
+ve.ce.Surface.prototype.handleObservedChanges = function ( oldState, newState 
) {
+       var newSelection, dmContentChange,
+               dmDoc = this.getModel().getDocument(),
+               insertedText = false;
+
+       if ( newState.contentChanged ) {
+               dmContentChange = ve.ce.modelChangeFromContentChange( oldState, 
newState );
+               if ( !dmContentChange.rerender ) {
+                       this.incRenderLock();
+               }
+               try {
+                       this.changeModel( dmContentChange.transaction, 
dmContentChange.selection );
+               } finally {
+                       if ( !dmContentChange.rerender ) {
+                               this.decRenderLock();
+                       }
+               }
+
+               insertedText = dmContentChange.transaction.operations.filter( 
function ( op ) {
+                       return op.type === 'replace' && op.insert.length;
+               } ).length > 0;
        }
-       if ( branchNodeChange ) {
-               this.onObservedBranchNodeChange(
-                       branchNodeChange.oldBranchNode,
-                       branchNodeChange.newBranchNode
-               );
+
+       if (
+               newState.branchNodeChanged &&
+               oldState &&
+               oldState.node &&
+               oldState.node.root &&
+               oldState.node instanceof ve.ce.ContentBranchNode
+       ) {
+               oldState.node.renderContents();
        }
-       if ( rangeChange ) {
-               this.onObservedRangeChange(
-                       rangeChange.oldRange,
-                       rangeChange.newRange
-               );
+
+       if ( newState.selectionChanged && !(
+               // Ignore when the newRange is just a flipped oldRange
+               oldState &&
+               oldState.veRange &&
+               newState.veRange &&
+               !newState.veRange.isCollapsed() &&
+               oldState.veRange.equalsSelection( newState.veRange )
+       ) ) {
+               if ( newState.veRange ) {
+                       newSelection = new ve.dm.LinearSelection( dmDoc, 
newState.veRange );
+               } else {
+                       newSelection = new ve.dm.NullSelection( dmDoc );
+               }
+               this.incRenderLock();
+               try {
+                       this.changeModel( null, newSelection );
+               } finally {
+                       this.decRenderLock();
+               }
+               this.checkUnicorns( false );
+
+               // Firefox lets you create multiple selections within a single 
paragraph
+               // which our model doesn't support, so detect and prevent these.
+               // This shouldn't create problems with IME candidates as only 
an explicit user
+               // action can create a multiple selection (CTRL+click), and we 
remove it
+               // immediately, so there can never be a multiple selection 
while the user is
+               // typing text; therefore the selection change will never 
commit IME candidates
+               // prematurely.
+               while ( this.nativeSelection.rangeCount > 1 ) {
+                       // The current range is the last range, so remove 
ranges from the front
+                       this.nativeSelection.removeRange( 
this.nativeSelection.getRangeAt( 0 ) );
+               }
        }
-       if ( branchNodeChange && branchNodeChange.newBranchNode ) {
+
+       if ( insertedText ) {
+               this.checkSequences();
+       }
+       if ( newState.branchNodeChanged && newState.node ) {
                this.updateCursorHolders();
                this.showModelSelection( this.getModel().getSelection() );
-       }
-};
-
-/**
- * Handle branch node changes observed from the DOM.
- *
- * @see ve.ce.SurfaceObserver#pollOnce
- *
- * @method
- * @param {ve.ce.BranchNode} oldBranchNode Node from which the range anchor 
has just moved
- * @param {ve.ce.BranchNode} newBranchNode Node into which the range anchor 
has just moved
- */
-ve.ce.Surface.prototype.onObservedBranchNodeChange = function ( oldBranchNode 
) {
-       if ( oldBranchNode instanceof ve.ce.ContentBranchNode ) {
-               oldBranchNode.renderContents();
        }
 };
 
@@ -2638,48 +2660,6 @@
        } );
 
        this.onModelSelect();
-};
-
-/**
- * Handle selection changes observed from the DOM.
- *
- * This can be fired by a DOM selection change that doesn't cause any change 
in the DM range,
- * with oldRange === newRange .
- *
- * @see ve.ce.SurfaceObserver#pollOnce
- *
- * @method
- * @param {ve.Range|null} oldRange
- * @param {ve.Range|null} newRange
- */
-ve.ce.Surface.prototype.onObservedRangeChange = function ( oldRange, newRange 
) {
-       if ( newRange && !newRange.isCollapsed() && oldRange && 
oldRange.equalsSelection( newRange ) ) {
-               // Ignore when the newRange is just a flipped oldRange
-               return;
-       }
-
-       this.incRenderLock();
-       try {
-               this.changeModel(
-                       null,
-                       newRange ?
-                               new ve.dm.LinearSelection( 
this.getModel().getDocument(), newRange ) :
-                               new ve.dm.NullSelection( 
this.getModel().getDocument() )
-               );
-       } finally {
-               this.decRenderLock();
-       }
-       this.checkUnicorns( false );
-       // Firefox lets you create multiple selections within a single paragraph
-       // which our model doesn't support, so detect and prevent these.
-       // This shouldn't create problems with IME candidates as only an 
explicit user action
-       // can create a multiple selection (CTRL+click), and we remove it 
immediately, so there can
-       // never be a multiple selection while the user is typing text; 
therefore the
-       // selection change will never commit IME candidates prematurely.
-       while ( this.nativeSelection.rangeCount > 1 ) {
-               // The current range is the last range, so remove ranges from 
the front
-               this.nativeSelection.removeRange( 
this.nativeSelection.getRangeAt( 0 ) );
-       }
 };
 
 /**
@@ -2745,174 +2725,6 @@
                focusNode: node,
                focusOffset: offset
        } ) );
-};
-
-/**
- * Handle content changes observed from the DOM.
- *
- * The DOM change is analysed heuristically to build a semantically meaningful 
Transaction
- * with the annotations the user intended.
- *
- * @see ve.ce.SurfaceObserver#pollOnce
- *
- * @method
- * @param {ve.ce.Node} node CE node the change occurred in
- * @param {Object} previous Old data
- * @param {Object} previous.text Old plain text content
- * @param {Object} previous.hash Old DOM hash
- * @param {ve.Range} previous.range Old selection
- * @param {Object} next New data
- * @param {Object} next.text New plain text content
- * @param {Object} next.hash New DOM hash
- * @param {ve.Range} next.range New selection
- */
-ve.ce.Surface.prototype.onObservedContentChange = function ( node, previous, 
next ) {
-       var data, range, len, annotations, offsetDiff, sameLeadingAndTrailing,
-               previousStart, nextStart, newRange, replacementRange,
-               fromLeft = 0,
-               fromRight = 0,
-               nodeOffset = node.getModel().getOffset(),
-               previousData = previous.text.split( '' ),
-               nextData = next.text.split( '' ),
-               dmDoc = this.getModel().getDocument(),
-               modelData = dmDoc.data,
-               lengthDiff = next.text.length - previous.text.length,
-               surface = this;
-
-       if ( previous.range && next.range ) {
-               offsetDiff = ( previous.range.isCollapsed() && 
next.range.isCollapsed() ) ?
-                       next.range.start - previous.range.start : null;
-               previousStart = previous.range.start - nodeOffset - 1;
-               nextStart = next.range.start - nodeOffset - 1;
-               sameLeadingAndTrailing = offsetDiff !== null && (
-                       (
-                               lengthDiff > 0 &&
-                               previous.text.slice( 0, previousStart ) ===
-                                       next.text.slice( 0, previousStart ) &&
-                               previous.text.slice( previousStart ) ===
-                                       next.text.slice( nextStart )
-                       ) ||
-                       (
-                               lengthDiff < 0 &&
-                               previous.text.slice( 0, nextStart ) ===
-                                       next.text.slice( 0, nextStart ) &&
-                               previous.text.slice( previousStart - lengthDiff 
+ offsetDiff ) ===
-                                       next.text.slice( nextStart )
-                       )
-               );
-
-               // Simple insertion
-               if ( lengthDiff > 0 && offsetDiff === lengthDiff && 
sameLeadingAndTrailing ) {
-                       data = nextData.slice( previousStart, nextStart );
-                       // Apply insertion annotations
-                       if ( node.unicornAnnotations ) {
-                               annotations = node.unicornAnnotations;
-                       } else if ( 
this.keyDownState.focusIsAfterAnnotationBoundary ) {
-                               annotations = 
modelData.getAnnotationsFromOffset(
-                                       nodeOffset + previousStart + 1
-                               );
-                       } else {
-                               annotations = 
this.model.getInsertionAnnotations();
-                       }
-
-                       if ( annotations.getLength() ) {
-                               ve.dm.Document.static.addAnnotationsToData( 
data, annotations );
-                       }
-
-                       this.incRenderLock();
-                       try {
-                               this.changeModel(
-                                       ve.dm.Transaction.newFromInsertion(
-                                               this.documentView.model, 
previous.range.start, data
-                                       ),
-                                       new ve.dm.LinearSelection( 
this.documentView.model, next.range )
-                               );
-                       } finally {
-                               this.decRenderLock();
-                       }
-                       setTimeout( function () {
-                               surface.checkSequences();
-                       } );
-                       return;
-               }
-
-               // Simple deletion
-               if ( ( offsetDiff === 0 || offsetDiff === lengthDiff ) && 
sameLeadingAndTrailing ) {
-                       if ( offsetDiff === 0 ) {
-                               range = new ve.Range( next.range.start, 
next.range.start - lengthDiff );
-                       } else {
-                               range = new ve.Range( next.range.start, 
previous.range.start );
-                       }
-                       this.incRenderLock();
-                       try {
-                               this.changeModel(
-                                       ve.dm.Transaction.newFromRemoval( 
this.documentView.model,
-                                               range ),
-                                       new ve.dm.LinearSelection( 
this.documentView.model, next.range )
-                               );
-                       } finally {
-                               this.decRenderLock();
-                       }
-                       return;
-               }
-       }
-
-       // Complex change:
-       // 1. Count unchanged characters from left and right;
-       // 2. Assume that the minimal changed region indicates the replacement 
made by the user;
-       // 3. Hence guess how to map annotations.
-       // N.B. this logic can go wrong; e.g. this code will see slice->slide 
and
-       // assume that the user changed 'c' to 'd', but the user could instead 
have changed 'ic'
-       // to 'id', which would map annotations differently.
-
-       len = Math.min( previousData.length, nextData.length );
-
-       while ( fromLeft < len && previousData[ fromLeft ] === nextData[ 
fromLeft ] ) {
-               ++fromLeft;
-       }
-
-       while (
-               fromRight < len - fromLeft &&
-               previousData[ previousData.length - 1 - fromRight ] ===
-               nextData[ nextData.length - 1 - fromRight ]
-       ) {
-               ++fromRight;
-       }
-       replacementRange = new ve.Range(
-               nodeOffset + 1 + fromLeft,
-               nodeOffset + 1 + previousData.length - fromRight
-       );
-       data = nextData.slice( fromLeft, nextData.length - fromRight );
-
-       if ( node.unicornAnnotations ) {
-               // This CBN is unicorned. Use the stored annotations.
-               annotations = node.unicornAnnotations;
-       } else if ( fromLeft + fromRight < previousData.length ) {
-               // Content is being removed, so guess that we want to use the 
annotations from the
-               // start of the removed content.
-               annotations = modelData.getAnnotationsFromOffset( 
replacementRange.start );
-       } else {
-               // No content is being removed, so guess that we want to use 
the annotations from
-               // just before the insertion (which means none at all if the 
insertion is at the
-               // start of a CBN).
-               annotations = modelData.getAnnotationsFromOffset( 
replacementRange.start - 1 );
-       }
-       if ( annotations.getLength() ) {
-               ve.dm.Document.static.addAnnotationsToData( data, annotations );
-       }
-       newRange = next.range;
-       if ( newRange.isCollapsed() ) {
-               newRange = new ve.Range( dmDoc.getNearestCursorOffset( 
newRange.start, 1 ) );
-       }
-
-       this.changeModel(
-               ve.dm.Transaction.newFromReplacement( this.documentView.model, 
replacementRange, data ),
-               new ve.dm.LinearSelection( this.documentView.model, newRange )
-       );
-       this.queueCheckSequences = true;
-       setTimeout( function () {
-               surface.checkSequences();
-       } );
 };
 
 /**
@@ -3007,28 +2819,19 @@
  * modified, because anchorNode/focusNode are live and mutable, and so the 
offsets may come to
  * point confusingly to different places than they did when the selection was 
saved).
  *
- * Annotation changes before the cursor focus are detected: see 
ve.ce.isAfterAnnotationBoundary .
- *
  * @param {jQuery.Event|null} e Key down event; must be active when this call 
is made
  */
 ve.ce.Surface.prototype.storeKeyDownState = function ( e ) {
        this.keyDownState.event = e;
        this.keyDownState.selection = null;
-       this.keyDownState.focusIsAfterAnnotationBoundary = null;
 
-       if ( this.nativeSelection.rangeCount > 0 ) {
-               this.keyDownState.focusIsAfterAnnotationBoundary = 
ve.ce.isAfterAnnotationBoundary(
-                       this.nativeSelection.focusNode,
-                       this.nativeSelection.focusOffset
-               );
-               if ( e && (
-                       e.keyCode === OO.ui.Keys.UP ||
-                       e.keyCode === OO.ui.Keys.DOWN ||
-                       e.keyCode === OO.ui.Keys.LEFT ||
-                       e.keyCode === OO.ui.Keys.RIGHT
-               ) ) {
-                       this.keyDownState.selection = new ve.SelectionState( 
this.nativeSelection );
-               }
+       if ( this.nativeSelection.rangeCount > 0 && e && (
+               e.keyCode === OO.ui.Keys.UP ||
+               e.keyCode === OO.ui.Keys.DOWN ||
+               e.keyCode === OO.ui.Keys.LEFT ||
+               e.keyCode === OO.ui.Keys.RIGHT
+       ) ) {
+               this.keyDownState.selection = new ve.SelectionState( 
this.nativeSelection );
        }
 };
 
diff --git a/src/ce/ve.ce.SurfaceObserver.js b/src/ce/ve.ce.SurfaceObserver.js
index 9f36fa2..4ace94b 100644
--- a/src/ce/ve.ce.SurfaceObserver.js
+++ b/src/ce/ve.ce.SurfaceObserver.js
@@ -115,11 +115,7 @@
 /**
  * Poll for changes.
  *
- * TODO: fixing selection in certain cases, handling selection across multiple 
nodes in Firefox
- *
  * @method
- * @fires contentChange
- * @fires rangeChange
  */
 ve.ce.SurfaceObserver.prototype.pollOnce = function () {
        this.pollOnceInternal( true );
@@ -148,20 +144,13 @@
 /**
  * Poll for changes.
  *
- * TODO: fixing selection in certain cases, handling selection across multiple 
nodes in Firefox
- *
  * @method
  * @private
  * @param {boolean} signalChanges If there changes are observed, call 
Surface#handleObservedChange
  * @param {boolean} selectionOnly Check for selection changes only
- * @fires contentChange
- * @fires rangeChange
  */
 ve.ce.SurfaceObserver.prototype.pollOnceInternal = function ( signalChanges, 
selectionOnly ) {
-       var oldState, newState,
-               contentChange = null,
-               branchNodeChange = null,
-               rangeChange = null;
+       var oldState, newState;
 
        if ( !this.domDocument || this.disabled ) {
                return;
@@ -173,41 +162,16 @@
                this.documentView.getDocumentNode(),
                selectionOnly
        );
-
        this.rangeState = newState;
 
-       if ( !selectionOnly && newState.node !== null && 
newState.contentChanged && signalChanges ) {
-               contentChange = {
-                       node: newState.node,
-                       previous: { text: oldState.text, hash: oldState.hash, 
range: oldState.veRange },
-                       next: { text: newState.text, hash: newState.hash, 
range: newState.veRange }
-               };
-       }
-
-       // TODO: Is it correct that branchNode changes are signalled even if 
!signalChanges ?
-       if ( newState.branchNodeChanged ) {
-               branchNodeChange = {
-                       oldBranchNode: (
-                               oldState && oldState.node && oldState.node.root 
?
-                               oldState.node :
-                               null
-                       ),
-                       newBranchNode: newState.node
-               };
-       }
-
-       if ( newState.selectionChanged && signalChanges ) {
-               // Caution: selectionChanged is true if the CE selection is 
different, which can
-               // be the case even if the DM selection is unchanged. So the 
following line can
-               // signal a range change with identical oldRange and newRange.
-               rangeChange = {
-                       oldRange: ( oldState ? oldState.veRange : null ),
-                       newRange: newState.veRange
-               };
-       }
-
-       if ( contentChange || branchNodeChange || rangeChange ) {
-               this.surface.handleObservedChanges( contentChange, 
branchNodeChange, rangeChange );
+       if ( signalChanges && (
+               newState.contentChanged ||
+               // TODO: The prior code signalled branchNode changes even if 
!signalChanges .
+               // Was this needed?
+               newState.branchNodeChanged ||
+               newState.selectionChanged
+       ) ) {
+               this.surface.handleObservedChanges( oldState, newState );
        }
 };
 
diff --git a/src/ce/ve.ce.js b/src/ce/ve.ce.js
index 5ed0e59..b253d66 100644
--- a/src/ce/ve.ce.js
+++ b/src/ce/ve.ce.js
@@ -457,3 +457,158 @@
        }
        return $( node ).closest( '.ve-ce-linkAnnotation' )[ 0 ];
 };
+
+/**
+ * Analyse a DOM content change to build a Transaction
+ *
+ * Content changes have oldState.node === newState.node and 
newState.contentChanged === true .
+ * Annotations are inferred heuristically from plaintext to do what the user 
intended.
+ * TODO: treat more changes as simple (not needing a re-render); see
+ * https://phabricator.wikimedia.org/T114260 .
+ *
+ * @method
+ * @param {ve.ce.RangeState} oldState The prior range state
+ * @param {ve.ce.RangeState} newState The changed range state
+ *
+ * @return {Object} Results of analysis
+ * @return {ve.dm.Transaction} return.transaction Transaction corresponding to 
the DOM content change
+ * @return {ve.dm.Selection} return.selection Changed selection to apply 
(TODO: unsafe / useless?)
+ * @return {boolean} return.rerender Whether the DOM needs rerendering after 
applying the transaction
+ */
+ve.ce.modelChangeFromContentChange = function ( oldState, newState ) {
+       var data, len, annotations, bothCollapsed, oldStart, newStart, 
replacementRange,
+               fromLeft = 0,
+               fromRight = 0,
+               // It is guaranteed that oldState.node === newState.node , so 
just call it 'node'
+               node = newState.node,
+               nodeOffset = node.getModel().getOffset(),
+               oldText = oldState.text,
+               newText = newState.text,
+               oldRange = oldState.veRange,
+               newRange = newState.veRange,
+               oldData = oldText.split( '' ),
+               newData = newText.split( '' ),
+               lengthDiff = newText.length - oldText.length,
+               dmDoc = node.getModel().getDocument(),
+               modelData = dmDoc.data;
+
+       bothCollapsed = oldRange.isCollapsed() && newRange.isCollapsed();
+       oldStart = oldRange.start - nodeOffset - 1;
+       newStart = newRange.start - nodeOffset - 1;
+
+       // If the only change is an insertion just before the new cursor, then 
apply a
+       // single insertion transaction, using the annotations from the old 
start
+       // position (accounting for whether the cursor was before or after an 
annotation
+       // boundary)
+       if (
+               bothCollapsed &&
+               lengthDiff > 0 &&
+               oldText.slice( 0, oldStart ) === newText.slice( 0, oldStart ) &&
+               oldText.slice( oldStart ) === newText.slice( newStart )
+       ) {
+               data = newData.slice( oldStart, newStart );
+               if ( node.unicornAnnotations ) {
+                       annotations = node.unicornAnnotations;
+               } else {
+                       annotations = 
modelData.getInsertionAnnotationsFromRange(
+                               oldRange,
+                               oldState.focusIsAfterAnnotationBoundary
+                       );
+               }
+
+               if ( annotations.getLength() ) {
+                       ve.dm.Document.static.addAnnotationsToData( data, 
annotations );
+               }
+
+               return {
+                       transaction: ve.dm.Transaction.newFromInsertion(
+                               dmDoc,
+                               oldRange.start,
+                               data
+                       ),
+                       selection: new ve.dm.LinearSelection( dmDoc, newRange ),
+                       rerender: false
+               };
+       }
+
+       // If the only change is a removal touching the old cursor position, 
then apply
+       // a single removal transaction.
+       if (
+               bothCollapsed &&
+               lengthDiff < 0 &&
+               oldText.slice( 0, newStart ) === newText.slice( 0, newStart ) &&
+               oldText.slice( newStart - lengthDiff ) === newText.slice( 
newStart )
+       ) {
+               return {
+                       transaction: ve.dm.Transaction.newFromRemoval(
+                               dmDoc,
+                               new ve.Range( newRange.start, newRange.start - 
lengthDiff )
+                       ),
+                       selection: new ve.dm.LinearSelection( dmDoc, newRange ),
+                       rerender: false
+               };
+       }
+
+       // Complex change (either removal+insertion or insertion not just 
before new cursor)
+       // 1. Count unchanged characters from left and right;
+       // 2. Assume that the minimal changed region indicates the replacement 
made by the user;
+       // 3. Hence guess how to map annotations.
+       // N.B. this logic can go wrong; e.g. this code will see slice->slide 
and
+       // assume that the user changed 'c' to 'd', but the user could instead 
have changed 'ic'
+       // to 'id', which would map annotations differently.
+
+       len = Math.min( oldData.length, newData.length );
+
+       while ( fromLeft < len && oldData[ fromLeft ] === newData[ fromLeft ] ) 
{
+               ++fromLeft;
+       }
+
+       while (
+               fromRight < len - fromLeft &&
+               oldData[ oldData.length - 1 - fromRight ] ===
+               newData[ newData.length - 1 - fromRight ]
+       ) {
+               ++fromRight;
+       }
+       replacementRange = new ve.Range(
+               nodeOffset + 1 + fromLeft,
+               nodeOffset + 1 + oldData.length - fromRight
+       );
+       data = newData.slice( fromLeft, newData.length - fromRight );
+
+       if ( node.unicornAnnotations ) {
+               // This CBN is unicorned. Use the stored annotations.
+               annotations = node.unicornAnnotations;
+       } else {
+               // Guess the annotations from the (possibly empty) range being 
replaced.
+               //
+               // Still consider focusIsAfterAnnotationBoundary, even though 
the change is
+               // not necessarily at the cursor: assume the old focus was 
inside the same
+               // DOM text node as the insertion, and therefore has the same 
annotations.
+               // Otherwise, when using an IME that selects inserted text, 
this code path
+               // can cause an annotation discrepancy that triggers an 
unwanted re-render,
+               // closing the IME (For example, when typing at the start of 
<p><i>x</i></p>
+               // in Windows 8.1 Korean on IE11).
+               annotations = modelData.getInsertionAnnotationsFromRange(
+                       replacementRange,
+                       oldRange.isCollapsed() && 
oldState.focusIsAfterAnnotationBoundary
+               );
+       }
+       if ( annotations.getLength() ) {
+               ve.dm.Document.static.addAnnotationsToData( data, annotations );
+       }
+       if ( newRange.isCollapsed() ) {
+               // TODO: Remove this, or comment why it's necessary
+               // (When wouldn't we be at a cursor offset?)
+               newRange = new ve.Range( dmDoc.getNearestCursorOffset( 
newRange.start, 1 ) );
+       }
+       return {
+               transaction: ve.dm.Transaction.newFromReplacement(
+                       dmDoc,
+                       replacementRange,
+                       data
+               ),
+               selection: new ve.dm.LinearSelection( dmDoc, newRange ),
+               rerender: true
+       };
+};
diff --git a/tests/ce/ve.ce.Surface.test.js b/tests/ce/ve.ce.Surface.test.js
index 57ab75c..9147bd4 100644
--- a/tests/ce/ve.ce.Surface.test.js
+++ b/tests/ce/ve.ce.Surface.test.js
@@ -494,7 +494,7 @@
        }
 } );
 
-QUnit.test( 'onObservedContentChange', function ( assert ) {
+QUnit.test( 'handleObservedChanges (content changes)', function ( assert ) {
        var i,
                cases = [
                        {
@@ -556,12 +556,53 @@
                                        ]
                                ],
                                msg: 'Replace into non-zero annotation next to 
word break'
+                       },
+                       {
+                               prevHtml: '<p><b>X</b></p>',
+                               prevRange: new ve.Range( 2 ),
+                               nextHtml: '<p><b>XY</b></p>',
+                               nextRange: new ve.Range( 3 ),
+                               expectedOps: [
+                                       [
+                                               { type: 'retain', length: 2 },
+                                               {
+                                                       type: 'replace',
+                                                       insert: [ [ 'Y', [ 0 ] 
] ],
+                                                       remove: [],
+                                                       insertedDataOffset: 0,
+                                                       insertedDataLength: 1
+                                               },
+                                               { type: 'retain', length: 3 }
+                                       ]
+                               ],
+                               msg: 'Append into bold'
+                       },
+                       {
+                               prevHtml: '<p><b>X</b></p>',
+                               prevRange: new ve.Range( 2 ),
+                               prevFocusIsAfterAnnotationBoundary: true,
+                               nextHtml: '<p><b>X</b>Y</p>',
+                               nextRange: new ve.Range( 3 ),
+                               expectedOps: [
+                                       [
+                                               { type: 'retain', length: 2 },
+                                               {
+                                                       type: 'replace',
+                                                       insert: [ 'Y' ],
+                                                       remove: [],
+                                                       insertedDataOffset: 0,
+                                                       insertedDataLength: 1
+                                               },
+                                               { type: 'retain', length: 3 }
+                                       ]
+                               ],
+                               msg: 'Append after bold'
                        }
                ];
 
        QUnit.expect( cases.length * 2 );
 
-       function testRunner( prevHtml, prevRange, nextHtml, nextRange, 
expectedOps, expectedRange, msg ) {
+       function testRunner( prevHtml, prevRange, 
prevFocusIsAfterAnnotationBoundary, nextHtml, nextRange, expectedOps, 
expectedRange, msg ) {
                var txs, i, ops,
                        view = ve.test.utils.createSurfaceViewFromHtml( 
prevHtml ),
                        model = view.getModel(),
@@ -569,17 +610,23 @@
                        prevNode = $( prevHtml )[ 0 ],
                        nextNode = $( nextHtml )[ 0 ],
                        prev = {
+                               node: node,
                                text: ve.ce.getDomText( prevNode ),
                                hash: ve.ce.getDomHash( prevNode ),
-                               range: prevRange
+                               veRange: prevRange,
+                               focusIsAfterAnnotationBoundary: 
prevFocusIsAfterAnnotationBoundary || false
                        },
                        next = {
+                               node: node,
                                text: ve.ce.getDomText( nextNode ),
                                hash: ve.ce.getDomHash( nextNode ),
-                               range: nextRange
+                               veRange: nextRange,
+                               contentChanged: true
                        };
 
-               view.onObservedContentChange( node, prev, next );
+               // Set model linear selection, so that insertion annotations 
are primed correctly
+               model.setLinearSelection( prevRange );
+               view.handleObservedChanges( prev, next );
                txs = model.getHistory()[ 0 ].transactions;
                ops = [];
                for ( i = 0; i < txs.length; i++ ) {
@@ -593,7 +640,8 @@
 
        for ( i = 0; i < cases.length; i++ ) {
                testRunner(
-                       cases[ i ].prevHtml, cases[ i ].prevRange, cases[ i 
].nextHtml, cases[ i ].nextRange,
+                       cases[ i ].prevHtml, cases[ i ].prevRange, cases[ i 
].prevFocusIsAfterAnnotationBoundary || false,
+                       cases[ i ].nextHtml, cases[ i ].nextRange,
                        cases[ i ].expectedOps, cases[ i ].expectedRange || 
cases[ i ].nextRange, cases[ i ].msg
                );
        }

-- 
To view, visit https://gerrit.wikimedia.org/r/241144
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I420c14f56187bb881c4b636a746d4383d9ba3146
Gerrit-PatchSet: 15
Gerrit-Project: VisualEditor/VisualEditor
Gerrit-Branch: master
Gerrit-Owner: Divec <[email protected]>
Gerrit-Reviewer: DLynch <[email protected]>
Gerrit-Reviewer: Divec <[email protected]>
Gerrit-Reviewer: Esanders <[email protected]>
Gerrit-Reviewer: Jforrester <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to