%PDF- %PDF-
| Direktori : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/src/ce/ |
| Current File : //www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/src/ce/ve.ce.Surface.js |
/*!
* VisualEditor ContentEditable Surface class.
*
* @copyright 2011-2016 VisualEditor Team and others; see http://ve.mit-license.org
*/
/**
* ContentEditable surface.
*
* @class
* @extends OO.ui.Element
* @mixins OO.EventEmitter
*
* @constructor
* @param {ve.dm.Surface} model Surface model to observe
* @param {ve.ui.Surface} ui Surface user interface
* @param {Object} [config] Configuration options
*/
ve.ce.Surface = function VeCeSurface( model, ui, config ) {
var surface = this,
profile = $.client.profile();
// Parent constructor
ve.ce.Surface.super.call( this, config );
// Mixin constructors
OO.EventEmitter.call( this );
// Properties
this.surface = ui;
this.model = model;
this.documentView = new ve.ce.Document( model.getDocument(), this );
this.selection = null;
this.surfaceObserver = new ve.ce.SurfaceObserver( this );
this.$window = $( this.getElementWindow() );
this.$document = $( this.getElementDocument() );
this.$documentNode = this.getDocument().getDocumentNode().$element;
// Window.getSelection returns a live singleton representing the document's selection
this.nativeSelection = this.getElementWindow().getSelection();
this.eventSequencer = new ve.EventSequencer( [
'keydown', 'keypress', 'keyup',
'compositionstart', 'compositionend',
'input', 'mousedown'
] );
this.clipboard = null;
this.clipboardId = String( Math.random() );
this.clipboardIndex = 0;
this.renderLocks = 0;
this.dragging = false;
this.relocatingNode = false;
this.allowedFile = null;
this.resizing = false;
this.focused = false;
this.deactivated = false;
this.$deactivatedSelection = $( '<div>' );
this.activeNode = null;
this.contentBranchNodeChanged = false;
this.selectionLink = null;
this.$highlightsFocused = $( '<div>' );
this.$highlightsBlurred = $( '<div>' );
this.$highlights = $( '<div>' ).append(
this.$highlightsFocused, this.$highlightsBlurred
);
this.$findResults = $( '<div>' );
this.$dropMarker = $( '<div>' ).addClass( 've-ce-surface-dropMarker oo-ui-element-hidden' );
this.$lastDropTarget = null;
this.lastDropPosition = null;
this.$pasteTarget = $( '<div>' );
this.pasting = false;
this.fakePasting = false;
// Support: IE<=10
// Use paste triggered by CTRL+V in IE<=10, otherwise the event is too late to move the selection
this.useFakePaste = profile.name === 'msie' && profile.versionNumber < 11;
this.copying = false;
this.pasteSpecial = false;
this.pointerEvents = null;
this.focusedBlockSlug = null;
this.focusedNode = null;
// This is set on entering changeModel, then unset when leaving.
// It is used to test whether a reflected change event is emitted.
this.newModelSelection = null;
// Snapshot updated at keyDown. See storeKeyDownState.
this.keyDownState = {
event: null,
selection: null
};
this.cursorDirectionality = null;
this.unicorningNode = null;
this.setUnicorningRecursionGuard = false;
this.cursorHolders = null;
this.hasSelectionChangeEvents = 'onselectionchange' in this.getElementDocument();
// Events
this.model.connect( this, {
select: 'onModelSelect',
documentUpdate: 'onModelDocumentUpdate',
insertionAnnotationsChange: 'onInsertionAnnotationsChange'
} );
this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
this.$documentNode.on( {
// mouse events shouldn't be sequenced as the event sequencer
// is detached on blur
mousedown: this.onDocumentMouseDown.bind( this ),
// mouseup is bound to the whole document on mousedown
cut: this.onCut.bind( this ),
copy: this.onCopy.bind( this )
} );
this.onWindowResizeHandler = this.onWindowResize.bind( this );
this.$window.on( 'resize', this.onWindowResizeHandler );
this.onDocumentFocusInOutHandler = this.onDocumentFocusInOut.bind( this );
this.$document.on( 'focusin focusout', this.onDocumentFocusInOutHandler );
// It is possible for a mousedown to clear the selection
// without triggering a focus change event (e.g. if the
// document has been programmatically blurred) so trigger
// a focus change to check if we still have a selection
this.debounceFocusChange = ve.debounce( this.onFocusChange ).bind( this );
this.$document.on( 'mousedown', this.debounceFocusChange );
this.$pasteTarget.add( this.$highlights ).on( {
cut: this.onCut.bind( this ),
copy: this.onCopy.bind( this ),
paste: this.onPaste.bind( this )
} );
this.$documentNode
.on( 'paste', this.onPaste.bind( this ) )
.on( 'focus', 'a', function () {
// Opera <= 12 triggers 'blur' on document node before any link is
// focused and we don't want that
surface.$documentNode[ 0 ].focus();
} );
// Support: IE<=11
// IE<=11 will fire two selection change events when moving the selection from
// the paste target to the document. We are only interested in the last one (T133104).
this.onDocumentSelectionChangeDebounced = ve.debounce( this.onDocumentSelectionChange.bind( this ) );
if ( this.hasSelectionChangeEvents ) {
this.$document.on( 'selectionchange', this.onDocumentSelectionChangeDebounced );
} else {
// Fake selection change events with mousemove if dragging
this.$documentNode.on( 'mousemove', function () {
if ( surface.dragging ) {
surface.onDocumentSelectionChangeDebounced();
}
} );
// mousedown needs to run after native mousedown action has changed the selection
this.eventSequencer.after( {
mousedown: this.onDocumentSelectionChangeDebounced
} );
}
this.$element.on( {
dragstart: this.onDocumentDragStart.bind( this ),
dragover: this.onDocumentDragOver.bind( this ),
dragleave: this.onDocumentDragLeave.bind( this ),
drop: this.onDocumentDrop.bind( this )
} );
// Add listeners to the eventSequencer. They won't get called until
// eventSequencer.attach(node) has been called.
this.eventSequencer.on( {
keydown: this.onDocumentKeyDown.bind( this ),
keyup: this.onDocumentKeyUp.bind( this ),
keypress: this.onDocumentKeyPress.bind( this ),
input: this.onDocumentInput.bind( this ),
compositionstart: this.onDocumentCompositionStart.bind( this )
} ).after( {
keydown: this.afterDocumentKeyDown.bind( this )
} );
// Initialization
// Support: Chrome
// Add 'notranslate' class to prevent Chrome's translate feature from
// completely messing up the CE DOM (T59124)
this.$element.addClass( 've-ce-surface notranslate' );
this.$highlights.addClass( 've-ce-surface-highlights' );
this.$highlightsFocused.addClass( 've-ce-surface-highlights-focused' );
this.$highlightsBlurred.addClass( 've-ce-surface-highlights-blurred' );
this.$deactivatedSelection.addClass( 've-ce-surface-deactivatedSelection' );
this.$pasteTarget
.addClass( 've-ce-surface-paste' )
.prop( {
tabIndex: -1,
contentEditable: 'true'
} );
// Add elements to the DOM
this.$highlights.append( this.$dropMarker );
this.$element.append( this.$documentNode, this.$pasteTarget );
this.surface.$blockers.append( this.$highlights );
this.surface.$selections.append( this.$deactivatedSelection );
};
/* Inheritance */
OO.inheritClass( ve.ce.Surface, OO.ui.Element );
OO.mixinClass( ve.ce.Surface, OO.EventEmitter );
/* Events */
/**
* @event relocationStart
*/
/**
* @event relocationEnd
*/
/**
* @event keyup
*/
/**
* When the surface changes its position (only if it happens
* after initialize has already been called).
*
* @event position
*/
/**
* @event focus
* Note that it's possible for a focus event to occur immediately after a blur event, if the focus
* moves to or from a FocusableNode. In this case the surface doesn't lose focus conceptually, but
* a pair of blur-focus events is emitted anyway.
*/
/**
* @event blur
* Note that it's possible for a focus event to occur immediately after a blur event, if the focus
* moves to or from a FocusableNode. In this case the surface doesn't lose focus conceptually, but
* a pair of blur-focus events is emitted anyway.
*/
/* Static properties */
/**
* Attributes considered 'unsafe' for copy/paste
*
* These attributes may be dropped by the browser during copy/paste, so
* any element containing these attributes will have them JSON encoded into
* data-ve-attributes on copy.
*
* @type {string[]}
*/
ve.ce.Surface.static.unsafeAttributes = [
// Support: Firefox
// RDFa: Firefox ignores these
'about',
'content',
'datatype',
'property',
'rel',
'resource',
'rev',
'typeof',
// CSS: Values are often added or modified
'style'
];
/**
* Cursor holder template
*
* @static
* @property {HTMLElement}
*/
ve.ce.Surface.static.cursorHolderTemplate = (
$( '<div>' )
.addClass( 've-ce-cursorHolder' )
.prop( 'contentEditable', 'true' )
.append(
// The image does not need a src for Firefox in spite of cursoring
// bug https://bugzilla.mozilla.org/show_bug.cgi?id=989012 , because
// you can cursor to ce=false blocks in Firefox (see bug
// https://bugzilla.mozilla.org/show_bug.cgi?id=1155031 )
$( '<img>' ).addClass( 've-ce-cursorHolder-img' )
)
.get( 0 )
);
/* Static methods */
/**
* When pasting, browsers normalize HTML to varying degrees.
* This hash creates a comparable string for validating clipboard contents.
*
* @param {jQuery} $elements Clipboard HTML
* @param {Object} [beforePasteData] Paste information, including leftText and rightText to strip
* @return {string} Hash
*/
ve.ce.Surface.static.getClipboardHash = function ( $elements, beforePasteData ) {
beforePasteData = beforePasteData || {};
return $elements.text().slice(
beforePasteData.leftText ? beforePasteData.leftText.length : 0,
beforePasteData.rightText ? -beforePasteData.rightText.length : undefined
)
// Whitespace may be modified (e.g. ' ' to ' '), so strip it all
.replace( /\s/gm, '' );
};
/* Methods */
/**
* Destroy the surface, removing all DOM elements.
*
* @method
*/
ve.ce.Surface.prototype.destroy = function () {
var documentNode = this.documentView.getDocumentNode();
// Detach observer and event sequencer
this.surfaceObserver.stopTimerLoop();
this.surfaceObserver.detach();
this.eventSequencer.detach();
// Make document node not live
documentNode.setLive( false );
// Disconnect events
this.model.disconnect( this );
// Disconnect DOM events on the document
this.$document.off( 'focusin focusout', this.onDocumentFocusInOutHandler );
this.$document.off( 'mousedown', this.debounceFocusChange );
if ( this.hasSelectionChangeEvents ) {
this.$document.off( 'selectionchange', this.onDocumentSelectionChangeDebounced );
}
// Disconnect DOM events on the window
this.$window.off( 'resize', this.onWindowResizeHandler );
// Support: Firefox, iOS
// FIXME T126041: Blur to make selection/cursor disappear (needed in Firefox
// in some cases, and in iOS to hide the keyboard)
if ( this.isFocused() ) {
this.blur();
}
// Remove DOM elements (also disconnects their events)
this.$element.remove();
this.$highlights.remove();
};
/**
* Get linear model offest from a mouse event
*
* @param {Event} e Event
* @return {number} Linear model offset, or -1 if coordinates are out of bounds
*/
ve.ce.Surface.prototype.getOffsetFromEventCoords = function ( e ) {
return this.getOffsetFromCoords(
e.pageX - this.$document.scrollLeft(),
e.pageY - this.$document.scrollTop()
);
};
/**
* Get linear model offset from absolute coords
*
* @param {number} x X offset
* @param {number} y Y offset
* @return {number} Linear model offset, or -1 if coordinates are out of bounds
*/
ve.ce.Surface.prototype.getOffsetFromCoords = function ( x, y ) {
var offset, caretPosition, range, textRange, $marker,
doc = this.getElementDocument();
try {
if ( doc.caretPositionFromPoint ) {
// Gecko
// http://dev.w3.org/csswg/cssom-view/#extensions-to-the-document-interface
caretPosition = document.caretPositionFromPoint( x, y );
offset = ve.ce.getOffset( caretPosition.offsetNode, caretPosition.offset );
} else if ( doc.caretRangeFromPoint ) {
// Webkit
// http://www.w3.org/TR/2009/WD-cssom-view-20090804/
range = document.caretRangeFromPoint( x, y );
offset = ve.ce.getOffset( range.startContainer, range.startOffset );
} else if ( document.body.createTextRange ) {
// Trident
// http://msdn.microsoft.com/en-gb/library/ie/ms536632(v=vs.85).aspx
textRange = document.body.createTextRange();
textRange.moveToPoint( x, y );
textRange.pasteHTML( '<span class="ve-ce-textRange-drop-marker"> </span>' );
$marker = $( '.ve-ce-textRange-drop-marker' );
offset = ve.ce.getOffset( $marker.get( 0 ), 0 );
$marker.remove();
}
return offset;
} catch ( e ) {
// Both ve.ce.getOffset and TextRange.moveToPoint can throw out of bounds exceptions
return -1;
}
};
/**
* Get selection view object
*
* @param {ve.dm.Selection} selection Optional selection model, defaults to current selection
* @return {ve.ce.Selection} Selection view
*/
ve.ce.Surface.prototype.getSelection = function ( selection ) {
if ( selection ) {
// Specific selection requested, bypass cache
return ve.ce.Selection.static.newFromModel( selection, this );
} else if ( !this.selection ) {
this.selection = ve.ce.Selection.static.newFromModel( this.getModel().getSelection(), this );
}
return this.selection;
};
/*! Initialization */
/**
* Initialize surface.
*
* This should be called after the surface has been attached to the DOM.
*
* @method
*/
ve.ce.Surface.prototype.initialize = function () {
this.documentView.getDocumentNode().setLive( true );
if ( $.client.profile().layout === 'gecko' ) {
// Turn off native object editing. This must be tried after the surface has been added to DOM.
// This is only needed in Gecko. In other engines, these properties are off by default,
// and turning them off again is expensive; see https://phabricator.wikimedia.org/T89928
try {
this.$document[ 0 ].execCommand( 'enableObjectResizing', false, false );
this.$document[ 0 ].execCommand( 'enableInlineTableEditing', false, false );
} catch ( e ) { /* Silently ignore */ }
}
};
/**
* Enable editing.
*
* @method
*/
ve.ce.Surface.prototype.enable = function () {
this.documentView.getDocumentNode().enable();
};
/**
* Disable editing.
*
* @method
*/
ve.ce.Surface.prototype.disable = function () {
this.documentView.getDocumentNode().disable();
};
/**
* Give focus to the surface, reapplying the model selection, or selecting the first content offset
* if the model selection is null.
*
* This is used when switching between surfaces, e.g. when closing a dialog window. Calling this
* function will also reapply the selection, even if the surface is already focused.
*/
ve.ce.Surface.prototype.focus = function () {
var node,
surface = this,
selection = this.getSelection();
if ( selection.getModel().isNull() ) {
this.getModel().selectFirstContentOffset();
selection = this.getSelection();
}
// Focus the documentNode for text selections, or the pasteTarget for focusedNode selections
if ( selection.isFocusedNode() ) {
this.$pasteTarget[ 0 ].focus();
} else if ( selection.isNativeCursor() ) {
node = this.getDocument().getNodeAndOffset( selection.getModel().getRange().start ).node;
$( node ).closest( '[contenteditable=true]' )[ 0 ].focus();
}
// If we are calling focus after replacing a node the selection may be gone
// but onDocumentFocus won't fire so restore the selection here too.
this.onModelSelect();
// setTimeout: postpone until onDocumentFocus has been called
setTimeout( function () {
// Support: Chrome
// In some browsers (e.g. Chrome) giving the document node focus doesn't
// necessarily give you a selection (e.g. if the first child is a <figure>)
// so if the surface isn't 'focused' (has no selection) give it a selection
// manually
// TODO: rename isFocused and other methods to something which reflects
// the fact they actually mean "has a native selection"
if ( !surface.isFocused() ) {
surface.getModel().selectFirstContentOffset();
}
} );
// onDocumentFocus takes care of the rest
};
/**
* Blur the surface
*/
ve.ce.Surface.prototype.blur = function () {
if ( this.deactivated ) {
// Clear the model selection, so activate doesn't trigger another de-activate
this.getModel().setNullSelection();
this.activate();
}
this.nativeSelection.removeAllRanges();
this.getElementDocument().activeElement.blur();
// This won't trigger focusin/focusout events, so trigger focus change manually
this.onFocusChange();
};
/**
* Handler for focusin and focusout events. Filters events and debounces to #onFocusChange.
*
* @param {jQuery.Event} e focusin/out event
*/
ve.ce.Surface.prototype.onDocumentFocusInOut = function ( e ) {
// Support: IE11
// Filter out focusin/out events on iframes
// IE11 emits these when the focus moves into/out of an iframed document,
// but these events are misleading because the focus in this document didn't
// actually move.
if ( e.target.nodeName.toLowerCase() === 'iframe' ) {
return;
}
this.debounceFocusChange();
};
/**
* Handle global focus change.
*/
ve.ce.Surface.prototype.onFocusChange = function () {
var hasFocus = false;
hasFocus = OO.ui.contains(
[
this.$documentNode[ 0 ],
this.$pasteTarget[ 0 ],
this.$highlights[ 0 ]
],
this.nativeSelection.anchorNode,
true
);
if ( this.deactivated ) {
if ( OO.ui.contains( this.$documentNode[ 0 ], this.nativeSelection.anchorNode, true ) ) {
this.onDocumentFocus();
}
} else {
if ( hasFocus && !this.isFocused() ) {
this.onDocumentFocus();
}
if ( !hasFocus && this.isFocused() ) {
this.onDocumentBlur();
}
}
};
/**
* Deactivate the surface, stopping the surface observer and replacing the native
* range with a fake rendered one.
*
* Used by dialogs so they can take focus without losing the original document selection.
*/
ve.ce.Surface.prototype.deactivate = function () {
if ( !this.deactivated ) {
// Disable the surface observer, there can be no observable changes
// until the surface is activated
this.surfaceObserver.disable();
this.deactivated = true;
// Remove ranges so the user can't accidentally type into the document
this.nativeSelection.removeAllRanges();
this.updateDeactivatedSelection();
}
};
/**
* Reactivate the surface and restore the native selection
*/
ve.ce.Surface.prototype.activate = function () {
if ( this.deactivated ) {
this.deactivated = false;
this.updateDeactivatedSelection();
this.surfaceObserver.enable();
if ( OO.ui.contains( this.$documentNode[ 0 ], this.nativeSelection.anchorNode, true ) ) {
// The selection has been placed back in the document, either by the user clicking
// or by the closing window updating the model. Poll in case it was the user clicking.
this.surfaceObserver.clear();
this.surfaceObserver.pollOnce();
} else {
// Clear focused node so onModelSelect re-selects it if necessary
this.focusedNode = null;
this.onModelSelect();
}
}
};
/**
* Update the fake selection while the surface is deactivated.
*
* While the surface is deactivated, all calls to showModelSelection will get redirected here.
*/
ve.ce.Surface.prototype.updateDeactivatedSelection = function () {
var i, l, rects,
selection = this.getSelection();
this.$deactivatedSelection.empty();
// Check we have a deactivated surface and a native selection
if ( this.deactivated && selection.isNativeCursor() ) {
rects = selection.getSelectionRects();
if ( rects ) {
for ( i = 0, l = rects.length; i < l; i++ ) {
this.$deactivatedSelection.append(
$( '<div>' ).css( {
top: rects[ i ].top,
left: rects[ i ].left,
// Collapsed selections can have a width of 0, so expand
width: Math.max( rects[ i ].width, 1 ),
height: rects[ i ].height
}
) ).toggleClass( 've-ce-surface-deactivatedSelection-collapsed', selection.getModel().isCollapsed() );
}
}
}
};
/**
* Handle document focus events.
*
* This is triggered by a global focusin/focusout event noticing a selection on the document.
*
* @method
* @fires focus
*/
ve.ce.Surface.prototype.onDocumentFocus = function () {
if ( this.getModel().getSelection().isNull() ) {
// If the document is being focused by a non-mouse/non-touch user event,
// find the first content offset and place the cursor there.
this.getModel().selectFirstContentOffset();
}
this.eventSequencer.attach( this.$element );
this.surfaceObserver.startTimerLoop();
this.focused = true;
this.activate();
this.$element.addClass( 've-ce-surface-focused' );
this.emit( 'focus' );
};
/**
* Handle document blur events.
*
* This is triggered by a global focusin/focusout event noticing no selection on the document.
*
* @method
* @fires blur
*/
ve.ce.Surface.prototype.onDocumentBlur = function () {
this.eventSequencer.detach();
this.surfaceObserver.stopTimerLoop();
this.surfaceObserver.pollOnce();
this.surfaceObserver.clear();
this.dragging = false;
this.focused = false;
if ( this.focusedNode ) {
this.focusedNode.setFocused( false );
this.focusedNode = null;
}
this.getModel().setNullSelection();
this.$element.removeClass( 've-ce-surface-focused' );
this.emit( 'blur' );
};
/**
* Check if surface is focused.
*
* @return {boolean} Surface is focused
*/
ve.ce.Surface.prototype.isFocused = function () {
return this.focused;
};
/**
* Handle document mouse down events.
*
* @method
* @param {jQuery.Event} e Mouse down event
*/
ve.ce.Surface.prototype.onDocumentMouseDown = function ( e ) {
var newFragment;
if ( e.which !== OO.ui.MouseButtons.LEFT ) {
return;
}
// Remember the mouse is down
this.dragging = true;
// Bind mouseup to the whole document in case of dragging out of the surface
this.$document.on( 'mouseup', this.onDocumentMouseUpHandler );
this.surfaceObserver.stopTimerLoop();
// setTimeout: In some browsers the selection doesn't change until after the event
// so poll in the 'after' function.
// TODO: rewrite to use EventSequencer
setTimeout( this.afterDocumentMouseDown.bind( this, e, this.getSelection() ) );
// Support: IE
// Handle triple click
// FIXME T126043: do not do triple click handling in IE, because their click counting is broken
if ( e.originalEvent.detail >= 3 && !ve.init.platform.constructor.static.isInternetExplorer() ) {
// Browser default behaviour for triple click won't behave as we want
e.preventDefault();
newFragment = this.getModel().getFragment()
// After double-clicking in an inline slug, we'll get a selection like
// <p><span><img />|</span></p><p>|Foo</p>. This selection spans a CBN boundary,
// so we can't expand to the nearest CBN. To handle this case and other possible
// cases where the selection spans a CBN boundary, collapse the selection before
// expanding it. If the selection is entirely within the same CBN as it should be,
// this won't change the result.
.collapseToStart()
// Cover the CBN we're in
.expandLinearSelection( 'closest', ve.dm.ContentBranchNode )
// ...but that covered the entire CBN, we only want the contents
.adjustLinearSelection( 1, -1 );
// If something weird happened (e.g. no CBN found), newFragment will be null.
// Don't select it in that case, because that'll blur the surface.
if ( !newFragment.isNull() ) {
newFragment.select();
}
}
};
/**
* Deferred until after document mouse down
*
* @param {jQuery.Event} e Mouse down event
* @param {ve.ce.Selection} selectionBefore Selection before the mouse event
*/
ve.ce.Surface.prototype.afterDocumentMouseDown = function ( e, selectionBefore ) {
// TODO: guard with incRenderLock?
this.surfaceObserver.pollOnce();
if ( e.shiftKey ) {
this.fixShiftClickSelect( selectionBefore );
}
};
/**
* Handle document mouse up events.
*
* @method
* @param {jQuery.Event} e Mouse up event
*/
ve.ce.Surface.prototype.onDocumentMouseUp = function ( e ) {
this.$document.off( 'mouseup', this.onDocumentMouseUpHandler );
this.surfaceObserver.startTimerLoop();
// setTimeout: In some browsers the selection doesn't change until after the event
// so poll in the 'after' function
// TODO: rewrite to use EventSequencer
setTimeout( this.afterDocumentMouseUp.bind( this, e, this.getSelection() ) );
};
/**
* Deferred until after document mouse up
*
* @param {jQuery.Event} e Mouse up event
* @param {ve.ce.Selection} selectionBefore Selection before the mouse event
*/
ve.ce.Surface.prototype.afterDocumentMouseUp = function ( e, selectionBefore ) {
// TODO: guard with incRenderLock?
this.surfaceObserver.pollOnce();
if ( e.shiftKey ) {
this.fixShiftClickSelect( selectionBefore );
}
this.dragging = false;
};
/**
* Fix shift-click selection
*
* Support: Chrome
* When shift-clicking on links Chrome tries to collapse the selection
* so check for this and fix manually.
*
* This can occur on mousedown or, if the existing selection covers the
* link, on mouseup.
*
* https://code.google.com/p/chromium/issues/detail?id=345745
*
* @param {ve.ce.Selection} selectionBefore Selection before the mouse event
*/
ve.ce.Surface.prototype.fixShiftClickSelect = function ( selectionBefore ) {
var newSelection;
if ( !selectionBefore.isNativeCursor() ) {
return;
}
newSelection = this.getSelection();
if ( newSelection.getModel().isCollapsed() && !newSelection.equals( selectionBefore ) ) {
this.getModel().setLinearSelection(
new ve.Range(
selectionBefore.getModel().getRange().from,
newSelection.getModel().getRange().to
)
);
}
};
/**
* Handle document selection change events.
*
* @method
* @param {jQuery.Event} e Selection change event
*/
ve.ce.Surface.prototype.onDocumentSelectionChange = function () {
this.fixupCursorPosition( 0, this.dragging );
this.updateActiveLink();
this.surfaceObserver.pollOnceSelection();
};
/**
* Handle document drag start events.
*
* @method
* @param {jQuery.Event} e Drag start event
*/
ve.ce.Surface.prototype.onDocumentDragStart = function ( e ) {
var dataTransfer = e.originalEvent.dataTransfer;
try {
dataTransfer.setData( 'application-x/VisualEditor', JSON.stringify( this.getModel().getSelection() ) );
} catch ( err ) {
// Support: IE
// IE doesn't support custom data types, but overwriting the actual drag data should be avoided
// TODO: Do this with an internal state to avoid overwriting drag data even in IE
dataTransfer.setData( 'text', '__ve__' + JSON.stringify( this.getModel().getSelection() ) );
}
};
/**
* Handle document drag over events.
*
* @method
* @param {jQuery.Event} e Drag over event
*/
ve.ce.Surface.prototype.onDocumentDragOver = function ( e ) {
var i, l, $target, $dropTarget, node, dropPosition, targetPosition, targetOffset, top, left,
nodeType, inIgnoreChildren, item, fakeItem,
dataTransferHandlerFactory = this.getSurface().dataTransferHandlerFactory,
isContent = true,
dataTransfer = e.originalEvent.dataTransfer;
if ( this.relocatingNode ) {
isContent = this.relocatingNode.isContent();
nodeType = this.relocatingNode.getType();
} else {
if ( this.allowedFile === null ) {
this.allowedFile = false;
// If we can get file metadata, check if there is a DataTransferHandler registered
// to handle it.
if ( dataTransfer.items ) {
for ( i = 0, l = dataTransfer.items.length; i < l; i++ ) {
item = dataTransfer.items[ i ];
if ( item.kind !== 'string' ) {
fakeItem = new ve.ui.DataTransferItem( item.kind, item.type );
if ( dataTransferHandlerFactory.getHandlerNameForItem( fakeItem ) ) {
this.allowedFile = true;
break;
}
}
}
} else if ( dataTransfer.files ) {
for ( i = 0, l = dataTransfer.files.length; i < l; i++ ) {
item = dataTransfer.items[ i ];
fakeItem = new ve.ui.DataTransferItem( item.kind, item.type );
if ( dataTransferHandlerFactory.getHandlerNameForItem( fakeItem ) ) {
this.allowedFile = true;
break;
}
}
// Support: Firefox
// If we have no metadata (e.g. in Firefox) assume it is droppable
} else if ( Array.prototype.indexOf.call( dataTransfer.types || [], 'Files' ) !== -1 ) {
this.allowedFile = true;
}
}
// this.allowedFile is cached until the next dragleave event
if ( this.allowedFile ) {
isContent = false;
nodeType = 'alienBlock';
}
}
function getNearestDropTarget( node ) {
while ( node.parent && !node.parent.isAllowedChildNodeType( nodeType ) ) {
node = node.parent;
}
if ( node.parent ) {
inIgnoreChildren = false;
node.parent.traverseUpstream( function ( n ) {
if ( n.shouldIgnoreChildren() ) {
node = null;
return false;
}
} );
return node;
}
}
if ( !isContent ) {
e.preventDefault();
$target = $( e.target ).closest( '.ve-ce-branchNode, .ve-ce-leafNode' );
if ( $target.length ) {
// Find the nearest node which will accept this node type
node = getNearestDropTarget( $target.data( 'view' ) );
if ( node ) {
$dropTarget = node.$element;
dropPosition = e.originalEvent.pageY - $dropTarget.offset().top > $dropTarget.outerHeight() / 2 ? 'bottom' : 'top';
} else {
targetOffset = this.getOffsetFromEventCoords( e.originalEvent );
if ( targetOffset !== -1 ) {
node = getNearestDropTarget( this.getDocument().getBranchNodeFromOffset( targetOffset ) );
if ( node ) {
$dropTarget = node.$element;
dropPosition = 'top';
}
}
if ( !$dropTarget ) {
$dropTarget = this.$lastDropTarget;
dropPosition = this.lastDropPosition;
}
}
}
if ( this.$lastDropTarget && (
!this.$lastDropTarget.is( $dropTarget ) || dropPosition !== this.lastDropPosition
) ) {
this.$dropMarker.addClass( 'oo-ui-element-hidden' );
$dropTarget = null;
}
if ( $dropTarget && (
!$dropTarget.is( this.$lastDropTarget ) || dropPosition !== this.lastDropPosition
) ) {
targetPosition = $dropTarget.position();
// Go beyond margins as they can overlap
top = targetPosition.top + parseFloat( $dropTarget.css( 'margin-top' ) );
left = targetPosition.left + parseFloat( $dropTarget.css( 'margin-left' ) );
if ( dropPosition === 'bottom' ) {
top += $dropTarget.outerHeight();
}
this.$dropMarker
.css( {
top: top,
left: left
} )
.width( $dropTarget.outerWidth() )
.removeClass( 'oo-ui-element-hidden' );
}
if ( $dropTarget !== undefined ) {
this.$lastDropTarget = $dropTarget;
this.lastDropPosition = dropPosition;
}
}
};
/**
* Handle document drag leave events.
*
* @method
* @param {jQuery.Event} e Drag leave event
*/
ve.ce.Surface.prototype.onDocumentDragLeave = function () {
this.allowedFile = null;
if ( this.$lastDropTarget ) {
this.$dropMarker.addClass( 'oo-ui-element-hidden' );
this.$lastDropTarget = null;
this.lastDropPosition = null;
}
};
/**
* Handle document drop events.
*
* Limits native drag and drop behaviour.
*
* @method
* @param {jQuery.Event} e Drop event
*/
ve.ce.Surface.prototype.onDocumentDrop = function ( e ) {
// Properties may be nullified by other events, so cache before setTimeout
var selectionJSON, dragSelection, dragRange, originFragment, originData,
targetRange, targetOffset, targetFragment,
surfaceModel = this.getModel(),
dataTransfer = e.originalEvent.dataTransfer,
$dropTarget = this.$lastDropTarget,
dropPosition = this.lastDropPosition;
// Prevent native drop event from modifying view
e.preventDefault();
// Determine drop position
if ( $dropTarget ) {
// Block level drag and drop: use the lastDropTarget to get the targetOffset
if ( $dropTarget ) {
targetRange = $dropTarget.data( 'view' ).getModel().getOuterRange();
if ( dropPosition === 'top' ) {
targetOffset = targetRange.start;
} else {
targetOffset = targetRange.end;
}
} else {
return;
}
} else {
targetOffset = this.getOffsetFromEventCoords( e.originalEvent );
if ( targetOffset === -1 ) {
return;
}
}
targetFragment = surfaceModel.getLinearFragment( new ve.Range( targetOffset ) );
// Get source range from drag data
try {
selectionJSON = dataTransfer.getData( 'application-x/VisualEditor' );
} catch ( err ) {
selectionJSON = dataTransfer.getData( 'text' );
// Support: IE9 (selectionJSON not set; T75240)
if ( selectionJSON && selectionJSON.slice( 0, 6 ) === '__ve__' ) {
selectionJSON = selectionJSON.slice( 6 );
} else {
selectionJSON = null;
}
}
if ( this.relocatingNode ) {
dragRange = this.relocatingNode.getModel().getOuterRange();
} else if ( selectionJSON ) {
dragSelection = ve.dm.Selection.static.newFromJSON( surfaceModel.getDocument(), selectionJSON );
if ( dragSelection instanceof ve.dm.LinearSelection ) {
dragRange = dragSelection.getRange();
}
}
// Internal drop
if ( dragRange ) {
// Get a fragment and data of the node being dragged
originFragment = surfaceModel.getLinearFragment( dragRange );
originData = originFragment.getData();
// Start staging so we can abort in the catch later
surfaceModel.pushStaging();
// Remove node from old location
originFragment.removeContent();
try {
// Re-insert data at new location
targetFragment.insertContent( originData );
surfaceModel.applyStaging();
} catch ( error ) {
// Insert content may throw an exception if it can't find a way
// to fixup the insertion sensibly
surfaceModel.popStaging();
}
} else {
// External drop
this.handleDataTransfer( dataTransfer, false, targetFragment );
}
this.endRelocation();
};
/**
* Handle document key down events.
*
* @method
* @param {jQuery.Event} e Key down event
*/
ve.ce.Surface.prototype.onDocumentKeyDown = function ( e ) {
var trigger, executed,
selection = this.getModel().getSelection(),
updateFromModel = false;
if ( selection instanceof ve.dm.NullSelection ) {
return;
}
if ( e.which === 229 ) {
// Support: IE, Chrome
// Ignore fake IME events (emitted in IE and Chrome)
return;
}
this.surfaceObserver.stopTimerLoop();
this.incRenderLock();
try {
// TODO: is this correct?
this.surfaceObserver.pollOnce();
} finally {
this.decRenderLock();
}
this.storeKeyDownState( e );
if ( ve.ce.keyDownHandlerFactory.executeHandlersForKey( e.keyCode, selection.getName(), this, e ) ) {
updateFromModel = true;
} else {
trigger = new ve.ui.Trigger( e );
if ( trigger.isComplete() ) {
// Support: IE<=10
if ( this.useFakePaste && trigger.toString() === 'ctrl+v' ) {
this.onPaste( e, true );
}
executed = this.surface.execute( trigger );
if ( executed || this.isBlockedTrigger( trigger ) ) {
e.preventDefault();
e.stopPropagation();
updateFromModel = true;
}
}
}
if ( !updateFromModel ) {
this.incRenderLock();
}
try {
this.surfaceObserver.pollOnce();
} finally {
if ( !updateFromModel ) {
this.decRenderLock();
}
}
this.surfaceObserver.startTimerLoop();
};
/**
* Check if a trigger event is blocked from performing its default behaviour
*
* If any of these triggers can't execute on the surface, (e.g. the underline
* command has been blacklisted), we should still preventDefault so ContentEditable
* native commands don't occur, leaving the view out of sync with the model.
*
* @method
* @param {ve.ui.Trigger} trigger Trigger to check
* @return {boolean} Trigger should preventDefault
*/
ve.ce.Surface.prototype.isBlockedTrigger = function ( trigger ) {
var platformKey = ve.getSystemPlatform() === 'mac' ? 'mac' : 'pc',
blocked = {
mac: [ 'meta+b', 'meta+i', 'meta+u', 'meta+z', 'meta+y', 'meta+shift+z', 'tab', 'shift+tab', 'meta+[', 'meta+]' ],
pc: [ 'ctrl+b', 'ctrl+i', 'ctrl+u', 'ctrl+z', 'ctrl+y', 'ctrl+shift+z', 'tab', 'shift+tab' ]
};
return blocked[ platformKey ].indexOf( trigger.toString() ) !== -1;
};
/**
* Handle document key press events.
*
* @method
* @param {jQuery.Event} e Key press event
*/
ve.ce.Surface.prototype.onDocumentKeyPress = function ( e ) {
var selection;
// Handle the case where keyPress Enter is fired without a matching keyDown. This can
// happen with OS X Romanising Korean IMEs on Firefox, when pressing Enter with
// uncommitted candidate text; see T120156. Behave as though keyDown Enter has been
// fired.
if (
e.keyCode === OO.ui.Keys.ENTER &&
!this.keyDownState.event &&
// We're only aware of cases of this happening with uncommitted candidate text,
// which implies a native selection. But we instead perform a weaker test - for
// a non-null selection - to match that same test in onDocumentKeyDown
!( ( selection = this.getModel().getSelection() ) instanceof ve.dm.NullSelection )
) {
this.surfaceObserver.stopTimerLoop();
if ( ve.ce.keyDownHandlerFactory.executeHandlersForKey( e.keyCode, selection.getName(), this, e ) ) {
this.surfaceObserver.pollOnce();
}
this.surfaceObserver.startTimerLoop();
return;
}
// Filter out non-character keys. Doing this prevents:
// * Unexpected content deletion when selection is not collapsed and the user presses, for
// example, the Home key (Firefox fires 'keypress' for it)
// TODO: Should be covered with Selenium tests.
if (
// Catches most keys that don't produce output (charCode === 0, thus no character)
e.which === 0 || e.charCode === 0 ||
// Opera 12 doesn't always adhere to that convention
e.keyCode === OO.ui.Keys.TAB || e.keyCode === OO.ui.Keys.ESCAPE ||
// Ignore all keypresses with Ctrl / Cmd modifier keys
ve.ce.isShortcutKey( e )
) {
return;
}
this.handleInsertion();
};
/**
* Deferred until after document key down event
*
* @param {jQuery.Event} e keydown event
*/
ve.ce.Surface.prototype.afterDocumentKeyDown = function ( e ) {
var keyDownSelectionState, direction, focusableNode, startOffset, endOffset,
offsetDiff, dmFocus, dmSelection, inNonSlug, ceSelection, ceNode, range,
fixupCursorForUnicorn, matrix, col, row, $focusNode, removedUnicorns,
surface = this,
isArrow = (
e.keyCode === OO.ui.Keys.UP ||
e.keyCode === OO.ui.Keys.DOWN ||
e.keyCode === OO.ui.Keys.LEFT ||
e.keyCode === OO.ui.Keys.RIGHT
);
/**
* Determine whether a position is editable, and if so which focusable node it is in
*
* We can land inside ce=false in many browsers:
* - Firefox has normal cursor positions at most node boundaries inside ce=false
* - Chromium has superfluous cursor positions around a ce=false img
* - IE hardly restricts editing at all inside ce=false
* If ce=false then we have landed inside the focusable node.
* If we land in a non-text position, assume we should have hit the node
* immediately after the position we hit (in the direction of motion)
* If we land inside a sequence of grouped nodes, assume we should treat them as a
* unit instead of letting the cursor slip inside them.
* @private
* @param {Node} DOM node of cursor position
* @param {number} offset Offset of cursor position
* @param {number} direction Cursor motion direction (1=forward, -1=backward)
* @return {ve.ce.Node|null} node, or null if not in a focusable node
*/
function getSurroundingFocusableNode( node, offset, direction ) {
var focusNode;
if ( node.nodeType === Node.TEXT_NODE ) {
focusNode = node;
} else if ( direction > 0 && offset < node.childNodes.length ) {
focusNode = node.childNodes[ offset ];
} else if ( direction < 0 && offset > 0 ) {
focusNode = node.childNodes[ offset - 1 ];
} else {
focusNode = node;
}
if ( ve.isContentEditable( focusNode ) ) {
// We are allowed to be inside this focusable node (e.g. editing a
// table cell or caption).
return null;
}
return $( focusNode ).closest( '.ve-ce-focusableNode, .ve-ce-tableNode' ).data( 'view' ) || null;
}
/**
* Compute the direction of cursor movement, if any
*
* Even if the user pressed a cursor key in the interior of the document, there may not
* be any movement: browser BIDI and ce=false handling can be quite quirky.
*
* Furthermore, the keydown selection nodes may have become detached since keydown (e.g.
* if ve.ce.ContentBranchNode#renderContents has run).
*
* @return {number|null} negative for startwards, positive for endwards, null for none
*/
function getDirection() {
return (
isArrow &&
ve.compareDocumentOrder(
surface.nativeSelection.focusNode,
surface.nativeSelection.focusOffset,
keyDownSelectionState.focusNode,
keyDownSelectionState.focusOffset
)
) || null;
}
if ( e !== this.keyDownState.event ) {
return;
}
keyDownSelectionState = this.keyDownState.selectionState;
this.clearKeyDownState();
if (
( e.keyCode === OO.ui.Keys.BACKSPACE || e.keyCode === OO.ui.Keys.DELETE ) &&
this.nativeSelection.focusNode
) {
inNonSlug = this.nativeSelection.focusNode.nodeType === Node.ELEMENT_NODE &&
!this.nativeSelection.focusNode.classList.contains( 've-ce-branchNode-inlineSlug' );
if ( inNonSlug ) {
// In a non-slug element. Sync the DM, then see if we need a slug.
this.incRenderLock();
try {
this.surfaceObserver.pollOnce();
} finally {
this.decRenderLock();
}
dmSelection = surface.model.getSelection();
if ( dmSelection instanceof ve.dm.LinearSelection ) {
dmFocus = dmSelection.getRange().end;
ceNode = this.documentView.getBranchNodeFromOffset( dmFocus );
if ( ceNode && ceNode.getModel().hasSlugAtOffset( dmFocus ) ) {
ceNode.setupBlockSlugs();
}
}
}
// Remove then re-set the selection, to clear any browser-native preannotations.
// This should be IME-safe because delete/backspace key events should only happen
// when there is no IME candidate window open.
//
// Note that if an IME removes text otherwise than by delete/backspace, then
// browser-native preannotations might still get applied. This can happen: see
// https://phabricator.wikimedia.org/T116275 .
// That's nasty, but it's not a reason to leave the delete/backspace case broken.
ceSelection = new ve.SelectionState( this.nativeSelection );
this.nativeSelection.removeAllRanges();
this.showSelectionState( ceSelection );
if ( inNonSlug ) {
return;
}
}
// Only fixup cursoring on linear selections.
if ( isArrow && !( surface.model.getSelection() instanceof ve.dm.LinearSelection ) ) {
return;
}
// Restore the selection and stop, if we cursored out of a table edit cell.
// Assumption: if we cursored out of a table cell, then none of the fixups below this point
// would have got the selection back inside the cell. Therefore it's OK to check here.
if ( isArrow && this.restoreActiveNodeSelection() ) {
return;
}
// If we landed in a cursor holder, select the corresponding focusable node instead
// (which, for a table, will select the first cell). Else if we arrowed a collapsed
// cursor across a focusable node, select the node instead.
$focusNode = $( this.nativeSelection.focusNode );
if ( $focusNode.hasClass( 've-ce-cursorHolder' ) ) {
if ( $focusNode.hasClass( 've-ce-cursorHolder-after' ) ) {
direction = -1;
focusableNode = $focusNode.prev().data( 'view' );
} else {
direction = 1;
focusableNode = $focusNode.next().data( 'view' );
}
this.removeCursorHolders();
} else if (
// If we arrowed a collapsed cursor into/across a focusable node, select the node instead
isArrow &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
keyDownSelectionState.isCollapsed &&
this.nativeSelection.isCollapsed &&
( direction = getDirection() ) !== null
) {
focusableNode = getSurroundingFocusableNode(
this.nativeSelection.focusNode,
this.nativeSelection.focusOffset,
direction
);
if ( !focusableNode ) {
// Calculate the DM offsets of our motion
try {
startOffset = ve.ce.getOffset(
keyDownSelectionState.focusNode,
keyDownSelectionState.focusOffset
);
endOffset = ve.ce.getOffset(
this.nativeSelection.focusNode,
this.nativeSelection.focusOffset
);
offsetDiff = endOffset - startOffset;
} catch ( ex ) {
startOffset = endOffset = offsetDiff = undefined;
}
if ( Math.abs( offsetDiff ) === 2 ) {
// Test whether we crossed a focusable node
// (this applies even if we cursored up/down)
focusableNode = (
this.model.documentModel.documentNode
.getNodeFromOffset( ( startOffset + endOffset ) / 2 )
);
if ( focusableNode.isFocusable() ) {
range = new ve.Range( startOffset, endOffset );
} else {
focusableNode = undefined;
}
}
}
}
if ( focusableNode ) {
if ( !range ) {
range = focusableNode.getOuterRange();
if ( direction < 0 ) {
range = range.flip();
}
}
if ( focusableNode instanceof ve.ce.TableNode ) {
if ( direction > 0 ) {
this.model.setSelection( new ve.dm.TableSelection(
this.model.documentModel, range, 0, 0
) );
} else {
matrix = focusableNode.getModel().getMatrix();
row = matrix.getRowCount() - 1;
col = matrix.getColCount( row ) - 1;
this.model.setSelection( new ve.dm.TableSelection(
this.model.documentModel, range, col, row
) );
}
} else {
this.model.setLinearSelection( range );
}
if ( e.keyCode === OO.ui.Keys.LEFT ) {
this.cursorDirectionality = direction > 0 ? 'rtl' : 'ltr';
} else if ( e.keyCode === OO.ui.Keys.RIGHT ) {
this.cursorDirectionality = direction < 0 ? 'rtl' : 'ltr';
}
// else up/down pressed; leave this.cursorDirectionality as null
// (it was set by setLinearSelection calling onModelSelect)
}
if ( direction === undefined ) {
direction = getDirection();
}
fixupCursorForUnicorn = (
!e.shiftKey &&
( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.RIGHT )
);
removedUnicorns = this.cleanupUnicorns( fixupCursorForUnicorn );
if ( removedUnicorns ) {
this.surfaceObserver.pollOnceNoCallback();
} else {
this.incRenderLock();
try {
this.surfaceObserver.pollOnce();
} finally {
this.decRenderLock();
}
}
this.fixupCursorPosition( direction, e.shiftKey );
};
/**
* Check whether the DOM selection has moved out of the unicorned area (i.e. is not currently
* between two unicorns) and if so, set the model selection from the DOM selection, destroy the
* unicorns and return true. If there are no active unicorns, this function does nothing and
* returns false.
*
* If the unicorns are destroyed as a consequence of the user moving the cursor across a unicorn
* with the left/rightarrow keys, the cursor will have to be moved again to produce the cursor
* movement the user expected. Set the fixupCursor parameter to true to enable this behavior.
*
* @param {boolean} fixupCursor If destroying unicorns, fix up left/rightarrow cursor position
* @return {boolean} Whether unicorns have been destroyed
*/
ve.ce.Surface.prototype.cleanupUnicorns = function ( fixupCursor ) {
var preUnicorn, postUnicorn, range, node, fixup, veRange;
if ( !this.unicorningNode || !this.unicorningNode.unicorns ) {
return false;
}
preUnicorn = this.unicorningNode.unicorns[ 0 ];
postUnicorn = this.unicorningNode.unicorns[ 1 ];
if ( !this.$documentNode[ 0 ].contains( preUnicorn ) ) {
return false;
}
if ( this.nativeSelection.rangeCount === 0 ) {
// XXX do we want to clear unicorns in this case?
return false;
}
range = this.nativeSelection.getRangeAt( 0 );
// Test whether the selection endpoint is between unicorns. If so, do nothing.
// Unicorns can only contain text, so just move backwards until we hit a non-text node.
node = range.endContainer;
if ( node.nodeType === Node.ELEMENT_NODE ) {
node = range.endOffset > 0 ? node.childNodes[ range.endOffset - 1 ] : null;
}
while ( node !== null && node.nodeType === Node.TEXT_NODE ) {
node = node.previousSibling;
}
if ( node === preUnicorn ) {
return false;
}
// Selection endpoint is not between unicorns.
// Test whether it is before or after the pre-unicorn (i.e. before/after both unicorns)
if ( ve.compareDocumentOrder(
range.endContainer,
range.endOffset,
preUnicorn.parentNode,
ve.parentIndex( preUnicorn )
) < 0 ) {
// before the pre-unicorn
fixup = -1;
} else {
// at or after the pre-unicorn (actually must be after the post-unicorn)
fixup = 1;
}
// Apply the DOM selection to the model
this.incRenderLock();
try {
veRange = ve.ce.veRangeFromSelection( this.nativeSelection );
if ( veRange ) {
// The most likely reason for this condition to not-pass is if we
// try to cleanup unicorns while the native selection is outside
// the model momentarily, as sometimes happens during paste.
this.changeModel( null, new ve.dm.LinearSelection(
this.model.getDocument(),
veRange
) );
}
if ( fixupCursor ) {
this.moveModelCursor( fixup );
}
} finally {
this.decRenderLock();
}
this.renderSelectedContentBranchNode();
this.showModelSelection();
return true;
};
/**
* Handle document key up events.
*
* @method
* @param {jQuery.Event} e Key up event
* @fires keyup
*/
ve.ce.Surface.prototype.onDocumentKeyUp = function () {
this.emit( 'keyup' );
};
/**
* Handle cut events.
*
* @method
* @param {jQuery.Event} e Cut event
*/
ve.ce.Surface.prototype.onCut = function ( e ) {
var surface = this,
selection = this.getModel().getSelection();
if ( selection.isCollapsed() ) {
return;
}
this.onCopy( e );
// setTimeout: postpone until after the setTimeout in onCopy
setTimeout( function () {
// Trigger a fake backspace to remove the content: this behaves differently based on the selection,
// e.g. in a TableSelection.
ve.ce.keyDownHandlerFactory.executeHandlersForKey( OO.ui.Keys.BACKSPACE, selection.getName(), surface, e );
} );
};
/**
* Handle copy events.
*
* @method
* @param {jQuery.Event} e Copy event
*/
ve.ce.Surface.prototype.onCopy = function ( e ) {
var originalSelection, clipboardKey, supportsCustomMimeType,
scrollTop, unsafeSelector, slice,
profile = $.client.profile(),
selection = this.getModel().getSelection(),
view = this,
htmlDoc = this.getModel().getDocument().getHtmlDocument(),
clipboardData = e.originalEvent.clipboardData;
this.$pasteTarget.empty();
if ( selection.isCollapsed() ) {
return;
}
slice = this.model.documentModel.shallowCloneFromSelection( selection );
// Clone the elements in the slice
slice.data.cloneElements( true );
ve.dm.converter.getDomSubtreeFromModel( slice, this.$pasteTarget[ 0 ], true );
// Some browsers strip out spans when they match the styling of the
// paste target (e.g. plain spans) so we must protect against this
// by adding a dummy class, which we can remove after paste.
this.$pasteTarget.find( 'span' ).addClass( 've-pasteProtect' );
// href absolutization either doesn't occur (because we copy HTML to the clipboard
// directly with clipboardData#setData) or it resolves against the wrong document
// (window.document instead of ve.dm.Document#getHtmlDocument) so do it manually
// with ve#resolveUrl
this.$pasteTarget.find( 'a' ).attr( 'href', function ( i, href ) {
return ve.resolveUrl( href, htmlDoc );
} );
// Support: Firefox
// Some attributes (e.g RDFa attributes in Firefox) aren't preserved by copy
unsafeSelector = '[' + ve.ce.Surface.static.unsafeAttributes.join( '],[' ) + ']';
this.$pasteTarget.find( unsafeSelector ).each( function () {
var i, val,
attrs = {},
ua = ve.ce.Surface.static.unsafeAttributes;
i = ua.length;
while ( i-- ) {
val = this.getAttribute( ua[ i ] );
if ( val !== null ) {
attrs[ ua[ i ] ] = val;
}
}
this.setAttribute( 'data-ve-attributes', JSON.stringify( attrs ) );
} );
this.clipboardIndex++;
clipboardKey = this.clipboardId + '-' + this.clipboardIndex;
this.clipboard = { slice: slice, hash: null };
// Support: IE, Firefox<48
// Writing the key to text/xcustom won't work in IE & Firefox<48, so write
// it to the HTML instead
supportsCustomMimeType = !!clipboardData && (
// Chrome
clipboardData.items ||
// Firefox >= 48 (but not Firefox Android, which has name='android' and doesn't support this feature)
( profile.name === 'firefox' && profile.versionNumber >= 48 )
);
if ( !supportsCustomMimeType ) {
this.$pasteTarget.prepend(
$( '<span>' ).attr( 'data-ve-clipboard-key', clipboardKey ).html( ' ' )
);
// To ensure the contents with the clipboardKey isn't modified in an external editor,
// store a hash of the contents for later validation.
this.clipboard.hash = this.constructor.static.getClipboardHash( this.$pasteTarget.contents() );
}
// If we have access to the clipboard write straight to it so we don't
// have to fiddle around with the selection and fix scroll offsets.
// Support: Edge
// Despite having the clipboard API, Edge only supports Text and URL types.
if ( clipboardData && !ve.init.platform.constructor.static.isEdge() ) {
// Disable the default event so we can override the data
e.preventDefault();
// Only write a custom mime type if we think the browser supports it, otherwise
// we will have already written a key to the HTML above.
if ( supportsCustomMimeType ) {
clipboardData.setData( 'text/xcustom', clipboardKey );
}
clipboardData.setData( 'text/html', this.$pasteTarget.html() );
// innerText "approximates the text the user would get if they highlighted the
// contents of the element with the cursor and then copied to the clipboard." - MDN
// Use $.text as a fallback for Firefox <= 44
clipboardData.setData( 'text/plain', this.$pasteTarget[ 0 ].innerText || this.$pasteTarget.text() );
} else {
// Support: IE
// If direct clipboard editing is not allowed, we must use the pasteTarget to
// select the data we want to go in the clipboard
if ( this.getSelection().isNativeCursor() ) {
// We have a selection in the document; preserve it so it can restored
originalSelection = new ve.SelectionState( this.nativeSelection );
// Save scroll position before changing focus to "offscreen" paste target
scrollTop = this.$window.scrollTop();
// Prevent surface observation due to native range changing
this.surfaceObserver.disable();
ve.selectElement( this.$pasteTarget[ 0 ] );
// Restore scroll position after changing focus
this.$window.scrollTop( scrollTop );
// setTimeout: postpone until after the default copy action
setTimeout( function () {
// If the range was in $highlights (right-click copy), don't restore it
if ( !OO.ui.contains( view.$highlights[ 0 ], originalSelection.focusNode, true ) ) {
// Change focus back
view.$documentNode[ 0 ].focus();
view.showSelectionState( originalSelection );
// Restore scroll position
view.$window.scrollTop( scrollTop );
}
view.surfaceObserver.clear();
view.surfaceObserver.enable();
} );
} else {
// If the selection is non-native, the pasteTarget *should* already be selected...
ve.selectElement( this.$pasteTarget[ 0 ] );
}
}
};
/**
* Handle native paste event
*
* @param {jQuery.Event} e Paste event
* @param {boolean} fakePaste Paste event is fake (i.e. a CTRL+V keydown event)
*/
ve.ce.Surface.prototype.onPaste = function ( e, fakePaste ) {
var surface = this;
// Prevent pasting until after we are done
if ( this.pasting ) {
// Only prevent default if the first event wasn't fake
return this.fakePasting;
} else if ( this.useFakePaste && !fakePaste ) {
// The client requires fake paste, but the user triggered the
// paste without pressing CTRL+V (e.g. right click -> paste).
// We can't handle this case yet so just abort to avoid CE corruption.
return false;
}
this.beforePaste( e );
this.surfaceObserver.disable();
this.pasting = true;
this.fakePasting = !!fakePaste;
// setTimeout: postpone until after the default paste action
setTimeout( function () {
try {
if ( !e.isDefaultPrevented() ) {
surface.afterPaste( e );
}
} finally {
surface.surfaceObserver.clear();
surface.surfaceObserver.enable();
// Allow pasting again
surface.pasting = false;
surface.fakePasting = false;
surface.pasteSpecial = false;
surface.beforePasteData = null;
}
} );
};
/**
* Handle pre-paste events.
*
* @param {jQuery.Event} e Paste event
*/
ve.ce.Surface.prototype.beforePaste = function ( e ) {
var range, startNode, endNode, contextElement, nativeRange,
context, leftText, rightText, textNode, textStart, textEnd,
selection = this.getModel().getSelection(),
clipboardData = e.originalEvent.clipboardData,
surfaceModel = this.getModel(),
fragment = surfaceModel.getFragment(),
documentModel = surfaceModel.getDocument();
if ( selection instanceof ve.dm.LinearSelection ) {
range = fragment.getSelection().getRange();
} else if ( selection instanceof ve.dm.TableSelection ) {
range = new ve.Range( selection.getRanges()[ 0 ].start );
} else {
e.preventDefault();
return;
}
this.beforePasteData = {};
if ( clipboardData ) {
if ( this.handleDataTransfer( clipboardData, true ) ) {
e.preventDefault();
return;
}
this.beforePasteData.custom = clipboardData.getData( 'text/xcustom' );
this.beforePasteData.html = clipboardData.getData( 'text/html' );
if ( this.beforePasteData.html ) {
// http://msdn.microsoft.com/en-US/en-%20us/library/ms649015(VS.85).aspx
this.beforePasteData.html = this.beforePasteData.html
.replace( /^[\s\S]*<!-- *StartFragment *-->/, '' )
.replace( /<!-- *EndFragment *-->[\s\S]*$/, '' );
}
}
// Save scroll position before changing focus to "offscreen" paste target
this.beforePasteData.scrollTop = this.$window.scrollTop();
this.$pasteTarget.empty();
// Get node from cursor position
startNode = documentModel.getBranchNodeFromOffset( range.start );
if ( startNode.canContainContent() ) {
// If this is a content branch node, then add its DM HTML
// to the paste target to give CE some context.
textStart = textEnd = 0;
contextElement = startNode.getClonedElement();
// Make sure that context doesn't have any attributes that might confuse
// the importantElement check in afterPaste.
$( documentModel.getStore().value( contextElement.originalDomElementsIndex ) ).removeAttr( 'id typeof rel' );
context = [ contextElement ];
// If there is content to the left of the cursor, put a placeholder
// character to the left of the cursor
if ( range.start > startNode.getRange().start ) {
leftText = '☀';
context.push( leftText );
textStart = textEnd = 1;
}
// If there is content to the right of the cursor, put a placeholder
// character to the right of the cursor
endNode = documentModel.getBranchNodeFromOffset( range.end );
if ( range.end < endNode.getRange().end ) {
rightText = '☂';
context.push( rightText );
}
// If there is no text context, select some text to be replaced
if ( !leftText && !rightText ) {
context.push( '☁' );
textEnd = 1;
}
context.push( { type: '/' + context[ 0 ].type } );
// Throw away 'internal', specifically inner whitespace,
// before conversion as it can affect textStart/End offsets.
delete contextElement.internal;
ve.dm.converter.getDomSubtreeFromModel(
documentModel.cloneWithData( context, true ),
this.$pasteTarget[ 0 ]
);
// Giving the paste target focus too late can cause problems in FF (!?)
// so do it up here.
this.$pasteTarget[ 0 ].focus();
nativeRange = this.getElementDocument().createRange();
// Assume that the DM node only generated one child
textNode = this.$pasteTarget.children().contents()[ 0 ];
// Place the cursor between the placeholder characters
nativeRange.setStart( textNode, textStart );
nativeRange.setEnd( textNode, textEnd );
this.nativeSelection.removeAllRanges();
this.nativeSelection.addRange( nativeRange );
this.beforePasteData.context = context;
this.beforePasteData.leftText = leftText;
this.beforePasteData.rightText = rightText;
} else {
// If we're not in a content branch node, don't bother trying to do
// anything clever with paste context
this.$pasteTarget[ 0 ].focus();
}
// Restore scroll position after focusing the paste target
this.$window.scrollTop( this.beforePasteData.scrollTop );
};
/**
* Handle post-paste events.
*
* @param {jQuery.Event} e Paste event
*/
ve.ce.Surface.prototype.afterPaste = function ( e ) {
// jshint unused:false
var clipboardKey, clipboardId, clipboardIndex, clipboardHash,
$elements, parts, pasteData, slice, internalListRange,
data, pastedDocumentModel, htmlDoc, $body, $images, i,
context, left, right, contextRange, pastedText, handled,
tableAction,
items = [],
importantElement = '[id],[typeof],[rel]',
importRules = !this.pasteSpecial ? this.getSurface().getImportRules() : { all: { plainText: true, keepEmptyContentBranches: true } },
beforePasteData = this.beforePasteData || {},
surfaceModel = this.getModel(),
fragment = surfaceModel.getFragment(),
targetFragment = surfaceModel.getFragment( null, true ),
documentModel = surfaceModel.getDocument(),
view = this;
function sanitize( linearData ) {
// If the clipboardKey isn't set (paste from non-VE instance) use external import rules
if ( !clipboardKey ) {
linearData.sanitize( importRules.external || {} );
}
linearData.sanitize( importRules.all || {} );
}
// If the selection doesn't collapse after paste then nothing was inserted
if ( !this.nativeSelection.isCollapsed ) {
return;
}
if ( fragment.isNull() ) {
return null;
}
// Find the clipboard key
if ( beforePasteData.custom ) {
clipboardKey = beforePasteData.custom;
} else {
if ( beforePasteData.html ) {
$elements = $( $.parseHTML( beforePasteData.html ) );
// Try to find the clipboard key hidden in the HTML
$elements = $elements.filter( function () {
var val = this.getAttribute && this.getAttribute( 'data-ve-clipboard-key' );
if ( val ) {
clipboardKey = val;
// Remove the clipboard key span once read
return false;
}
return true;
} );
clipboardHash = this.constructor.static.getClipboardHash( $elements );
} else {
// HTML in pasteTarget my get wrapped, so use the recursive $.find to look for the clipboard key
clipboardKey = this.$pasteTarget.find( 'span[data-ve-clipboard-key]' ).data( 've-clipboard-key' );
// Pass beforePasteData so context gets stripped
clipboardHash = this.constructor.static.getClipboardHash( this.$pasteTarget, beforePasteData );
}
}
// Remove the clipboard key
this.$pasteTarget.find( 'span[data-ve-clipboard-key]' ).remove();
// If we have a clipboard key, validate it and fetch data
if ( clipboardKey === this.clipboardId + '-' + this.clipboardIndex ) {
// Hash validation: either text/xcustom was used or the hash must be
// equal to the hash of the pasted HTML to assert that the HTML
// hasn't been modified in another editor before being pasted back.
if ( beforePasteData.custom || clipboardHash === this.clipboard.hash ) {
slice = this.clipboard.slice;
}
}
// All $pasteTarget sanitization can be skipped for internal paste
if ( !slice ) {
// Remove style attributes. Any valid styles will be restored by data-ve-attributes.
this.$pasteTarget.find( '[style]' ).removeAttr( 'style' );
// FIXME T126044: Remove Parsoid IDs
this.$pasteTarget.find( '[id]' ).each( function () {
var $this = $( this );
if ( $this.attr( 'id' ).match( /^mw[\w-]{2,}$/ ) ) {
$this.removeAttr( 'id' );
}
} );
// Remove the pasteProtect class (see #onCopy) and unwrap empty spans.
this.$pasteTarget.find( 'span' ).each( function () {
var $this = $( this );
$this.removeClass( 've-pasteProtect' );
if ( $this.attr( 'class' ) === '' ) {
$this.removeAttr( 'class' );
}
// Unwrap empty spans
if ( !this.attributes.length ) {
$this.replaceWith( this.childNodes );
}
} );
// Restore attributes. See #onCopy.
this.$pasteTarget.find( '[data-ve-attributes]' ).each( function () {
var attrs;
try {
attrs = JSON.parse( this.getAttribute( 'data-ve-attributes' ) );
} catch ( e ) {
// Invalid JSON
return;
}
$( this ).attr( attrs );
this.removeAttribute( 'data-ve-attributes' );
} );
} else {
// Clone again. The elements were cloned on copy, but we need to clone
// on paste too in case the same thing is pasted multiple times.
slice.data.cloneElements( true );
}
if ( fragment.getSelection() instanceof ve.dm.TableSelection ) {
// Internal table-into-table paste
if ( fragment.getSelection() instanceof ve.dm.TableSelection && slice instanceof ve.dm.TableSlice ) {
tableAction = new ve.ui.TableAction( this.getSurface() );
tableAction.importTable( slice.getTableNode() );
return;
}
// For table selections the target is the first cell
targetFragment = surfaceModel.getLinearFragment( fragment.getSelection().getRanges()[ 0 ], true );
}
if ( slice ) {
// Pasting non-table content into table: just replace the the first cell with the pasted content
if ( fragment.getSelection() instanceof ve.dm.TableSelection ) {
// Cell was not deleted in beforePaste to prevent flicker when table-into-table paste is
// about to be triggered.
targetFragment.removeContent();
}
// Internal paste
try {
// Try to paste in the original data
// Take a copy to prevent the data being annotated a second time in the catch block
// and to prevent actions in the data model affecting view.clipboard
pasteData = new ve.dm.ElementLinearData(
slice.getStore(),
ve.copy( slice.getOriginalData() )
);
if ( this.pasteSpecial ) {
sanitize( pasteData );
}
// Insert content
targetFragment.insertContent( pasteData.getData(), true );
} catch ( err ) {
// If that fails, use the balanced data
// Take a copy to prevent actions in the data model affecting view.clipboard
pasteData = new ve.dm.ElementLinearData(
slice.getStore(),
ve.copy( slice.getBalancedData() )
);
if ( this.pasteSpecial ) {
sanitize( pasteData );
}
// Insert content
targetFragment.insertContent( pasteData.getData(), true );
}
} else {
if ( clipboardKey && beforePasteData.html ) {
// If the clipboardKey is set (paste from other VE instance), and clipboard
// data is available, then make sure important spans haven't been dropped
if ( !$elements ) {
$elements = $( $.parseHTML( beforePasteData.html ) );
}
if (
// FIXME T126045: Allow the test runner to force the use of clipboardData
clipboardKey === 'useClipboardData-0' || (
$elements.find( importantElement ).andSelf().filter( importantElement ).length > 0 &&
this.$pasteTarget.find( importantElement ).length === 0
)
) {
// CE destroyed an important element, so revert to using clipboard data
htmlDoc = ve.createDocumentFromHtml( beforePasteData.html );
// Remove the pasteProtect class. See #onCopy.
$( htmlDoc ).find( 'span' ).removeClass( 've-pasteProtect' );
beforePasteData.context = null;
}
}
if ( !htmlDoc ) {
// If there were no problems, let CE do its sanitizing as it may
// contain all sorts of horrible metadata (head tags etc.)
// TODO: IE will always take this path, and so may have bugs with span unwrapping
// in edge cases (e.g. pasting a single MWReference)
htmlDoc = ve.createDocumentFromHtml( this.$pasteTarget.html() );
}
// Some browsers don't provide pasted image data through the clipboardData API and
// instead create img tags with data URLs, so detect those here
$body = $( htmlDoc.body );
$images = $body.children( 'img[src^=data\\:]' );
// Check the body contained just children.
// TODO: In the future this may want to trigger image uploads *and* paste the HTML.
if ( $images.length === $body.children().length ) {
for ( i = 0; i < $images.length; i++ ) {
items.push( ve.ui.DataTransferItem.static.newFromDataUri(
$images.eq( i ).attr( 'src' ),
$images[ i ].outerHTML
) );
}
if ( this.handleDataTransferItems( items, true ) ) {
return;
}
}
// HACK: Fix invalid HTML from Google Docs nested lists (T98100).
// Converts
// <ul><li>A</li><ul><li>B</li></ul></ul>
// to
// <ul><li>A<ul><li>B</li></ul></li></ul>
$( htmlDoc.body ).find( 'ul > ul, ul > ol, ol > ul, ol > ol' ).each( function () {
if ( this.previousSibling ) {
this.previousSibling.appendChild( this );
} else {
// List starts double indented. This is invalid and a semantic nightmare.
// Just wrap with an extra list item
$( this ).wrap( '<li>' );
}
} );
// External paste
pastedDocumentModel = ve.dm.converter.getModelFromDom( htmlDoc, {
targetDoc: documentModel.getHtmlDocument(),
fromClipboard: true
} );
data = pastedDocumentModel.data;
// Clear metadata
pastedDocumentModel.metadata = new ve.dm.MetaLinearData( pastedDocumentModel.getStore(), new Array( 1 + data.getLength() ) );
// Clone again
data.cloneElements( true );
// Sanitize
sanitize( data );
data.remapInternalListKeys( documentModel.getInternalList() );
// Initialize node tree
pastedDocumentModel.buildNodeTree();
if ( fragment.getSelection() instanceof ve.dm.TableSelection ) {
// External table-into-table paste
if (
pastedDocumentModel.documentNode.children.length === 2 &&
pastedDocumentModel.documentNode.children[ 0 ] instanceof ve.dm.TableNode
) {
tableAction = new ve.ui.TableAction( this.getSurface() );
tableAction.importTable( pastedDocumentModel.documentNode.children[ 0 ], true );
return;
}
// Pasting non-table content into table: just replace the the first cell with the pasted content
// Cell was not deleted in beforePaste to prevent flicker when table-into-table paste is about to be triggered.
targetFragment.removeContent();
}
// If the paste was given context, calculate the range of the inserted data
if ( beforePasteData.context ) {
internalListRange = pastedDocumentModel.getInternalList().getListNode().getOuterRange();
context = new ve.dm.ElementLinearData(
pastedDocumentModel.getStore(),
ve.copy( beforePasteData.context )
);
// Sanitize context to match data
sanitize( context );
// Remove matching context from the left
left = 0;
while (
context.getLength() &&
ve.dm.ElementLinearData.static.compareElements(
data.getData( left ),
data.isElementData( left ) ? context.getData( 0 ) : beforePasteData.leftText
)
) {
left++;
context.splice( 0, 1 );
}
// Remove matching context from the right
right = internalListRange.start;
while (
right > 0 &&
context.getLength() &&
ve.dm.ElementLinearData.static.compareElements(
data.getData( right - 1 ),
data.isElementData( right - 1 ) ? context.getData( context.getLength() - 1 ) : beforePasteData.rightText
)
) {
right--;
context.splice( context.getLength() - 1, 1 );
}
// Support: Chrome
// FIXME T126046: Strip trailing linebreaks probably introduced by Chrome bug
while ( right > 0 && data.getType( right - 1 ) === 'break' ) {
right--;
}
contextRange = new ve.Range( left, right );
}
// If the external HTML turned out to be plain text after
// sanitization then run it as a plain text transfer item
if ( pastedDocumentModel.data.isPlainText( contextRange, true ) ) {
pastedText = pastedDocumentModel.data.getText( true, contextRange );
if ( pastedText ) {
handled = this.handleDataTransferItems(
[ ve.ui.DataTransferItem.static.newFromString( pastedText ) ],
true,
targetFragment
);
}
}
if ( !handled ) {
targetFragment.insertDocument( pastedDocumentModel, contextRange, true );
}
}
if ( this.getSelection().isNativeCursor() ) {
// Restore focus and scroll position
this.$documentNode[ 0 ].focus();
this.$window.scrollTop( beforePasteData.scrollTop );
// setTimeout: Firefox sometimes doesn't change scrollTop immediately when pasting
// line breaks at the end of a line so do it again later.
setTimeout( function () {
view.$window.scrollTop( beforePasteData.scrollTop );
} );
}
// If orignal selection was linear, switch to end of pasted text
if ( fragment.getSelection() instanceof ve.dm.LinearSelection ) {
targetFragment.collapseToEnd().select();
}
};
/**
* Handle the insertion of a data transfer object
*
* @param {DataTransfer} dataTransfer Data transfer
* @param {boolean} isPaste Handlers being used for paste
* @param {ve.dm.SurfaceFragment} [targetFragment] Fragment to insert data items at, defaults to current selection
* @return {boolean} One more items was handled
*/
ve.ce.Surface.prototype.handleDataTransfer = function ( dataTransfer, isPaste, targetFragment ) {
var i, l, stringData,
items = [],
htmlStringData = dataTransfer.getData( 'text/html' ),
stringTypes = [ 'text/x-moz-url', 'text/uri-list', 'text/x-uri', 'text/html', 'text/plain' ];
// Only look for files if HTML is not available:
// - If a file is pasted/dropped it is unlikely it will have HTML fallback (it will have plain text fallback though)
// - HTML generated from some clients has an image fallback(!) that is a screenshot of the HTML snippet (e.g. LibreOffice Calc)
if ( !htmlStringData ) {
if ( dataTransfer.items ) {
for ( i = 0, l = dataTransfer.items.length; i < l; i++ ) {
if ( dataTransfer.items[ i ].kind !== 'string' ) {
items.push( ve.ui.DataTransferItem.static.newFromItem( dataTransfer.items[ i ], htmlStringData ) );
}
}
} else if ( dataTransfer.files ) {
for ( i = 0, l = dataTransfer.files.length; i < l; i++ ) {
items.push( ve.ui.DataTransferItem.static.newFromBlob( dataTransfer.files[ i ], htmlStringData ) );
}
}
}
for ( i = 0, l = stringTypes.length; i < stringTypes.length; i++ ) {
stringData = dataTransfer.getData( stringTypes[ i ] );
if ( stringData ) {
items.push( ve.ui.DataTransferItem.static.newFromString( stringData, stringTypes[ i ], htmlStringData ) );
}
}
return this.handleDataTransferItems( items, isPaste, targetFragment );
};
/**
* Handle the insertion of data transfer items
*
* @param {ve.ui.DataTransferItem[]} items Data transfer items
* @param {boolean} isPaste Handlers being used for paste
* @param {ve.dm.SurfaceFragment} [targetFragment] Fragment to insert data items at, defaults to current selection
* @return {boolean} One more items was handled
*/
ve.ce.Surface.prototype.handleDataTransferItems = function ( items, isPaste, targetFragment ) {
var i, l, name, item,
dataTransferHandlerFactory = this.getSurface().dataTransferHandlerFactory,
handled = false;
targetFragment = targetFragment || this.getModel().getFragment();
function insert( docOrData ) {
var resultFragment = targetFragment.collapseToEnd();
if ( docOrData instanceof ve.dm.Document ) {
resultFragment.insertDocument( docOrData );
} else {
resultFragment.insertContent( docOrData );
}
// The resultFragment's selection now covers the inserted content;
// adjust selection to end of inserted content.
resultFragment.collapseToEnd().select();
}
for ( i = 0, l = items.length; i < l; i++ ) {
item = items[ i ];
name = dataTransferHandlerFactory.getHandlerNameForItem( item, isPaste, this.pasteSpecial );
if ( name ) {
dataTransferHandlerFactory.create( name, this.surface, item )
.getInsertableData().done( insert );
handled = true;
break;
} else if ( isPaste && item.type === 'text/html' ) {
// Don't handle anything else if text/html is available, as it is handled specially in #afterPaste
break;
}
}
return handled;
};
/**
* Select all the contents within the current context
*/
ve.ce.Surface.prototype.selectAll = function () {
var internalListRange, range, matrix, activeNode,
selection = this.getModel().getSelection(),
dmDoc = this.getModel().getDocument();
if ( selection instanceof ve.dm.LinearSelection ) {
activeNode = this.getActiveNode();
if ( activeNode ) {
range = activeNode.getRange();
range = new ve.Range( range.from + 1, range.to - 1 );
} else {
internalListRange = this.getModel().getDocument().getInternalList().getListNode().getOuterRange();
range = new ve.Range(
dmDoc.getNearestCursorOffset( 0, 1 ),
dmDoc.getNearestCursorOffset( internalListRange.start, -1 )
);
}
this.getModel().setLinearSelection( range );
} else if ( selection instanceof ve.dm.TableSelection ) {
matrix = selection.getTableNode().getMatrix();
this.getModel().setSelection(
new ve.dm.TableSelection(
selection.getDocument(), selection.tableRange,
0, 0, matrix.getMaxColCount() - 1, matrix.getRowCount() - 1
)
);
}
};
/**
* Handle input events.
*
* @method
* @param {jQuery.Event} e The input event
*/
ve.ce.Surface.prototype.onDocumentInput = function () {
this.incRenderLock();
try {
this.surfaceObserver.pollOnce();
} finally {
this.decRenderLock();
}
};
/**
* Handle compositionstart events.
* Note that their meaning varies between browser/OS/IME combinations
*
* @method
* @param {jQuery.Event} e The compositionstart event
*/
ve.ce.Surface.prototype.onDocumentCompositionStart = function () {
// Eagerly trigger emulated deletion on certain selections, to ensure a ContentEditable
// native node merge never happens. See https://phabricator.wikimedia.org/T123716 .
if (
this.model.selection instanceof ve.dm.TableSelection &&
$.client.profile().layout === 'gecko'
) {
// Support: Firefox
// Work around a segfault on blur+focus in Firefox compositionstart handlers.
// It would get triggered by handleInsertion emptying the table cell then putting
// a linear selection inside it. See:
// https://phabricator.wikimedia.org/T86589
// https://bugzilla.mozilla.org/show_bug.cgi?id=1230473
return;
}
this.handleInsertion();
};
/*! Custom Events */
/**
* Handle model select events.
*
* @see ve.dm.Surface#method-change
*/
ve.ce.Surface.prototype.onModelSelect = function () {
var focusedNode, blockSlug,
selection = this.getModel().getSelection();
this.cursorDirectionality = null;
this.contentBranchNodeChanged = false;
this.selection = null;
if ( selection instanceof ve.dm.NullSelection ) {
this.removeCursorHolders();
}
if ( selection instanceof ve.dm.LinearSelection ) {
blockSlug = this.findBlockSlug( selection.getRange() );
if ( blockSlug !== this.focusedBlockSlug ) {
if ( this.focusedBlockSlug ) {
this.focusedBlockSlug.classList.remove(
've-ce-branchNode-blockSlug-focused'
);
this.focusedBlockSlug = null;
}
if ( blockSlug ) {
blockSlug.classList.add( 've-ce-branchNode-blockSlug-focused' );
this.focusedBlockSlug = blockSlug;
this.preparePasteTargetForCopy();
}
}
focusedNode = this.findFocusedNode( selection.getRange() );
// If focus has changed, update nodes and this.focusedNode
if ( focusedNode !== this.focusedNode ) {
if ( this.focusedNode ) {
this.focusedNode.setFocused( false );
this.focusedNode = null;
}
if ( focusedNode ) {
focusedNode.setFocused( true );
this.focusedNode = focusedNode;
// If dragging, we already have a native selection, so don't mess with it
if ( !this.dragging ) {
this.preparePasteTargetForCopy();
// Since the selection is no longer in the documentNode, clear the SurfaceObserver's
// selection state. Otherwise, if the user places the selection back into the documentNode
// in exactly the same place where it was before, the observer won't consider that a change.
this.surfaceObserver.clear();
}
// If the node is outside the view, scroll to it
ve.scrollIntoView( this.focusedNode.$element.get( 0 ) );
}
}
} else {
if ( selection instanceof ve.dm.TableSelection ) {
this.preparePasteTargetForCopy();
}
if ( this.focusedNode ) {
this.focusedNode.setFocused( false );
}
this.focusedNode = null;
}
// Ignore the selection if changeModelSelection is currently being
// called with the same (object-identical) selection object
// (i.e. if the model is calling us back)
if ( !this.isRenderingLocked() && selection !== this.newModelSelection ) {
this.showModelSelection();
this.cleanupUnicorns( false );
}
// Update the selection state in the SurfaceObserver
this.surfaceObserver.pollOnceNoCallback();
};
/**
* Prepare the paste target for a copy event by selecting some text
*/
ve.ce.Surface.prototype.preparePasteTargetForCopy = function () {
// As FF won't fire a copy event with nothing selected, create a native selection.
// If there is a focusedNode available, use its text content so that context menu
// items such as "Search for [SELECTED TEXT]" make sense. If the text is empty or
// whitespace, use a single unicode character as this is required for programmatic
// selection to work correctly in all browsers (e.g. Safari won't select a single space).
// #onCopy will ignore this native selection and use the DM selection
if ( !this.getSurface().isMobile() ) {
this.$pasteTarget.text( ( this.focusedNode && this.focusedNode.$element.text().trim() ) || '☢' );
ve.selectElement( this.$pasteTarget[ 0 ] );
this.$pasteTarget[ 0 ].focus();
} else {
// Selecting the paste target fails on mobile:
// * On iOS The selection stays visible and causes scrolling
// * The user is unlikely to be able to trigger a keyboard copy anyway
// Instead just deactivate the surface so the native cursor doesn't
// get in the way and the on screen keyboard doesn't show.
// TODO: Provide a copy tool in the context menu
this.deactivate();
}
};
/**
* Get the focused node (optionally at a specified range), or null if one is not present
*
* @param {ve.Range} [range] Optional range to check for focused node, defaults to current selection's range
* @return {ve.ce.Node|null} Focused node
*/
ve.ce.Surface.prototype.getFocusedNode = function ( range ) {
var selection;
if ( !range ) {
return this.focusedNode;
}
selection = this.getModel().getSelection();
if (
selection instanceof ve.dm.LinearSelection &&
range.equalsSelection( selection.getRange() )
) {
return this.focusedNode;
}
return this.findFocusedNode( range );
};
/**
* Find the block slug a given range is in.
*
* @param {ve.Range} range Range to check
* @return {HTMLElement|null} Slug, or null if no slug or if range is not collapsed
*/
ve.ce.Surface.prototype.findBlockSlug = function ( range ) {
if ( !range.isCollapsed() ) {
return null;
}
return this.documentView.getDocumentNode().getSlugAtOffset( range.end );
};
/**
* Find the focusedNode at a specified range
*
* @param {ve.Range} range Range to search at for a focusable node
* @return {ve.ce.Node|null} Focused node
*/
ve.ce.Surface.prototype.findFocusedNode = function ( range ) {
var startNode, endNode,
documentNode = this.documentView.getDocumentNode();
// Detect when only a single focusable element is selected
if ( !range.isCollapsed() ) {
startNode = documentNode.getNodeFromOffset( range.start + 1 );
if ( startNode && startNode.isFocusable() ) {
endNode = documentNode.getNodeFromOffset( range.end - 1 );
if ( startNode === endNode ) {
return startNode;
}
}
} else {
// Check if the range is inside a focusable node with a collapsed selection
startNode = documentNode.getNodeFromOffset( range.start );
if ( startNode && startNode.isFocusable() ) {
return startNode;
}
}
return null;
};
/**
* Handle documentUpdate events on the surface model.
*/
ve.ce.Surface.prototype.onModelDocumentUpdate = function () {
var surface = this;
if ( this.contentBranchNodeChanged ) {
// Update the selection state from model
this.onModelSelect();
}
// Update the state of the SurfaceObserver
this.surfaceObserver.pollOnceNoCallback();
// setTimeout: Wait for other documentUpdate listeners to run before emitting
setTimeout( function () {
surface.emit( 'position' );
} );
};
/**
* Handle insertionAnnotationsChange events on the surface model.
*
* @param {ve.dm.AnnotationSet} insertionAnnotations
*/
ve.ce.Surface.prototype.onInsertionAnnotationsChange = function () {
var changed = this.renderSelectedContentBranchNode();
if ( !changed ) {
return;
}
// Must re-apply the selection after re-rendering
this.forceShowModelSelection();
this.surfaceObserver.pollOnceNoCallback();
};
/**
* Re-render the ContentBranchNode the selection is currently in.
*
* @return {boolean} Whether a re-render actually happened
*/
ve.ce.Surface.prototype.renderSelectedContentBranchNode = function () {
var selection, ceNode;
selection = this.model.getSelection();
if ( !( selection instanceof ve.dm.LinearSelection ) ) {
return false;
}
ceNode = this.documentView.getBranchNodeFromOffset( selection.getRange().start );
if ( ceNode === null ) {
return false;
}
if ( !( ceNode instanceof ve.ce.ContentBranchNode ) ) {
// not a content branch node
return false;
}
return ceNode.renderContents();
};
/**
* Handle changes observed from the DOM
*
* These are normally caused by the user interacting directly with the contenteditable.
*
* @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 ( oldState, newState ) {
var newSelection, transaction, removedUnicorns,
activeNode, coveringRange, nodeRange, containsStart, containsEnd,
surface = this,
dmDoc = this.getModel().getDocument(),
insertedText = false;
if ( newState.contentChanged ) {
transaction = newState.textState.getChangeTransaction(
oldState.textState,
dmDoc,
newState.node.getOffset(),
newState.node.unicornAnnotations
);
if ( transaction ) {
this.incRenderLock();
try {
this.changeModel( transaction );
} finally {
this.decRenderLock();
}
insertedText = transaction.operations.filter( function ( op ) {
return op.type === 'replace' && op.insert.length;
} ).length > 0;
}
}
if (
newState.branchNodeChanged &&
oldState &&
oldState.node &&
oldState.node.root &&
oldState.node instanceof ve.ce.ContentBranchNode
) {
oldState.node.renderContents();
}
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();
}
removedUnicorns = this.cleanupUnicorns( false );
if ( removedUnicorns ) {
this.surfaceObserver.pollOnceNoCallback();
}
// Ensure we don't observe a selection that spans an active node
activeNode = this.getActiveNode();
coveringRange = newSelection.getCoveringRange();
if ( activeNode && coveringRange ) {
nodeRange = activeNode.getRange();
containsStart = nodeRange.containsRange( new ve.Range( coveringRange.start ) );
containsEnd = nodeRange.containsRange( new ve.Range( coveringRange.end ) );
// If the range starts xor ends in the active node, but not both, then it must
// span an active node boundary, so fixup.
/*jshint bitwise: false*/
if ( containsStart ^ containsEnd ) {
newSelection = oldState && oldState.veRange ?
new ve.dm.LinearSelection( dmDoc, oldState.veRange ) :
new ve.dm.NullSelection( dmDoc );
// TODO: setTimeout: document purpose
setTimeout( function () {
surface.changeModel( null, newSelection );
surface .showModelSelection();
} );
}
/*jshint bitwise: true*/
}
// Support: Firefox
// 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 ( insertedText ) {
surface.afterRenderLock( function () {
surface.checkSequences();
surface.maybeSetBreakpoint();
} );
}
if ( newState.branchNodeChanged && newState.node ) {
this.updateCursorHolders();
this.showModelSelection();
}
if ( !insertedText ) {
// Two likely cases here:
// 1. The cursor moved. If so, fire off a breakpoint to catch any transactions
// that were pending, in case a word was being typed.
// 2. Text was deleted. If so, make a breakpoint. A future enhancement could be
// to make this only break after a sequence of deletes. (Maybe combine new
// breakpoints with the former breakpoint based on the new transactions?)
surface.getModel().breakpoint();
}
};
/**
* Create a slug out of a DOM element
*
* @param {HTMLElement} element Slug element
*/
ve.ce.Surface.prototype.createSlug = function ( element ) {
var $slug,
surface = this,
offset = ve.ce.getOffsetOfSlug( element ),
documentModel = this.getModel().getDocument();
this.changeModel( ve.dm.Transaction.newFromInsertion(
documentModel, offset, [
{ type: 'paragraph', internal: { generated: 'slug' } },
{ type: '/paragraph' }
]
), new ve.dm.LinearSelection( documentModel, new ve.Range( offset + 1 ) ) );
// Animate the slug open
$slug = this.getDocument().getDocumentNode().getNodeFromOffset( offset + 1 ).$element;
$slug.addClass( 've-ce-branchNode-newSlug' );
// setTimeout: postpone until after animation is complete
setTimeout( function () {
$slug.addClass( 've-ce-branchNode-newSlug-open' );
setTimeout( function () {
surface.emit( 'position' );
}, 200 );
} );
this.onModelSelect();
};
/**
* Move cursor if it is between annotation nails
*
* @param {number} direction Direction of travel, 1=forwards, -1=backwards, 0=unknown
* @param {boolean} extend Whether the anchor should stay where it is
*
* TODO: Improve name
*/
ve.ce.Surface.prototype.fixupCursorPosition = function ( direction, extend ) {
var node, offset, previousNode, fixedPosition, nextNode;
// Default to moving start-wards, to mimic typical Chromium behaviour
direction = direction > 0 ? 1 : -1;
if ( this.nativeSelection.rangeCount === 0 ) {
return;
}
node = this.nativeSelection.focusNode;
offset = this.nativeSelection.focusOffset;
if ( node.nodeType !== Node.ELEMENT_NODE ) {
return;
}
previousNode = node.childNodes[ offset - 1 ];
nextNode = node.childNodes[ offset ];
if (
!(
previousNode &&
previousNode.nodeType === Node.ELEMENT_NODE && (
previousNode.classList.contains( 've-ce-nail-pre-open' ) ||
previousNode.classList.contains( 've-ce-nail-pre-close' )
)
) && !(
nextNode &&
nextNode.nodeType === Node.ELEMENT_NODE && (
nextNode.classList.contains( 've-ce-nail-post-open' ) ||
nextNode.classList.contains( 've-ce-nail-post-close' )
)
)
) {
return;
}
// Between nails: cross the one in the specified direction
fixedPosition = ve.adjacentDomPosition(
{ node: node, offset: offset },
direction,
{ stop: ve.isHardCursorStep }
);
node = fixedPosition.node;
offset = fixedPosition.offset;
if ( direction === -1 ) {
// Support: Firefox
// Moving startwards: left-bias the fixed position
// Avoids Firefox bug "cursor disappears at left of img inside link":
// https://bugzilla.mozilla.org/show_bug.cgi?id=1175495
fixedPosition = ve.adjacentDomPosition(
fixedPosition,
direction,
{ stop: ve.isHardCursorStep }
);
if ( fixedPosition.node.nodeType === Node.TEXT_NODE ) {
// Have crossed into a text node; go back to its end
node = fixedPosition.node;
offset = fixedPosition.node.length;
}
}
this.showSelectionState( new ve.SelectionState( {
anchorNode: extend ? this.nativeSelection.anchorNode : node,
anchorOffset: extend ? this.nativeSelection.anchorOffset : offset,
focusNode: node,
focusOffset: offset
} ) );
};
/**
* Check the current surface offset for sequence matches
*/
ve.ce.Surface.prototype.checkSequences = function () {
var i, sequences,
executed = false,
model = this.getModel(),
selection = this.getSelection();
if ( !selection.isNativeCursor() ) {
return;
}
sequences = this.getSurface().sequenceRegistry.findMatching( model.getDocument().data, selection.getModel().getCoveringRange().end );
// sequences.length will likely be 0 or 1 so don't cache
for ( i = 0; i < sequences.length; i++ ) {
executed = sequences[ i ].sequence.execute( this.surface, sequences[ i ].range ) || executed;
}
if ( executed ) {
this.showModelSelection();
}
};
/**
* See if the just-entered content fits our criteria for setting a history breakpoint
*/
ve.ce.Surface.prototype.maybeSetBreakpoint = function () {
var offset,
data = this.getModel().getDocument().data,
selection = this.getSelection();
if ( !selection.isNativeCursor() ) {
return;
}
// We have just entered text, probably. We want to know whether we just
// created a word break. We can't check the current offset, since the
// common case is that being at the end of the string, which is inherently
// a word break. So, we check whether the previous offset is a word break,
// which should catch cases where we have hit space or added punctuation.
// We use getWordRange because it handles the unicode cases, and accounts
// for single-character words where a space back is a word break because
// it's the *start* of a word.
// Note: Text input which isn't using word breaks, for whatever reason,
// will get breakpoints set by the fallback timer anyway. This is the
// main reason to not debounce that timer here, as then a reasonable
// typist with such text would never get a breakpoint set. The compromise
// position here will occasionally get a breakpoint set in the middle of
// the first word typed.
offset = selection.getModel().getCoveringRange().end - 1;
if ( data.getWordRange( offset ).end === offset ) {
this.getModel().breakpoint();
}
};
/**
* Handle window resize event.
*
* @param {jQuery.Event} e Window resize event
*/
ve.ce.Surface.prototype.onWindowResize = ve.debounce( function () {
this.emit( 'position' );
}, 50 );
/*! Relocation */
/**
* Start a relocation action.
*
* @see ve.ce.FocusableNode
*
* @param {ve.ce.Node} node Node being relocated
*/
ve.ce.Surface.prototype.startRelocation = function ( node ) {
this.relocatingNode = node;
this.emit( 'relocationStart', node );
};
/**
* Complete a relocation action.
*
* @see ve.ce.FocusableNode
*/
ve.ce.Surface.prototype.endRelocation = function () {
if ( this.relocatingNode ) {
this.emit( 'relocationEnd', this.relocatingNode );
this.relocatingNode = null;
}
// Trigger a drag leave event to clear markers
this.onDocumentDragLeave();
};
/**
* Set the active node
*
* @param {ve.ce.Node|null} node Active node
*/
ve.ce.Surface.prototype.setActiveNode = function ( node ) {
this.activeNode = node;
};
/**
* Get the active node
*
* @return {ve.ce.Node|null} Active node
*/
ve.ce.Surface.prototype.getActiveNode = function () {
return this.activeNode;
};
/*! Utilities */
/**
* Store a state snapshot at a keydown event, to be used in an after-keydown handler
*
* A ve.SelectionState object is stored, but only when the key event is a cursor key.
* (It would be misleading to save selection properties for key events where the DOM might get
* 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).
*
* @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.selectionState = null;
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.selectionState = new ve.SelectionState( this.nativeSelection );
}
};
/**
* Clear a stored state snapshot from a key down event
*/
ve.ce.Surface.prototype.clearKeyDownState = function () {
this.keyDownState.event = null;
this.keyDownState.selectionState = null;
};
/**
* Move the DM surface cursor
*
* @param {number} offset Distance to move (negative = toward document start)
*/
ve.ce.Surface.prototype.moveModelCursor = function ( offset ) {
var selection = this.model.getSelection();
if ( selection instanceof ve.dm.LinearSelection ) {
this.model.setLinearSelection( this.model.getDocument().getRelativeRange(
selection.getRange(),
offset,
'character',
false
) );
}
};
/**
* Get the directionality at the current focused node
*
* @return {string} 'ltr' or 'rtl'
*/
ve.ce.Surface.prototype.getFocusedNodeDirectionality = function () {
var cursorNode,
range = this.model.getSelection().getRange();
// Use stored directionality if we have one.
if ( this.cursorDirectionality ) {
return this.cursorDirectionality;
}
// Else fall back on the CSS directionality of the focused node at the DM selection focus,
// which is less reliable because it does not take plaintext bidi into account.
// (range.to will actually be at the edge of the focused node, but the
// CSS directionality will be the same).
cursorNode = this.getDocument().getNodeAndOffset( range.to ).node;
if ( cursorNode.nodeType === Node.TEXT_NODE ) {
cursorNode = cursorNode.parentNode;
}
return $( cursorNode ).css( 'direction' );
};
/**
* Restore the selection from the model if it is outside the active table node
*
* This is only useful if the DOM selection and the model selection are out of sync.
*
* @return {boolean} Whether the selection was restored
*/
ve.ce.Surface.prototype.restoreActiveNodeSelection = function () {
var range;
if (
( range = this.getActiveNode() && this.getActiveNode().getRange() ) &&
!range.containsRange( ve.ce.veRangeFromSelection( this.nativeSelection ) )
) {
this.showModelSelection();
return true;
} else {
return false;
}
};
/**
* Find a ce=false branch node that a native cursor movement from here *might* skip
*
* If a node is returned, then it might get skipped by a single native cursor
* movement in the specified direction from the closest branch node at the
* current cursor focus. However, if null is returned, then any single such
* movement is guaranteed *not* to skip an uneditable branch node.
*
* Note we cannot predict precisely where/with which cursor key we might step out
* of the current closest branch node, because it is difficult to predict the
* behaviour of left/rightarrow (because of bidi visual cursoring) and
* up/downarrow (because of wrapping).
*
* @param {number} direction -1 for before the cursor, +1 for after
* @return {Node|null} Potentially cursor-adjacent uneditable branch node, or null
*/
ve.ce.Surface.prototype.findAdjacentUneditableBranchNode = function ( direction ) {
var node, viewNode,
activeNode = this.getActiveNode(),
forward = direction > 0;
node = $( this.nativeSelection.focusNode ).closest(
'.ve-ce-branchNode,.ve-ce-leafNode,.ve-ce-surface-paste'
)[ 0 ];
if ( !node || node.classList.contains( 've-ce-surface-paste' ) ) {
return null;
}
// Walk in document order till we find a ContentBranchNode (in which case
// return null) or a FocusableNode/TableNode (in which case return the node)
// or run out of nodes (in which case return null)
while ( true ) {
// Step up until we find a sibling
while ( !( forward ? node.nextSibling : node.previousSibling ) ) {
node = node.parentNode;
if ( node === null ) {
// Reached the document start/end
return null;
}
}
// Step back
node = forward ? node.nextSibling : node.previousSibling;
// Check and step down
while ( true ) {
if (
$.data( node, 'view' ) instanceof ve.ce.ContentBranchNode ||
// We shouldn't ever hit a raw text node, because they
// should all be wrapped in CBNs or focusable nodes, but
// just in case...
node.nodeType === Node.TEXT_NODE
) {
// This is cursorable (must have content or slugs)
return null;
}
if ( $( node ).is( '.ve-ce-focusableNode,.ve-ce-tableNode' ) ) {
if ( activeNode ) {
viewNode = $( node ).data( 'view' );
if ( !activeNode.getRange().containsRange( viewNode.getRange() ) ) {
// Node is outside the active node
return null;
}
}
return node;
}
if ( !node.childNodes || node.childNodes.length === 0 ) {
break;
}
node = forward ? node.firstChild : node.lastChild;
}
}
};
/**
* Insert cursor holders, if they might be required as a cursor target
*/
ve.ce.Surface.prototype.updateCursorHolders = function () {
var holderBefore = null,
holderAfter = null,
doc = this.getElementDocument(),
nodeBefore = this.findAdjacentUneditableBranchNode( -1 ),
nodeAfter = this.findAdjacentUneditableBranchNode( 1 );
this.removeCursorHolders();
if ( nodeBefore ) {
holderBefore = doc.importNode( this.constructor.static.cursorHolderTemplate, true );
holderBefore.classList.add( 've-ce-cursorHolder-after' );
if ( ve.inputDebug ) {
holderBefore.classList.add( 've-ce-cursorHolder-debug' );
}
$( nodeBefore ).after( holderBefore );
}
if ( nodeAfter ) {
holderAfter = doc.importNode( this.constructor.static.cursorHolderTemplate, true );
holderAfter.classList.add( 've-ce-cursorHolder-before' );
$( nodeAfter ).before( holderAfter );
if ( ve.inputDebug ) {
holderAfter.classList.add( 've-ce-cursorHolder-debug' );
}
}
this.cursorHolders = { before: holderBefore, after: holderAfter };
};
/**
* Remove cursor holders, if they exist
*/
ve.ce.Surface.prototype.removeCursorHolders = function () {
if ( !this.cursorHolders ) {
return;
}
if ( this.cursorHolders.before ) {
this.cursorHolders.before.parentNode.removeChild( this.cursorHolders.before );
}
if ( this.cursorHolders.after ) {
this.cursorHolders.after.parentNode.removeChild( this.cursorHolders.after );
}
this.cursorHolders = null;
};
/**
* Handle insertion of content.
*/
ve.ce.Surface.prototype.handleInsertion = function () {
var range, hasChanged,
surfaceModel = this.getModel(),
fragment = surfaceModel.getFragment(),
selection = this.getSelection();
hasChanged = false;
if ( selection instanceof ve.ce.TableSelection ) {
// Collapse table selection to anchor cell
surfaceModel.setSelection( selection.getModel().collapseToFrom() );
// Delete the current contents
ve.ce.keyDownHandlerFactory.lookup( 'tableDelete' ).static.execute( this );
// Place selection inside the cell
this.documentView.getBranchNodeFromOffset( selection.getModel().tableRange.start + 1 ).setEditing( true );
// Selection has changed, update
selection = this.getSelection();
} else if ( selection.isFocusedNode() ) {
// Don't allow a user to delete a non-table focusable node just by typing
return;
}
if ( !( selection instanceof ve.ce.LinearSelection ) ) {
return;
}
range = selection.getModel().getRange();
// Handles removing expanded selection before inserting new text
if (
this.selectionSplitsLink() ||
( !range.isCollapsed() && !this.documentView.rangeInsideOneLeafNode( range ) )
) {
// Remove the selection to force its re-application from the DM (even if the
// DM is too granular to detect the selection change)
surfaceModel.setNullSelection();
fragment.removeContent().collapseToStart().select();
hasChanged = true;
this.surfaceObserver.clear();
this.storeKeyDownState( this.keyDownState.event );
this.surfaceObserver.stopTimerLoop();
this.surfaceObserver.pollOnce();
}
};
/**
* Get an approximate range covering data visible in the viewport
*
* It is assumed that vertical offset increases as you progress through the DM.
* Items with custom positioning may throw off results given by this method, so
* it should only be treated as an approximation.
*
* @return {ve.Range} Range covering data visible in the viewport
*/
ve.ce.Surface.prototype.getViewportRange = function () {
var surface = this,
documentModel = this.getModel().getDocument(),
data = documentModel.data,
dimensions = this.surface.getViewportDimensions(),
// We want a little padding when finding the range, because this is
// generally used for things like find/replace, where scrolling to see
// context is important.
padding = 50,
top = Math.max( 0, dimensions.top - padding ),
bottom = dimensions.bottom + ( padding * 2 ),
documentRange = new ve.Range( 0, this.getModel().getDocument().getInternalList().getListNode().getOuterRange().start );
function highestIgnoreChildrenNode( childNode ) {
var ignoreChildrenNode = null;
childNode.traverseUpstream( function ( node ) {
if ( node.shouldIgnoreChildren() ) {
ignoreChildrenNode = node;
}
} );
return ignoreChildrenNode;
}
function binarySearch( offset, range, side ) {
var mid, rect, midNode, ignoreChildrenNode, nodeRange,
start = range.start,
end = range.end,
lastLength = Infinity;
while ( range.getLength() < lastLength ) {
lastLength = range.getLength();
mid = Math.round( ( range.start + range.end ) / 2 );
midNode = documentModel.documentNode.getNodeFromOffset( mid );
ignoreChildrenNode = highestIgnoreChildrenNode( midNode );
if ( ignoreChildrenNode ) {
nodeRange = ignoreChildrenNode.getOuterRange();
mid = side === 'top' ? nodeRange.end : nodeRange.start;
} else {
mid = data.getNearestContentOffset( mid );
}
rect = surface.getSelection( new ve.dm.LinearSelection( documentModel, new ve.Range( mid ) ) ).getSelectionBoundingRect();
if ( rect[ side ] > offset ) {
end = mid;
range = new ve.Range( range.start, end );
} else {
start = mid;
range = new ve.Range( start, range.end );
}
}
return side === 'bottom' ? start : end;
}
return new ve.Range(
binarySearch( top, documentRange, 'bottom' ),
binarySearch( bottom, documentRange, 'top' )
);
};
/**
* Apply a DM selection to the DOM, even if the old DOM selection is different but DM-equivalent
*
* @method
* @return {boolean} Whether the selection actually changed
*/
ve.ce.Surface.prototype.forceShowModelSelection = function () {
return this.showModelSelection( true );
};
/**
* Apply a DM selection to the DOM
*
* @method
* @param {boolean} [force] Replace the DOM selection if it is different but DM-equivalent
* @return {boolean} Whether the selection actually changed
*/
ve.ce.Surface.prototype.showModelSelection = function ( force ) {
var selection, changed, modelRange, impliedModelRange;
if ( this.deactivated ) {
// setTimeout: Defer until view has updated
setTimeout( this.updateDeactivatedSelection.bind( this ) );
return false;
}
selection = this.getSelection();
if ( !selection.isNativeCursor() || this.focusedBlockSlug ) {
// Model selection is an emulated selection (e.g. table). The view is certain to
// match it already, because there is no way to change the view selection when
// an emulated selection is showing.
return false;
}
modelRange = selection.getModel().getRange();
if ( !force && this.documentView.documentNode.$element.get( 0 ).contains(
this.nativeSelection.focusNode
) ) {
// See whether the model range implied by the DOM selection is already equal to
// the actual model range. This is necessary because one model selection can
// correspond to many DOM selections, and we don't want to change a DOM
// selection that is already valid to an arbitrary different DOM selection.
impliedModelRange = new ve.Range(
ve.ce.getOffset(
this.nativeSelection.anchorNode,
this.nativeSelection.anchorOffset
),
ve.ce.getOffset(
this.nativeSelection.focusNode,
this.nativeSelection.focusOffset
)
);
if ( modelRange.equals( impliedModelRange ) ) {
// Current native selection fits model range; don't change
return false;
}
}
changed = this.showSelectionState( this.getSelectionState( modelRange ) );
// Support: Chrome
// Fixes T131674, which is only triggered with Chromium-style ce=false cursoring
// restrictions (but other cases of non-updated cursor holders can probably occur
// in other browsers).
if ( changed ) {
this.updateCursorHolders();
return true;
}
return false;
};
/**
* Apply a selection state to the DOM
*
* If the browser cannot show a backward selection, fall back to the forward equivalent
*
* @param {ve.SelectionState} selection The selection state to show
* @return {boolean} Whether the selection actually changed
*/
ve.ce.Surface.prototype.showSelectionState = function ( selection ) {
var range,
$focusTarget,
extendedBackwards = false,
sel = this.nativeSelection,
newSel = selection;
if ( newSel.equalsSelection( sel ) ) {
this.updateActiveLink();
return false;
}
if ( newSel.isBackwards ) {
if ( sel.extend ) {
// Set the range at the anchor, and extend backwards to the focus
range = this.getElementDocument().createRange();
range.setStart( newSel.anchorNode, newSel.anchorOffset );
sel.removeAllRanges();
sel.addRange( range );
try {
sel.extend( newSel.focusNode, newSel.focusOffset );
extendedBackwards = true;
} catch ( e ) {
// Support: Firefox
// Firefox sometimes fails when nodes are different
// see https://bugzilla.mozilla.org/show_bug.cgi?id=921444
}
}
if ( !extendedBackwards ) {
// Fallback: Apply the corresponding forward selection
newSel = newSel.flip();
if ( newSel.equalsSelection( sel ) ) {
this.updateActiveLink();
return false;
}
}
}
if ( !extendedBackwards ) {
// Forward selection
sel.removeAllRanges();
sel.addRange( newSel.getNativeRange( this.getElementDocument() ) );
}
// Setting a range doesn't give focus in all browsers so make sure this happens
// Also set focus after range to prevent scrolling to top
$focusTarget = $( newSel.focusNode ).closest( '[contenteditable=true]' );
if ( !OO.ui.contains( $focusTarget.get( 0 ), this.getElementDocument().activeElement ) ) {
// Note: contains *doesn't* include === here. This is desired, as the
// common case for getting here is when pressing backspace when the
// cursor is in the middle of a block of text (thus both are a <div>),
// and we don't want to scroll away from the caret.
$focusTarget.focus();
} else {
// Scroll the node into view
ve.scrollIntoView(
$( newSel.focusNode ).closest( '*' ).get( 0 )
);
}
this.updateActiveLink();
return true;
};
/**
* Update the activeLink property and apply CSS classes accordingly
*/
ve.ce.Surface.prototype.updateActiveLink = function () {
var activeLink = this.linkAnnotationAtFocus();
if ( activeLink === this.activeLink ) {
return;
}
if ( this.activeLink ) {
this.activeLink.classList.remove( 've-ce-linkAnnotation-active' );
}
this.activeLink = activeLink;
if ( activeLink ) {
this.activeLink.classList.add( 've-ce-linkAnnotation-active' );
}
this.model.emit( 'contextChange' );
};
/**
* Update the selection to contain the contents of a node
*
* @param {HTMLElement} node
* @return {boolean} Whether the selection changed
*/
ve.ce.Surface.prototype.selectNodeContents = function ( node ) {
var anchor, focus;
if ( !node ) {
return false;
}
anchor = ve.ce.nextCursorOffset( node.childNodes[ 0 ] );
focus = ve.ce.previousCursorOffset( node.childNodes[ node.childNodes.length - 1 ] );
return this.showSelectionState( new ve.SelectionState( {
anchorNode: anchor.node,
anchorOffset: anchor.offset, // past the nail
focusNode: focus.node,
focusOffset: focus.offset, // before the nail
isCollapsed: false
} ) );
};
/**
* Update the selection to contain the contents of the activeLink, if it exists
*
* @return {boolean} Whether the selection changed
*/
ve.ce.Surface.prototype.selectActiveLinkContents = function () {
return this.selectLinkContents( this.activeLink );
};
/**
* Get the linkAnnotation node containing the cursor focus
*
* If there is no focus, or it is not inside a linkAnnotation, return null
*
* @return {Node|null} the linkAnnotation node containing the focus
*
*/
ve.ce.Surface.prototype.linkAnnotationAtFocus = function () {
return $( this.nativeSelection.focusNode ).closest( '.ve-ce-linkAnnotation' )[ 0 ] || null;
};
/**
* Get a SelectionState corresponding to a ve.Range.
*
* If either endpoint of the ve.Range is not a cursor offset, adjust the SelectionState
* endpoints to be at cursor offsets. For a collapsed selection, the adjustment preserves
* collapsedness; for a non-collapsed selection, the adjustment is in the direction that
* grows the selection (thereby avoiding collapsing or reversing the selection).
*
* @method
* @param {ve.Range} range Range to get selection for
* @return {Object} The selection
* @return {Node} return.anchorNode The anchor node
* @return {number} return.anchorOffset The anchor offset
* @return {Node} return.focusNode The focus node
* @return {number} return.focusOffset The focus offset
* @return {boolean} return.isCollapsed True if the focus and anchor are in the same place
* @return {boolean} return.isBackwards True if the focus is before the anchor
*/
ve.ce.Surface.prototype.getSelectionState = function ( range ) {
var anchor, focus,
dmDoc = this.getModel().getDocument();
// Anchor/focus at the nearest correct position in the direction that
// grows the selection. If we're not yet fully focused, move the selection
// outside any nails to avoid popping up a context menu.
anchor = this.documentView.getNodeAndOffset(
dmDoc.getNearestCursorOffset( range.from, range.isBackwards() ? 1 : -1 ),
!this.focused
);
if ( range.isCollapsed() ) {
focus = anchor;
} else {
focus = this.documentView.getNodeAndOffset(
dmDoc.getNearestCursorOffset( range.to, range.isBackwards() ? -1 : 1 ),
!this.focused
);
}
return new ve.SelectionState( {
anchorNode: anchor.node,
anchorOffset: anchor.offset,
focusNode: focus.node,
focusOffset: focus.offset,
isBackwards: range.isBackwards()
} );
};
/**
* Get a native range object for a specified ve.Range
*
* Native ranges are only used by linear selections. They don't show whether the selection
* is backwards, so they should be used for measurement only.
*
* @param {ve.Range} [range] Optional range to get the native range for, defaults to current selection's range
* @return {Range|null} Native range object, or null if there is no suitable selection
*/
ve.ce.Surface.prototype.getNativeRange = function ( range ) {
var selectionState;
if ( !range ) {
// If no range specified, or range is equivalent to current native selection,
// then use the current native selection
selectionState = new ve.SelectionState( this.nativeSelection );
} else {
selectionState = this.getSelectionState( range );
}
return selectionState.getNativeRange( this.getElementDocument() );
};
/**
* Append passed highlights to highlight container.
*
* @method
* @param {jQuery} $highlights Highlights to append
* @param {boolean} focused Highlights are currently focused
*/
ve.ce.Surface.prototype.appendHighlights = function ( $highlights, focused ) {
// Only one item can be blurred-highlighted at a time, so remove the others.
// Remove by detaching so they don't lose their event handlers, in case they
// are attached again.
this.$highlightsBlurred.children().detach();
if ( focused ) {
this.$highlightsFocused.append( $highlights );
} else {
this.$highlightsBlurred.append( $highlights );
}
};
/*! Getters */
/**
* Get the top-level surface.
*
* @method
* @return {ve.ui.Surface} Surface
*/
ve.ce.Surface.prototype.getSurface = function () {
return this.surface;
};
/**
* Get the surface model.
*
* @method
* @return {ve.dm.Surface} Surface model
*/
ve.ce.Surface.prototype.getModel = function () {
return this.model;
};
/**
* Get the document view.
*
* @method
* @return {ve.ce.Document} Document view
*/
ve.ce.Surface.prototype.getDocument = function () {
return this.documentView;
};
/**
* Check whether there are any render locks
*
* @method
* @return {boolean} Render is locked
*/
ve.ce.Surface.prototype.isRenderingLocked = function () {
return this.renderLocks > 0;
};
/**
* Add a single render lock (to disable rendering)
*
* @method
*/
ve.ce.Surface.prototype.incRenderLock = function () {
this.renderLocks++;
};
/**
* Remove a single render lock
*
* @method
*/
ve.ce.Surface.prototype.decRenderLock = function () {
this.renderLocks--;
};
/**
* Escape the current render lock
*/
ve.ce.Surface.prototype.afterRenderLock = function ( callback ) {
// TODO: implement an actual tracking system that makes sure renderlock is
// 0 when this is done.
// setTimeout: postpone until there is definitely no render lock
setTimeout( callback );
};
/**
* Change the model only, not the CE surface
*
* This avoids event storms when the CE surface is already correct
*
* @method
* @param {ve.dm.Transaction|ve.dm.Transaction[]|null} transactions One or more transactions to
* process, or null to process none
* @param {ve.dm.Selection} selection New selection
* @throws {Error} If calls to this method are nested
*/
ve.ce.Surface.prototype.changeModel = function ( transactions, selection ) {
if ( this.newModelSelection !== null ) {
throw new Error( 'Nested change of newModelSelection' );
}
this.newModelSelection = selection;
try {
this.model.change( transactions, selection );
} finally {
this.newModelSelection = null;
}
};
/**
* Inform the surface that one of its ContentBranchNodes' rendering has changed.
*
* @see ve.ce.ContentBranchNode#renderContents
*/
ve.ce.Surface.prototype.setContentBranchNodeChanged = function () {
this.contentBranchNodeChanged = true;
this.clearKeyDownState();
};
/**
* Set the node that has the current unicorn.
*
* If another node currently has a unicorn, it will be rerendered, which will
* cause it to release its unicorn.
*
* @param {ve.ce.ContentBranchNode} node The node claiming the unicorn
*/
ve.ce.Surface.prototype.setUnicorning = function ( node ) {
if ( this.setUnicorningRecursionGuard ) {
throw new Error( 'setUnicorning recursing' );
}
if ( this.unicorningNode && this.unicorningNode !== node ) {
this.setUnicorningRecursionGuard = true;
try {
this.unicorningNode.renderContents();
} finally {
this.setUnicorningRecursionGuard = false;
}
}
this.unicorningNode = node;
};
/**
* Release the current unicorn held by a given node.
*
* If the node doesn't hold the current unicorn, nothing happens.
* This function does not cause any node to be rerendered.
*
* @param {ve.ce.ContentBranchNode} node The node releasing the unicorn
*/
ve.ce.Surface.prototype.setNotUnicorning = function ( node ) {
if ( this.unicorningNode === node ) {
this.unicorningNode = null;
}
};
/**
* Ensure that no node has a unicorn.
*
* If the given node currently has the unicorn, it will be released and
* no rerender will happen. If another node has the unicorn, that node
* will be rerendered to get rid of the unicorn.
*
* @param {ve.ce.ContentBranchNode} node The node releasing the unicorn
*/
ve.ce.Surface.prototype.setNotUnicorningAll = function ( node ) {
if ( this.unicorningNode === node ) {
// Don't call back node.renderContents()
this.unicorningNode = null;
}
this.setUnicorning( null );
};
/**
* Get list of selected nodes and annotations.
*
* Exclude link annotations unless the CE focus is inside a link
*
* @param {boolean} [all] Include nodes and annotations which only cover some of the fragment
* @return {ve.dm.Model[]} Selected models
*/
ve.ce.Surface.prototype.getSelectedModels = function () {
var models, fragmentAfter;
if ( !( this.model.selection instanceof ve.dm.LinearSelection ) ) {
return [];
}
models = this.model.getFragment().getSelectedModels();
if ( this.model.selection.isCollapsed() ) {
fragmentAfter = this.model.getFragment( new ve.dm.LinearSelection(
this.model.getDocument(),
new ve.Range(
this.model.selection.range.start,
this.model.selection.range.start + 1
)
) );
models = OO.unique( [].concat(
models,
fragmentAfter.getSelectedModels()
) );
}
if ( this.activeLink ) {
return models;
}
return models.filter( function ( annModel ) {
return !( annModel instanceof ve.dm.LinkAnnotation );
} );
};
/**
* Tests whether the selection covers part but not all of a link
*
* @return {boolean} True if a link is split either at the focus or at the anchor (or both)
*/
ve.ce.Surface.prototype.selectionSplitsLink = function () {
return ve.ce.linkAt( this.nativeSelection.anchorNode ) !==
ve.ce.linkAt( this.nativeSelection.focusNode );
};
/**
* Check if the surface supports the pointer-events CSS rule
*
* Support: IE<=10
*
* @return {boolean} The surface supports pointer-events
*/
ve.ce.Surface.prototype.supportsPointerEvents = function () {
var element;
if ( this.pointerEvents === null ) {
element = this.getElementDocument().createElement( 'div' );
element.style.cssText = 'pointer-events:auto';
this.pointerEvents = element.style.pointerEvents === 'auto';
}
return this.pointerEvents;
};