mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-12-01 17:36:35 +00:00
60262b883e
So that if the function bails with a return they are still run. Bug: 58346 Change-Id: Ibd0f194e9c31e2b023dbd00a13ade51785f78ae6
2123 lines
63 KiB
JavaScript
2123 lines
63 KiB
JavaScript
/*!
|
|
* VisualEditor ContentEditable Surface class.
|
|
*
|
|
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
|
|
* @license The MIT License (MIT); see LICENSE.txt
|
|
*/
|
|
/*global rangy */
|
|
|
|
/**
|
|
* ContentEditable surface.
|
|
*
|
|
* @class
|
|
* @extends OO.ui.Element
|
|
* @mixins OO.EventEmitter
|
|
*
|
|
* @constructor
|
|
* @param {jQuery} $container
|
|
* @param {ve.dm.Surface} model Surface model to observe
|
|
* @param {ve.ui.Surface} surface Surface user interface
|
|
* @param {Object} [config] Configuration options
|
|
*/
|
|
ve.ce.Surface = function VeCeSurface( model, surface, options ) {
|
|
var $documentNode;
|
|
// Parent constructor
|
|
OO.ui.Element.call( this, options );
|
|
|
|
// Mixin constructors
|
|
OO.EventEmitter.call( this );
|
|
|
|
// Properties
|
|
this.surface = surface;
|
|
this.inIme = false;
|
|
this.model = model;
|
|
this.documentView = new ve.ce.Document( model.getDocument(), this );
|
|
this.surfaceObserver = new ve.ce.SurfaceObserver( this.documentView );
|
|
this.selectionTimeout = null;
|
|
this.$document = this.$( this.getElementDocument() );
|
|
this.eventSequencer = new ve.EventSequencer( [
|
|
'keydown', 'keypress', 'keyup', 'mousedown', 'mouseup',
|
|
'mousemove', 'compositionstart', 'compositionend'
|
|
] );
|
|
this.clipboard = [];
|
|
this.clipboardId = String( Math.random() );
|
|
this.renderLocks = 0;
|
|
this.dragging = false;
|
|
this.relocating = false;
|
|
this.selecting = false;
|
|
this.resizing = false;
|
|
this.contentBranchNodeChanged = false;
|
|
this.$phantoms = this.$( '<div>' );
|
|
this.$highlights = this.$( '<div>' );
|
|
this.$pasteTarget = this.$( '<div>' );
|
|
this.pasting = false;
|
|
this.pasteSpecial = false;
|
|
this.clickHistory = [];
|
|
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;
|
|
|
|
// Events
|
|
this.surfaceObserver.connect(
|
|
this, { 'contentChange': 'onContentChange', 'selectionChange': 'onSelectionChange' }
|
|
);
|
|
this.model.connect( this,
|
|
{ 'select': 'onModelSelect', 'documentUpdate': 'onModelDocumentUpdate' }
|
|
);
|
|
|
|
$documentNode = this.documentView.getDocumentNode().$element;
|
|
$documentNode.on( {
|
|
'cut': ve.bind( this.onCut, this ),
|
|
'copy': ve.bind( this.onCopy, this )
|
|
} );
|
|
this.$pasteTarget.on( {
|
|
'cut': ve.bind( this.onCut, this ),
|
|
'copy': ve.bind( this.onCopy, this )
|
|
} );
|
|
|
|
// blur and focus fire in the wrong order in jQuery 1.8 . Bind to the native events which do
|
|
// fire in the correct order.
|
|
$documentNode[0].addEventListener( 'focus', ve.bind( this.documentOnFocus, this ) );
|
|
$documentNode[0].addEventListener( 'blur', ve.bind( this.documentOnBlur, this ) );
|
|
// $pasteTarget is focused when selecting a FocusableNode
|
|
this.$pasteTarget[0].addEventListener( 'focus', ve.bind( this.documentOnFocus, this ) );
|
|
this.$pasteTarget[0].addEventListener( 'blur', ve.bind( this.documentOnBlur, this ) );
|
|
|
|
$documentNode.on( $.browser.msie ? 'beforepaste' : 'paste', ve.bind( this.onPaste, this ) );
|
|
$documentNode.on( 'focus', 'a', function () {
|
|
// Opera triggers 'blur' on document node before any link is
|
|
// focused and we don't want that
|
|
$documentNode.focus();
|
|
} );
|
|
|
|
this.$element.on( {
|
|
'dragover': ve.bind( this.onDocumentDragOver, this ),
|
|
'drop': ve.bind( this.onDocumentDrop, this )
|
|
} );
|
|
|
|
// Add listeners to the eventSequencer. They won't get called until
|
|
// eventSequencer.attach(node) has been called.
|
|
this.eventSequencer.on( {
|
|
'keydown': ve.bind( this.onDocumentKeyDown, this ),
|
|
'keyup': ve.bind( this.onDocumentKeyUp, this ),
|
|
'keypress': ve.bind( this.onDocumentKeyPress, this ),
|
|
'mousedown': ve.bind( this.onDocumentMouseDown, this ),
|
|
'mouseup': ve.bind( this.onDocumentMouseUp, this ),
|
|
'mousemove': ve.bind( this.onDocumentMouseMove, this ),
|
|
'compositionstart': ve.bind( this.onDocumentCompositionStart, this ),
|
|
'compositionend': ve.bind( this.onDocumentCompositionEnd, this )
|
|
} );
|
|
this.eventSequencer.after( {
|
|
'keypress': ve.bind( this.afterDocumentKeyPress, this )
|
|
} );
|
|
|
|
// Initialization
|
|
this.$element.addClass( 've-ce-surface' );
|
|
this.$phantoms.addClass( 've-ce-surface-phantoms' );
|
|
this.$highlights.addClass( 've-ce-surface-highlights' );
|
|
this.$pasteTarget.addClass( 've-ce-surface-paste' ).prop( 'contentEditable', 'true' );
|
|
|
|
// Add elements to the DOM
|
|
this.$element.append( this.documentView.getDocumentNode().$element, this.$pasteTarget );
|
|
this.surface.$localOverlayBlockers.append( this.$phantoms, this.$highlights );
|
|
};
|
|
|
|
/* Inheritance */
|
|
|
|
OO.inheritClass( ve.ce.Surface, OO.ui.Element );
|
|
|
|
OO.mixinClass( ve.ce.Surface, OO.EventEmitter );
|
|
|
|
/* Events */
|
|
|
|
/**
|
|
* @event selectionStart
|
|
*/
|
|
|
|
/**
|
|
* @event selectionEnd
|
|
*/
|
|
|
|
/**
|
|
* @event relocationStart
|
|
*/
|
|
|
|
/**
|
|
* @event relocationEnd
|
|
*/
|
|
|
|
/**
|
|
* @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 methods */
|
|
|
|
/**
|
|
* When pasting, browsers normalize HTML to varying degrees.
|
|
* This hash creates a comparable string for validating clipboard contents.
|
|
*
|
|
* @param {jQuery} $elements Clipboard HTML elements
|
|
* @returns {string} Hash
|
|
*/
|
|
ve.ce.Surface.static.getClipboardHash = function ( $elements ) {
|
|
var hash = '';
|
|
// Collect text contents, or just node name for content-less nodes.
|
|
$elements.each( function () {
|
|
hash += this.textContent || '<' + this.nodeName + '>';
|
|
} );
|
|
// Whitespace may be added/removed, so strip it all
|
|
return hash.replace( /\s/gm, '' );
|
|
};
|
|
|
|
/* Methods */
|
|
|
|
/**
|
|
* Get the coordinates of the selection anchor.
|
|
*
|
|
* @method
|
|
* @returns {Object|null} { 'start': { 'x': ..., 'y': ... }, 'end': { 'x': ..., 'y': ... } }
|
|
*/
|
|
ve.ce.Surface.prototype.getSelectionRect = function () {
|
|
var sel, rect, $span, lineHeight, startRange, startOffset, endRange, endOffset, focusedOffset;
|
|
|
|
if ( this.focusedNode ) {
|
|
focusedOffset = this.focusedNode.$element.offset();
|
|
return {
|
|
'start': {
|
|
'x': focusedOffset.left,
|
|
'y': focusedOffset.top
|
|
},
|
|
'end': {
|
|
'x': focusedOffset.left + this.focusedNode.$element.width(),
|
|
'y': focusedOffset.top + this.focusedNode.$element.height()
|
|
}
|
|
};
|
|
}
|
|
|
|
if ( !rangy.initialized ) {
|
|
rangy.init();
|
|
}
|
|
|
|
sel = rangy.getSelection( this.getElementDocument() );
|
|
|
|
// We can't do anything if there's no selection
|
|
if ( sel.rangeCount === 0 ) {
|
|
return null;
|
|
}
|
|
|
|
rect = sel.getBoundingDocumentRect();
|
|
|
|
// Sometimes the selection will have invalid bounding rect information, which presents as all
|
|
// rectangle dimensions being 0 which causes #getStartDocumentPos and #getEndDocumentPos to
|
|
// throw exceptions
|
|
if ( rect.top === 0 || rect.bottom === 0 || rect.left === 0 || rect.right === 0 ) {
|
|
// Calculate starting range position
|
|
startRange = sel.getRangeAt( 0 );
|
|
$span = this.$( '<span>|</span>', startRange.startContainer.ownerDocument );
|
|
startRange.insertNode( $span[0] );
|
|
startOffset = $span.offset();
|
|
$span.detach();
|
|
|
|
// Calculate ending range position
|
|
endRange = startRange.cloneRange();
|
|
endRange.collapse( false );
|
|
endRange.insertNode( $span[0] );
|
|
endOffset = $span.offset();
|
|
lineHeight = $span.height();
|
|
$span.detach();
|
|
|
|
// Restore the selection
|
|
startRange.refresh();
|
|
|
|
// Return the selection bounding rectangle
|
|
return {
|
|
'start': {
|
|
'x': startOffset.left,
|
|
'y': startOffset.top
|
|
},
|
|
'end': {
|
|
'x': endOffset.left,
|
|
// Adjust the vertical position by the line-height to get the bottom dimension
|
|
'y': endOffset.top + lineHeight
|
|
}
|
|
};
|
|
} else {
|
|
return {
|
|
'start': sel.getStartDocumentPos(),
|
|
'end': sel.getEndDocumentPos()
|
|
};
|
|
}
|
|
};
|
|
|
|
/*! Initialization */
|
|
|
|
/**
|
|
* Initialize surface.
|
|
*
|
|
* This should be called after the surface has been attached to the DOM.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.ce.Surface.prototype.initialize = function () {
|
|
if ( !rangy.initialized ) {
|
|
rangy.init();
|
|
}
|
|
this.documentView.getDocumentNode().setLive( true );
|
|
// Turn off native object editing. This must be tried after the surface has been added to DOM.
|
|
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();
|
|
};
|
|
|
|
/**
|
|
* Destroy the surface, removing all DOM elements.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.ce.Surface.prototype.destroy = function () {
|
|
this.surfaceObserver.detach();
|
|
this.documentView.getDocumentNode().setLive( false );
|
|
this.$element.remove();
|
|
this.$phantoms.remove();
|
|
};
|
|
|
|
/**
|
|
* Give focus to the surface, reapplying the model selection.
|
|
*
|
|
* This is used when switching between surfaces, e.g. when closing a dialog window.
|
|
*
|
|
* If the surface is already focused, this does nothing. In particular, the selection won't be
|
|
* reapplied.
|
|
*/
|
|
ve.ce.Surface.prototype.focus = function () {
|
|
// Focus the documentNode for text selections, or the pasteTarget for focusedNode selections
|
|
if ( this.focusedNode ) {
|
|
this.$pasteTarget[0].focus();
|
|
} else {
|
|
this.documentView.getDocumentNode().$element[0].focus();
|
|
}
|
|
// documentOnFocus takes care of the rest
|
|
};
|
|
|
|
/*! Native Browser Events */
|
|
|
|
/**
|
|
* Handle document focus events.
|
|
*
|
|
* @method
|
|
* @param {Event} e Focus event (native event, NOT a jQuery event!)
|
|
* @fires focus
|
|
*/
|
|
ve.ce.Surface.prototype.documentOnFocus = function ( e ) {
|
|
if ( e.target === this.documentView.getDocumentNode().$element[0] && !this.focusedNode ) {
|
|
// The document node was focused (as opposed to the paste target)
|
|
// Restore the selection
|
|
this.onModelSelect( this.surface.getModel().getSelection() );
|
|
}
|
|
this.eventSequencer.attach( this.$element );
|
|
this.surfaceObserver.startTimerLoop();
|
|
this.emit( 'focus' );
|
|
};
|
|
|
|
/**
|
|
* Handle document blur events.
|
|
*
|
|
* @method
|
|
* @param {Event} e Blur event (native event, NOT a jQuery event!)
|
|
* @fires blur
|
|
*/
|
|
ve.ce.Surface.prototype.documentOnBlur = function () {
|
|
this.eventSequencer.detach();
|
|
this.surfaceObserver.stopTimerLoop();
|
|
this.surfaceObserver.pollOnce();
|
|
this.dragging = false;
|
|
this.emit( 'blur' );
|
|
};
|
|
|
|
/**
|
|
* Handle document mouse down events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Mouse down event
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentMouseDown = function ( e ) {
|
|
var selection, node;
|
|
|
|
// Remember the mouse is down
|
|
this.dragging = true;
|
|
|
|
// Old code to figure out if user clicked inside the document or not - leave it here for now
|
|
// this.$( e.target ).closest( '.ve-ce-documentNode' ).length === 0
|
|
|
|
if ( e.which === 1 ) {
|
|
this.surfaceObserver.stopTimerLoop();
|
|
// TODO: guard with incRenderLock?
|
|
this.surfaceObserver.pollOnce();
|
|
}
|
|
|
|
// Handle triple click
|
|
if ( this.getClickCount( e.originalEvent ) >= 3 ) {
|
|
// Browser default behaviour for triple click won't behave as we want
|
|
e.preventDefault();
|
|
|
|
selection = this.model.getSelection();
|
|
node = this.documentView.getDocumentNode().getNodeFromOffset( selection.start );
|
|
// Find the nearest non-content node
|
|
while ( node.parent !== null && node.model.isContent() ) {
|
|
node = node.parent;
|
|
}
|
|
this.model.setSelection( node.model.getRange() );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle document mouse up events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Mouse up event
|
|
* @fires selectionEnd
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentMouseUp = function ( e ) {
|
|
this.surfaceObserver.startTimerLoop();
|
|
// TODO: guard with incRenderLock?
|
|
this.surfaceObserver.pollOnce();
|
|
if ( !e.shiftKey && this.selecting ) {
|
|
this.emit( 'selectionEnd' );
|
|
this.selecting = false;
|
|
}
|
|
this.dragging = false;
|
|
};
|
|
|
|
/**
|
|
* Handle document mouse move events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Mouse move event
|
|
* @fires selectionStart
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentMouseMove = function () {
|
|
// Detect beginning of selection by moving mouse while dragging
|
|
if ( this.dragging && !this.selecting ) {
|
|
this.selecting = true;
|
|
this.emit( 'selectionStart' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle document dragover events.
|
|
*
|
|
* Limits native drag and drop behavior.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Drag over event
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentDragOver = function () {
|
|
if ( !this.relocating ) {
|
|
return false;
|
|
} else if ( this.selecting ) {
|
|
this.emit( 'selectionEnd' );
|
|
this.selecting = false;
|
|
this.dragging = false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle document drop events.
|
|
*
|
|
* Limits native drag and drop behavior.
|
|
*
|
|
* TODO: Look into using drag and drop data transfer to embed the dragged element's original range
|
|
* (for dragging within one document) and serialized linear model data (for dragging between
|
|
* multiple documents) and use a special mimetype, like application-x/VisualEditor, to allow
|
|
* dragover and drop events on the surface, removing the need to give the surface explicit
|
|
* instructions to allow and prevent dragging and dropping a certain node.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Drag drop event
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentDrop = function ( e ) {
|
|
var node = this.relocating;
|
|
|
|
if ( node ) {
|
|
// Process drop operation after native drop has been prevented below
|
|
setTimeout( ve.bind( function () {
|
|
var dropPoint, nodeData, originFragment, targetFragment,
|
|
nodeRange = node.getModel().getOuterRange();
|
|
|
|
// Get a fragment from the drop point
|
|
dropPoint = rangy.positionFromPoint(
|
|
e.originalEvent.pageX - this.$document.scrollLeft(),
|
|
e.originalEvent.pageY - this.$document.scrollTop()
|
|
);
|
|
if ( !dropPoint ) {
|
|
// Getting position from point supported
|
|
return false;
|
|
}
|
|
targetFragment = this.model.getFragment(
|
|
new ve.Range( ve.ce.getOffset( dropPoint.node, dropPoint.offset ) ), false
|
|
);
|
|
|
|
// Get a fragment and data of the node being dragged
|
|
originFragment = this.model.getFragment( nodeRange, false );
|
|
nodeData = originFragment.getData();
|
|
|
|
// Remove node from old location (auto-updates targetFragment's range)
|
|
originFragment.removeContent();
|
|
|
|
// Re-insert node at new location and re-select it
|
|
targetFragment.insertContent( nodeData ).select();
|
|
}, this ) );
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Handle document key down events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Key down event
|
|
* @fires selectionStart
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentKeyDown = function ( e ) {
|
|
var trigger,
|
|
updateFromModel = false;
|
|
|
|
// Ignore keydowns while in IME mode but do not preventDefault them (so text actually appear on
|
|
// the screen).
|
|
if ( this.inIme === true ) {
|
|
return;
|
|
}
|
|
|
|
// When entering IME mode IE first keydown (e.which = 229) before it fires compositionstart, so
|
|
// IME detection have to happen here instead of onDocumentCompositionStart.
|
|
// TODO: This code and code in onDocumentCompositionStart are very similar, consider moving them
|
|
// to one method.
|
|
if ( $.browser.msie === true && e.which === 229 ) {
|
|
this.inIme = true;
|
|
this.handleInsertion();
|
|
return;
|
|
}
|
|
|
|
this.surfaceObserver.stopTimerLoop();
|
|
this.incRenderLock();
|
|
try {
|
|
// TODO: is this correct?
|
|
this.surfaceObserver.pollOnce();
|
|
} finally {
|
|
this.decRenderLock();
|
|
}
|
|
switch ( e.keyCode ) {
|
|
case OO.ui.Keys.LEFT:
|
|
case OO.ui.Keys.RIGHT:
|
|
case OO.ui.Keys.UP:
|
|
case OO.ui.Keys.DOWN:
|
|
if ( !this.dragging && !this.selecting && e.shiftKey ) {
|
|
this.selecting = true;
|
|
this.emit( 'selectionStart' );
|
|
}
|
|
if ( ve.ce.isLeftOrRightArrowKey( e.keyCode ) ) {
|
|
this.handleLeftOrRightArrowKey( e );
|
|
} else {
|
|
this.handleUpOrDownArrowKey( e );
|
|
updateFromModel = true;
|
|
}
|
|
break;
|
|
case OO.ui.Keys.ENTER:
|
|
e.preventDefault();
|
|
this.handleEnter( e );
|
|
updateFromModel = true;
|
|
break;
|
|
case OO.ui.Keys.BACKSPACE:
|
|
e.preventDefault();
|
|
this.handleDelete( e, true );
|
|
updateFromModel = true;
|
|
break;
|
|
case OO.ui.Keys.DELETE:
|
|
e.preventDefault();
|
|
this.handleDelete( e, false );
|
|
updateFromModel = true;
|
|
break;
|
|
default:
|
|
trigger = new ve.ui.Trigger( e );
|
|
if ( trigger.isComplete() && this.surface.execute( trigger ) ) {
|
|
e.preventDefault();
|
|
updateFromModel = true;
|
|
}
|
|
break;
|
|
}
|
|
if ( !updateFromModel ) {
|
|
this.incRenderLock();
|
|
}
|
|
try {
|
|
this.surfaceObserver.pollOnce();
|
|
} finally {
|
|
if ( !updateFromModel ) {
|
|
this.decRenderLock();
|
|
}
|
|
}
|
|
this.surfaceObserver.startTimerLoop();
|
|
};
|
|
|
|
/**
|
|
* Handle document key press events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Key press event
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentKeyPress = function ( e ) {
|
|
var selection, prevNode, documentModel = this.model.getDocument();
|
|
|
|
// Prevent IE from editing Aliens/Entities
|
|
// This is for cases like <p><div>alien</div></p>, to put the cursor outside
|
|
// the alien tag.
|
|
if ( $.browser.msie === true ) {
|
|
selection = this.model.getSelection();
|
|
if ( selection.start !== 0 && selection.isCollapsed() ) {
|
|
prevNode = documentModel.getDocumentNode().getNodeFromOffset( selection.start - 1 );
|
|
if (
|
|
!this.documentView.getSlugAtOffset( selection.start ) &&
|
|
prevNode.isContent() &&
|
|
documentModel.data.isCloseElementData( selection.start - 1 )
|
|
) {
|
|
this.model.setSelection( new ve.Range( selection.start ) );
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter out non-character keys. If those keys wouldn't be filtered out unexpected content
|
|
// deletion would occur in case when selection is not collapsed and user press home key for
|
|
// instance (Firefox fires keypress for home key).
|
|
// TODO: Should be covered with Selenium tests.
|
|
if ( e.which === 0 || e.charCode === 0 || ve.ce.isShortcutKey( e ) ) {
|
|
return;
|
|
}
|
|
|
|
this.handleInsertion();
|
|
};
|
|
|
|
/**
|
|
* Poll again after the native key press
|
|
* @param {jQuery.Event} ev
|
|
*/
|
|
ve.ce.Surface.prototype.afterDocumentKeyPress = function () {
|
|
this.surfaceObserver.pollOnce();
|
|
};
|
|
|
|
/**
|
|
* Handle document key up events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Key up event
|
|
* @fires selectionEnd
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentKeyUp = function ( e ) {
|
|
// Detect end of selecting by letting go of shift
|
|
if ( !this.dragging && this.selecting && e.keyCode === OO.ui.Keys.SHIFT ) {
|
|
this.selecting = false;
|
|
this.emit( 'selectionEnd' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle cut events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Cut event
|
|
*/
|
|
ve.ce.Surface.prototype.onCut = function ( e ) {
|
|
// TODO: no pollOnce here: but should we add one?
|
|
this.surfaceObserver.stopTimerLoop();
|
|
this.onCopy( e );
|
|
setTimeout( ve.bind( function () {
|
|
var selection, tx;
|
|
|
|
// We don't like how browsers cut, so let's undo it and do it ourselves.
|
|
this.$document[0].execCommand( 'undo', false, false );
|
|
selection = this.model.getSelection();
|
|
|
|
// Transact
|
|
tx = ve.dm.Transaction.newFromRemoval( this.documentView.model, selection );
|
|
|
|
// Document may not have had real focus (e.g. with a FocusableNode)
|
|
this.documentView.getDocumentNode().$element[0].focus();
|
|
|
|
this.model.change( tx, new ve.Range( selection.start ) );
|
|
this.surfaceObserver.clear();
|
|
this.surfaceObserver.startTimerLoop();
|
|
this.surfaceObserver.pollOnce();
|
|
}, this ) );
|
|
};
|
|
|
|
/**
|
|
* Handle copy events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Copy event
|
|
*/
|
|
ve.ce.Surface.prototype.onCopy = function ( e ) {
|
|
var rangyRange, sel, originalRange,
|
|
clipboardIndex, clipboardItem, pasteData,
|
|
scrollTop,
|
|
view = this,
|
|
slice = this.model.documentModel.cloneSliceFromRange( this.model.getSelection() ),
|
|
clipboardData = e.originalEvent.clipboardData,
|
|
$window = this.$( OO.ui.Element.getWindow( this.$.context ) );
|
|
|
|
this.$pasteTarget.empty();
|
|
|
|
pasteData = slice.data.clone();
|
|
|
|
ve.dm.converter.store = slice.getStore();
|
|
ve.dm.converter.internalList = slice.getInternalList();
|
|
ve.dm.converter.getDomSubtreeFromData( slice.getData(), this.$pasteTarget[0] );
|
|
|
|
// 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' );
|
|
|
|
// Clone the elements in the slice, but only after the DM HTML has been built
|
|
slice.data.cloneElements();
|
|
|
|
clipboardItem = { 'slice': slice, 'hash': null };
|
|
clipboardIndex = this.clipboard.push( clipboardItem ) - 1;
|
|
|
|
// Check we have setData and that it actually works (returns true)
|
|
if (
|
|
clipboardData && clipboardData.setData &&
|
|
clipboardData.setData( 'text/xcustom', '' ) &&
|
|
clipboardData.setData( 'text/html', '' )
|
|
) {
|
|
// Webkit allows us to directly edit the clipboard
|
|
// Disable the default event so we can override the data
|
|
e.preventDefault();
|
|
clipboardData.setData( 'text/xcustom', this.clipboardId + '-' + clipboardIndex );
|
|
// As we've disabled the default event we need to set the normal clipboard data
|
|
clipboardData.setData( 'text/html', this.$pasteTarget.html() );
|
|
clipboardData.setData( 'text/plain', this.$pasteTarget.text() );
|
|
} else {
|
|
clipboardItem.hash = this.constructor.static.getClipboardHash( this.$pasteTarget.contents() );
|
|
this.$pasteTarget.prepend(
|
|
this.$( '<span>' ).attr( 'data-ve-clipboard-key', this.clipboardId + '-' + clipboardIndex )
|
|
);
|
|
// If direct clipboard editing is not allowed, we must use the pasteTarget to
|
|
// select the data we want to go in the clipboard
|
|
rangyRange = rangy.createRange( this.getElementDocument() );
|
|
rangyRange.setStart( this.$pasteTarget[0], 0 );
|
|
rangyRange.setEnd( this.$pasteTarget[0], this.$pasteTarget[0].childNodes.length );
|
|
|
|
// Save scroll position before changing focus to "offscreen" paste target
|
|
scrollTop = $window.scrollTop();
|
|
|
|
sel = rangy.getSelection( this.getElementDocument() );
|
|
originalRange = sel.getRangeAt( 0 ).cloneRange();
|
|
sel.removeAllRanges();
|
|
this.$pasteTarget[0].focus();
|
|
sel.addRange( rangyRange, false );
|
|
|
|
setTimeout( function () {
|
|
sel = rangy.getSelection( view.getElementDocument() );
|
|
sel.removeAllRanges();
|
|
view.documentView.getDocumentNode().$element[0].focus();
|
|
sel.addRange( originalRange );
|
|
|
|
$window.scrollTop( scrollTop );
|
|
} );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle native paste event
|
|
*
|
|
* @param {jQuery.Event} e Paste event
|
|
*/
|
|
ve.ce.Surface.prototype.onPaste = function ( e ) {
|
|
// Prevent pasting until after we are done
|
|
if ( this.pasting ) {
|
|
return false;
|
|
}
|
|
this.pasting = true;
|
|
this.beforePaste( e );
|
|
setTimeout( ve.bind( function () {
|
|
this.afterPaste( e );
|
|
|
|
// Allow pasting again
|
|
this.pasting = false;
|
|
this.pasteSpecial = false;
|
|
this.beforePasteData = null;
|
|
}, this ) );
|
|
};
|
|
|
|
/**
|
|
* Handle pre-paste events.
|
|
*
|
|
* @param {jQuery.Event} e Paste event
|
|
*/
|
|
ve.ce.Surface.prototype.beforePaste = function ( e ) {
|
|
var tx, node, range, rangyRange, sel,
|
|
context, leftText, rightText, textNode, textStart, textEnd,
|
|
$window = this.$( OO.ui.Element.getWindow( this.$.context ) ),
|
|
selection = this.model.getSelection(),
|
|
clipboardData = e.originalEvent.clipboardData,
|
|
doc = this.model.documentModel;
|
|
|
|
this.beforePasteData = {};
|
|
if ( clipboardData ) {
|
|
this.beforePasteData.custom = clipboardData.getData( 'text/xcustom' );
|
|
this.beforePasteData.html = clipboardData.getData( 'text/html' );
|
|
}
|
|
|
|
// TODO: no pollOnce here: but should we add one?
|
|
this.surfaceObserver.stopTimerLoop();
|
|
|
|
// Pasting into a range? Remove first.
|
|
if ( !rangy.getSelection( this.$document[0] ).isCollapsed ) {
|
|
tx = ve.dm.Transaction.newFromRemoval( doc, selection );
|
|
selection = tx.translateRange( selection );
|
|
this.model.change( tx, selection );
|
|
selection = this.model.getSelection();
|
|
}
|
|
|
|
// Save scroll position before changing focus to "offscreen" paste target
|
|
this.beforePasteData.scrollTop = $window.scrollTop();
|
|
|
|
this.$pasteTarget.empty();
|
|
|
|
// Get node from cursor position
|
|
node = doc.getNodeFromOffset( selection.start );
|
|
if ( node.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;
|
|
range = node.getRange();
|
|
context = [ node.getClonedElement() ];
|
|
// If there is content to the left of the cursor, put a placeholder
|
|
// character to the left of the cursor
|
|
if ( selection.start > range.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
|
|
if ( selection.end < range.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 } );
|
|
|
|
ve.dm.converter.store = doc.getStore();
|
|
ve.dm.converter.internalList = doc.getInternalList();
|
|
ve.dm.converter.getDomSubtreeFromData( context, this.$pasteTarget[0] );
|
|
|
|
// Giving the paste target focus too late can cause problems in FF (!?)
|
|
// so do it up here.
|
|
this.$pasteTarget[0].focus();
|
|
|
|
rangyRange = rangy.createRange( this.getElementDocument() );
|
|
// Assume that the DM node only generated one child
|
|
textNode = this.$pasteTarget.children().contents()[0];
|
|
// Place the cursor between the placeholder characters
|
|
rangyRange.setStart( textNode, textStart );
|
|
rangyRange.setEnd( textNode, textEnd );
|
|
sel = rangy.getSelection( this.getElementDocument() );
|
|
sel.removeAllRanges();
|
|
sel.addRange( rangyRange, false );
|
|
|
|
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();
|
|
}
|
|
|
|
};
|
|
|
|
/**
|
|
* Handle post-paste events.
|
|
*
|
|
* @param {jQuery.Event} e Paste event
|
|
*/
|
|
ve.ce.Surface.prototype.afterPaste = function () {
|
|
var clipboardKey, clipboardId, clipboardIndex,
|
|
$elements, parts, pasteData, slice, tx, internalListRange,
|
|
data, doc, htmlDoc,
|
|
context, left, right, contextRange,
|
|
beforePasteData = this.beforePasteData || {},
|
|
$window = this.$( OO.ui.Element.getWindow( this.$.context ) ),
|
|
selection = this.model.getSelection();
|
|
|
|
// If the selection doesn't collapse after paste then nothing was inserted
|
|
if ( !rangy.getSelection( this.getElementDocument() ).isCollapsed ) {
|
|
return;
|
|
}
|
|
|
|
// Remove the pasteProtect class. See #onCopy.
|
|
this.$pasteTarget.find( 'span' ).removeClass( 've-pasteProtect' );
|
|
|
|
// Find the clipboard key
|
|
if ( beforePasteData.custom ) {
|
|
clipboardKey = beforePasteData.custom;
|
|
} else {
|
|
$elements = beforePasteData.html ? this.$( $.parseHTML( beforePasteData.html ) ) : this.$pasteTarget.contents();
|
|
|
|
// 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;
|
|
} );
|
|
}
|
|
|
|
// If we have a clipboard key, validate it and fetch data
|
|
if ( clipboardKey ) {
|
|
parts = clipboardKey.split( '-' );
|
|
clipboardId = parts[0];
|
|
clipboardIndex = parts[1];
|
|
if ( clipboardId === this.clipboardId && this.clipboard[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 ||
|
|
this.clipboard[clipboardIndex].hash ===
|
|
this.constructor.static.getClipboardHash( $elements )
|
|
) {
|
|
slice = this.clipboard[clipboardIndex].slice;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( slice ) {
|
|
// Internal paste
|
|
try {
|
|
// Try to paste in the orignal 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 ) {
|
|
pasteData.sanitize( this.getSurface().getPasteRules(), true );
|
|
}
|
|
|
|
// Annotate
|
|
ve.dm.Document.static.addAnnotationsToData( pasteData.getData(), this.model.getInsertionAnnotations() );
|
|
|
|
// Transaction
|
|
tx = ve.dm.Transaction.newFromInsertion(
|
|
this.documentView.model,
|
|
selection.start,
|
|
pasteData.getData()
|
|
);
|
|
} 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 ) {
|
|
pasteData.sanitize( this.getSurface().getPasteRules(), true );
|
|
}
|
|
|
|
// Annotate
|
|
ve.dm.Document.static.addAnnotationsToData( pasteData.getData(), this.model.getInsertionAnnotations() );
|
|
|
|
// Transaction
|
|
tx = ve.dm.Transaction.newFromInsertion(
|
|
this.documentView.model,
|
|
selection.start,
|
|
pasteData.getData()
|
|
);
|
|
}
|
|
} 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 = this.$( $.parseHTML( beforePasteData.html ) );
|
|
}
|
|
if (
|
|
$elements.filter( 'span[id],span[typeof],span[rel]' ).length > 0 &&
|
|
this.$pasteTarget.filter('span[id],span[typeof],span[rel]').length === 0
|
|
) {
|
|
// CE destroyed an important span, 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 unwapping
|
|
// in edge cases (e.g. pasting a single MWReference)
|
|
htmlDoc = ve.createDocumentFromHtml( this.$pasteTarget.html() );
|
|
}
|
|
// External paste
|
|
doc = ve.dm.converter.getModelFromDom( htmlDoc );
|
|
data = doc.data;
|
|
// Clear metadata
|
|
doc.metadata = new ve.dm.MetaLinearData( doc.getStore(), new Array( 1 + data.getLength() ) );
|
|
// If the clipboardKey is set (paste from other VE instance), and it's a non-special paste, skip sanitization
|
|
if ( !clipboardKey || this.pasteSpecial ) {
|
|
data.sanitize( this.getSurface().getPasteRules(), this.pasteSpecial );
|
|
} else {
|
|
// ...except not quite - contentEditable can't be trusted not
|
|
// to add styles, so for now remove them
|
|
// TODO: store original styles in data
|
|
data.sanitize( { 'removeStyles': true } );
|
|
}
|
|
data.remapInternalListKeys( this.model.getDocument().getInternalList() );
|
|
|
|
// Initialize node tree
|
|
doc.buildNodeTree();
|
|
|
|
// If the paste was given context, calculate the range of the inserted data
|
|
if ( beforePasteData.context ) {
|
|
internalListRange = doc.getInternalList().getListNode().getOuterRange();
|
|
context = new ve.dm.ElementLinearData(
|
|
doc.getStore(),
|
|
ve.copy( beforePasteData.context )
|
|
);
|
|
if ( this.pasteSpecial ) {
|
|
// The context may have been sanitized, so sanitize here as well for comparison
|
|
context.sanitize( this.getSurface().getPasteRules(), this.pasteSpecial, true );
|
|
}
|
|
|
|
// Remove matching context from the left
|
|
left = 0;
|
|
while (
|
|
context.getLength() &&
|
|
ve.dm.ElementLinearData.static.compareUnannotated(
|
|
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 (
|
|
context.getLength() &&
|
|
ve.dm.ElementLinearData.static.compareUnannotated(
|
|
data.getData( right - 1 ),
|
|
data.isElementData( right - 1 ) ? context.getData( context.getLength() - 1 ) : beforePasteData.rightText
|
|
)
|
|
) {
|
|
right--;
|
|
context.splice( context.getLength() - 1, 1 );
|
|
}
|
|
contextRange = new ve.Range( left, right );
|
|
}
|
|
|
|
tx = ve.dm.Transaction.newFromDocumentInsertion(
|
|
this.documentView.model,
|
|
selection.start,
|
|
doc,
|
|
contextRange
|
|
);
|
|
}
|
|
|
|
// Restore focus and scroll position
|
|
this.documentView.getDocumentNode().$element[0].focus();
|
|
$window.scrollTop( beforePasteData.scrollTop );
|
|
|
|
selection = tx.translateRange( selection );
|
|
this.model.change( tx, new ve.Range( selection.start ) );
|
|
// Move cursor to end of selection
|
|
this.model.setSelection( new ve.Range( selection.end ) );
|
|
};
|
|
|
|
/**
|
|
* Handle document composition start events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Composition start event
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentCompositionStart = function () {
|
|
if ( $.browser.msie === true ) {
|
|
return;
|
|
}
|
|
this.inIme = true;
|
|
this.handleInsertion();
|
|
};
|
|
|
|
/**
|
|
* Handle document composition end events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Composition end event
|
|
*/
|
|
ve.ce.Surface.prototype.onDocumentCompositionEnd = function () {
|
|
this.inIme = false;
|
|
this.incRenderLock();
|
|
try {
|
|
this.surfaceObserver.pollOnce();
|
|
} finally {
|
|
this.decRenderLock();
|
|
}
|
|
this.surfaceObserver.startTimerLoop();
|
|
};
|
|
|
|
/*! Custom Events */
|
|
|
|
/**
|
|
* Handle model select events.
|
|
*
|
|
* @see ve.dm.Surface#method-change
|
|
*
|
|
* @method
|
|
* @param {ve.Range} selection
|
|
*/
|
|
ve.ce.Surface.prototype.onModelSelect = function ( selection ) {
|
|
var start, end, rangySel, rangyRange,
|
|
next = null,
|
|
previous = this.focusedNode;
|
|
|
|
this.contentBranchNodeChanged = false;
|
|
|
|
// Detect when only a single inline element is selected
|
|
if ( !selection.isCollapsed() ) {
|
|
start = this.documentView.getDocumentNode().getNodeFromOffset( selection.start + 1 );
|
|
if ( start.isFocusable() ) {
|
|
end = this.documentView.getDocumentNode().getNodeFromOffset( selection.end - 1 );
|
|
if ( start === end ) {
|
|
next = start;
|
|
}
|
|
}
|
|
} else {
|
|
// Check we haven't been programmatically placed inside a focusable node with a collapsed selection
|
|
start = this.documentView.getDocumentNode().getNodeFromOffset( selection.start );
|
|
if ( start.isFocusable() ) {
|
|
next = start;
|
|
}
|
|
}
|
|
// Update nodes
|
|
// Even update this if previous === next, because this function is called by the focus handler
|
|
// to restore a lost selection state
|
|
if ( previous ) {
|
|
previous.setFocused( false );
|
|
this.focusedNode = null;
|
|
if ( !next ) {
|
|
// If the selection is moving from a focusable node (in the paste target) back
|
|
// to a normal selection (in the document node), give the focus back to the
|
|
// document node.
|
|
this.documentView.getDocumentNode().$element[0].focus();
|
|
}
|
|
}
|
|
if ( next ) {
|
|
next.setFocused( true );
|
|
this.focusedNode = start;
|
|
// As FF won't fire a copy event with nothing selected, make
|
|
// a dummy selection of one space in the pasteTarget.
|
|
// onCopy will ignore this native selection and use the DM selection
|
|
this.$pasteTarget.text( ' ' );
|
|
rangySel = rangy.getSelection( this.getElementDocument() );
|
|
rangyRange = rangy.createRange( this.getElementDocument() );
|
|
rangyRange.setStart( this.$pasteTarget[0], 0 );
|
|
rangyRange.setEnd( this.$pasteTarget[0], 1 );
|
|
rangySel.removeAllRanges();
|
|
this.$pasteTarget[0].focus();
|
|
rangySel.addRange( rangyRange, false );
|
|
// 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 there is no focused node, use native selection, but 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.focusedNode && !this.isRenderingLocked() && selection !== this.newModelSelection ) {
|
|
this.showSelection( selection );
|
|
}
|
|
|
|
// Update the selection state in the SurfaceObserver
|
|
this.surfaceObserver.pollOnceNoEmit();
|
|
};
|
|
|
|
/**
|
|
* Handle documentUpdate events on the surface model.
|
|
* @param {ve.dm.Transaction} transaction Transaction that was processed
|
|
*/
|
|
ve.ce.Surface.prototype.onModelDocumentUpdate = function () {
|
|
if ( this.contentBranchNodeChanged ) {
|
|
// Update the selection state from model
|
|
this.onModelSelect( this.surface.getModel().selection );
|
|
}
|
|
// Update the state of the SurfaceObserver
|
|
this.surfaceObserver.pollOnceNoEmit();
|
|
};
|
|
|
|
/**
|
|
* Handle selection change events.
|
|
*
|
|
* @see ve.ce.SurfaceObserver#pollOnce
|
|
*
|
|
* @method
|
|
* @param {ve.Range} oldRange
|
|
* @param {ve.Range} newRange
|
|
*/
|
|
ve.ce.Surface.prototype.onSelectionChange = function ( oldRange, newRange ) {
|
|
if ( oldRange && newRange.flip().equals( oldRange ) ) {
|
|
// Ignore when the newRange is just a flipped oldRange
|
|
return;
|
|
}
|
|
this.incRenderLock();
|
|
try {
|
|
this.changeModel( null, newRange );
|
|
} finally {
|
|
this.decRenderLock();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle content change events.
|
|
*
|
|
* @see ve.ce.SurfaceObserver#pollOnce
|
|
*
|
|
* @method
|
|
* @param {ve.ce.Node} node CE node the change occured in
|
|
* @param {Object} previous Old data
|
|
* @param {Object} previous.text Old plain text content
|
|
* @param {Object} previous.hash Old DOM hash
|
|
* @param {ve.Range} previous.range Old selection
|
|
* @param {Object} next New data
|
|
* @param {Object} next.text New plain text content
|
|
* @param {Object} next.hash New DOM hash
|
|
* @param {ve.Range} next.range New selection
|
|
*/
|
|
ve.ce.Surface.prototype.onContentChange = function ( node, previous, next ) {
|
|
var data, range, len, annotations, offsetDiff, lengthDiff, sameLeadingAndTrailing,
|
|
previousStart, nextStart, newRange,
|
|
previousData, nextData,
|
|
i, length, annotation, annotationIndex, dataString,
|
|
annotationsLeft, annotationsRight,
|
|
fromLeft = 0,
|
|
fromRight = 0,
|
|
nodeOffset = node.getModel().getOffset();
|
|
|
|
if ( previous.range && next.range ) {
|
|
offsetDiff = ( previous.range.isCollapsed() && next.range.isCollapsed() ) ?
|
|
next.range.start - previous.range.start : null;
|
|
lengthDiff = next.text.length - previous.text.length;
|
|
previousStart = previous.range.start - nodeOffset - 1;
|
|
nextStart = next.range.start - nodeOffset - 1;
|
|
sameLeadingAndTrailing = offsetDiff !== null && (
|
|
// TODO: rewrite to static method with tests
|
|
(
|
|
lengthDiff > 0 &&
|
|
previous.text.substring( 0, previousStart ) ===
|
|
next.text.substring( 0, previousStart ) &&
|
|
previous.text.substring( previousStart ) ===
|
|
next.text.substring( nextStart )
|
|
) ||
|
|
(
|
|
lengthDiff < 0 &&
|
|
previous.text.substring( 0, nextStart ) ===
|
|
next.text.substring( 0, nextStart ) &&
|
|
previous.text.substring( previousStart - lengthDiff + offsetDiff ) ===
|
|
next.text.substring( nextStart )
|
|
)
|
|
);
|
|
|
|
// Simple insertion
|
|
if ( lengthDiff > 0 && offsetDiff === lengthDiff /* && sameLeadingAndTrailing */) {
|
|
data = ve.splitClusters( next.text ).slice(
|
|
previous.range.start - nodeOffset - 1,
|
|
next.range.start - nodeOffset - 1
|
|
);
|
|
// Apply insertion annotations
|
|
annotations = this.model.getInsertionAnnotations();
|
|
if ( annotations instanceof ve.dm.AnnotationSet ) {
|
|
ve.dm.Document.static.addAnnotationsToData( data, this.model.getInsertionAnnotations() );
|
|
}
|
|
this.incRenderLock();
|
|
try {
|
|
this.changeModel(
|
|
ve.dm.Transaction.newFromInsertion(
|
|
this.documentView.model, previous.range.start, data
|
|
),
|
|
next.range
|
|
);
|
|
} finally {
|
|
this.decRenderLock();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Simple deletion
|
|
if ( ( offsetDiff === 0 || offsetDiff === lengthDiff ) && sameLeadingAndTrailing ) {
|
|
if ( offsetDiff === 0 ) {
|
|
range = new ve.Range( next.range.start, next.range.start - lengthDiff );
|
|
} else {
|
|
range = new ve.Range( next.range.start, previous.range.start );
|
|
}
|
|
this.incRenderLock();
|
|
try {
|
|
this.changeModel(
|
|
ve.dm.Transaction.newFromRemoval( this.documentView.model,
|
|
range ),
|
|
next.range
|
|
);
|
|
} finally {
|
|
this.decRenderLock();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Complex change
|
|
|
|
previousData = ve.splitClusters( previous.text );
|
|
nextData = ve.splitClusters( next.text );
|
|
len = Math.min( previousData.length, nextData.length );
|
|
// Count same characters from left
|
|
while ( fromLeft < len && previousData[fromLeft] === nextData[fromLeft] ) {
|
|
++fromLeft;
|
|
}
|
|
// Count same characters from right
|
|
while (
|
|
fromRight < len - fromLeft &&
|
|
previousData[previousData.length - 1 - fromRight] ===
|
|
nextData[nextData.length - 1 - fromRight]
|
|
) {
|
|
++fromRight;
|
|
}
|
|
data = nextData.slice( fromLeft, nextData.length - fromRight );
|
|
// Get annotations to the left of new content and apply
|
|
annotations = this.model.getDocument().data.getAnnotationsFromOffset( nodeOffset + 1 + fromLeft );
|
|
if ( annotations.getLength() ) {
|
|
annotationsLeft = this.model.getDocument().data.getAnnotationsFromOffset( nodeOffset + fromLeft );
|
|
annotationsRight = this.model.getDocument().data.getAnnotationsFromOffset( nodeOffset + 1 + previousData.length - fromRight );
|
|
for ( i = 0, length = annotations.getLength(); i < length; i++ ) {
|
|
annotation = annotations.get( i );
|
|
annotationIndex = annotations.getIndex( i );
|
|
if ( annotation.constructor.static.splitOnWordbreak ) {
|
|
dataString = new ve.dm.DataString( nextData );
|
|
if (
|
|
// if no annotation to the right, check for wordbreak
|
|
(
|
|
!annotationsRight.containsIndex( annotationIndex ) &&
|
|
unicodeJS.wordbreak.isBreak( dataString, fromLeft )
|
|
) ||
|
|
// if no annotation to the left, check for wordbreak
|
|
(
|
|
!annotationsLeft.containsIndex( annotationIndex ) &&
|
|
unicodeJS.wordbreak.isBreak( dataString, nextData.length - fromRight )
|
|
)
|
|
) {
|
|
annotations.removeAt( i );
|
|
}
|
|
}
|
|
}
|
|
ve.dm.Document.static.addAnnotationsToData( data, annotations );
|
|
}
|
|
newRange = next.range;
|
|
if ( newRange.isCollapsed() ) {
|
|
newRange = new ve.Range( this.getNearestCorrectOffset( newRange.start, 1 ) );
|
|
}
|
|
|
|
if ( data.length > 0 ) {
|
|
this.changeModel(
|
|
ve.dm.Transaction.newFromInsertion(
|
|
this.documentView.model, nodeOffset + 1 + fromLeft,
|
|
data
|
|
),
|
|
newRange
|
|
);
|
|
}
|
|
if ( fromLeft + fromRight < previousData.length ) {
|
|
this.changeModel(
|
|
ve.dm.Transaction.newFromRemoval(
|
|
this.documentView.model,
|
|
new ve.Range(
|
|
data.length + nodeOffset + 1 + fromLeft,
|
|
data.length + nodeOffset + 1 +
|
|
previousData.length - fromRight
|
|
)
|
|
),
|
|
newRange
|
|
);
|
|
}
|
|
};
|
|
|
|
/*! Relocation */
|
|
|
|
/**
|
|
* Start a relocation action.
|
|
*
|
|
* @see ve.ce.RelocatableNode
|
|
*
|
|
* @method
|
|
* @param {ve.ce.Node} node Node being relocated
|
|
*/
|
|
ve.ce.Surface.prototype.startRelocation = function ( node ) {
|
|
this.relocating = node;
|
|
this.emit( 'relocationStart', node );
|
|
};
|
|
|
|
/**
|
|
* Complete a relocation action.
|
|
*
|
|
* @see ve.ce.RelocatableNode
|
|
*
|
|
* @method
|
|
* @param {ve.ce.Node} node Node being relocated
|
|
*/
|
|
ve.ce.Surface.prototype.endRelocation = function () {
|
|
this.emit( 'relocationEnd', this.relocating );
|
|
this.relocating = null;
|
|
};
|
|
|
|
/*! Utilities */
|
|
|
|
/**
|
|
* @method
|
|
*/
|
|
ve.ce.Surface.prototype.handleLeftOrRightArrowKey = function ( e ) {
|
|
var selection, range, direction;
|
|
// On Mac OS pressing Command (metaKey) + Left/Right is same as pressing Home/End.
|
|
// As we are not able to handle it programmatically (because we don't know at which offsets
|
|
// lines starts and ends) let it happen natively.
|
|
if ( e.metaKey ) {
|
|
return;
|
|
}
|
|
|
|
// Selection is going to be displayed programmatically so prevent default browser behaviour
|
|
e.preventDefault();
|
|
// TODO: onDocumentKeyDown did this already
|
|
this.surfaceObserver.stopTimerLoop();
|
|
this.incRenderLock();
|
|
try {
|
|
// TODO: onDocumentKeyDown did this already
|
|
this.surfaceObserver.pollOnce();
|
|
} finally {
|
|
this.decRenderLock();
|
|
}
|
|
selection = this.model.getSelection();
|
|
if ( this.$( e.target ).css( 'direction' ) === 'rtl' ) {
|
|
// If the language direction is RTL, switch left/right directions:
|
|
direction = e.keyCode === OO.ui.Keys.LEFT ? 1 : -1;
|
|
} else {
|
|
direction = e.keyCode === OO.ui.Keys.LEFT ? -1 : 1;
|
|
}
|
|
|
|
range = this.getDocument().getRelativeRange(
|
|
selection,
|
|
direction,
|
|
( e.altKey === true || e.ctrlKey === true ) ? 'word' : 'character',
|
|
e.shiftKey
|
|
);
|
|
this.model.setSelection( range );
|
|
// TODO: onDocumentKeyDown does this anyway
|
|
this.surfaceObserver.startTimerLoop();
|
|
this.surfaceObserver.pollOnce();
|
|
};
|
|
|
|
/**
|
|
* @method
|
|
*/
|
|
ve.ce.Surface.prototype.handleUpOrDownArrowKey = function ( e ) {
|
|
var selection, rangySelection, rangyRange, range, $element, nativeSel;
|
|
if ( !$.browser.msie ) {
|
|
// Firefox doesn't update its internal reference of the appropriate cursor position
|
|
// on the next or previous lines when the cursor is moved programmatically.
|
|
// By wiggling the selection, Firefox scraps its internal reference.
|
|
nativeSel = window.getSelection();
|
|
nativeSel.modify( 'extend', 'right', 'character' );
|
|
nativeSel.modify( 'extend', 'left', 'character' );
|
|
return;
|
|
}
|
|
// TODO: onDocumentKeyDown did this already
|
|
this.surfaceObserver.stopTimerLoop();
|
|
// TODO: onDocumentKeyDown did this already
|
|
this.surfaceObserver.pollOnce();
|
|
|
|
selection = this.model.getSelection();
|
|
rangySelection = rangy.getSelection( this.$document[0] );
|
|
// Perform programatic handling only for selection that is expanded and backwards according to
|
|
// model data but not according to browser data.
|
|
if ( !selection.isCollapsed() && selection.isBackwards() && !rangySelection.isBackwards() ) {
|
|
$element = this.$( this.documentView.getSlugAtOffset( selection.to ) );
|
|
if ( !$element ) {
|
|
$element = this.$( '<span>' )
|
|
.html( ' ' )
|
|
.css( { 'width': '0px', 'display': 'none' } );
|
|
rangySelection.anchorNode.splitText( rangySelection.anchorOffset );
|
|
rangySelection.anchorNode.parentNode.insertBefore(
|
|
$element[0],
|
|
rangySelection.anchorNode.nextSibling
|
|
);
|
|
}
|
|
rangyRange = rangy.createRange( this.$document[0] );
|
|
rangyRange.selectNode( $element[0] );
|
|
rangySelection.setSingleRange( rangyRange );
|
|
setTimeout( ve.bind( function () {
|
|
if ( !$element.hasClass( 've-ce-branchNode-slug' ) ) {
|
|
$element.remove();
|
|
}
|
|
this.surfaceObserver.pollOnce();
|
|
if ( e.shiftKey === true ) { // expanded range
|
|
range = new ve.Range( selection.from, this.model.getSelection().to );
|
|
} else { // collapsed range (just a cursor)
|
|
range = new ve.Range( this.model.getSelection().to );
|
|
}
|
|
this.model.setSelection( range );
|
|
this.surfaceObserver.pollOnce();
|
|
}, this ), 0 );
|
|
} else {
|
|
// TODO: onDocumentKeyDown does this anyway
|
|
this.surfaceObserver.startTimerLoop();
|
|
|
|
this.surfaceObserver.pollOnce();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle insertion of content.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.ce.Surface.prototype.handleInsertion = function () {
|
|
var slug, data, range, annotations, insertionAnnotations, placeholder,
|
|
selection = this.model.getSelection(), documentModel = this.model.getDocument();
|
|
|
|
// Handles removing expanded selection before inserting new text
|
|
if ( !selection.isCollapsed() ) {
|
|
// Pull annotations from the first character in the selection
|
|
annotations = documentModel.data.getAnnotationsFromRange(
|
|
new ve.Range( selection.start, selection.start + 1 )
|
|
);
|
|
this.model.change(
|
|
ve.dm.Transaction.newFromRemoval( this.documentView.model, selection ),
|
|
new ve.Range( selection.start )
|
|
);
|
|
this.surfaceObserver.clear();
|
|
selection = this.model.getSelection();
|
|
this.model.setInsertionAnnotations( annotations );
|
|
}
|
|
|
|
insertionAnnotations = this.model.getInsertionAnnotations() ||
|
|
new ve.dm.AnnotationSet( documentModel.getStore() );
|
|
|
|
if ( selection.isCollapsed() ) {
|
|
slug = this.documentView.getSlugAtOffset( selection.start );
|
|
// Always pawn in a slug
|
|
if ( slug || this.needsPawn( selection, insertionAnnotations ) ) {
|
|
placeholder = '♙';
|
|
if ( !insertionAnnotations.isEmpty() ) {
|
|
placeholder = [placeholder, insertionAnnotations.getIndexes()];
|
|
}
|
|
// is this a slug and if so, is this a block slug?
|
|
if ( slug && documentModel.data.isStructuralOffset( selection.start ) ) {
|
|
range = new ve.Range( selection.start + 1, selection.start + 2 );
|
|
data = [{ 'type': 'paragraph' }, placeholder, { 'type': '/paragraph' }];
|
|
} else {
|
|
range = new ve.Range( selection.start, selection.start + 1 );
|
|
data = [placeholder];
|
|
}
|
|
this.model.change(
|
|
ve.dm.Transaction.newFromInsertion(
|
|
this.documentView.model, selection.start, data
|
|
),
|
|
range
|
|
);
|
|
}
|
|
}
|
|
|
|
this.surfaceObserver.stopTimerLoop();
|
|
this.surfaceObserver.pollOnce();
|
|
};
|
|
|
|
/**
|
|
* Handle enter key down events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Enter key down event
|
|
*/
|
|
ve.ce.Surface.prototype.handleEnter = function ( e ) {
|
|
var tx, outerParent, outerChildrenCount, list,
|
|
selection = this.model.getSelection(),
|
|
documentModel = this.model.getDocument(),
|
|
emptyParagraph = [{ 'type': 'paragraph' }, { 'type': '/paragraph' }],
|
|
advanceCursor = true,
|
|
node = this.documentView.getNodeFromOffset( selection.from ),
|
|
nodeModel = node.getModel(),
|
|
cursor = selection.from,
|
|
contentBranchModel = nodeModel.isContent() ? nodeModel.getParent() : nodeModel,
|
|
contentBranchModelRange = contentBranchModel.getRange(),
|
|
stack = [],
|
|
outermostNode = null;
|
|
|
|
// Handle removal first
|
|
if ( selection.from !== selection.to ) {
|
|
tx = ve.dm.Transaction.newFromRemoval( documentModel, selection );
|
|
selection = tx.translateRange( selection );
|
|
// We do want this to propagate to the surface
|
|
this.model.change( tx, selection );
|
|
}
|
|
|
|
// Handle insertion
|
|
if (
|
|
contentBranchModel.getType() !== 'paragraph' &&
|
|
(
|
|
cursor === contentBranchModelRange.from ||
|
|
cursor === contentBranchModelRange.to
|
|
)
|
|
) {
|
|
// If we're at the start/end of something that's not a paragraph, insert a paragraph
|
|
// before/after
|
|
if ( cursor === contentBranchModelRange.from ) {
|
|
tx = ve.dm.Transaction.newFromInsertion(
|
|
documentModel, contentBranchModel.getOuterRange().from, emptyParagraph
|
|
);
|
|
advanceCursor = false;
|
|
} else if ( cursor === contentBranchModelRange.to ) {
|
|
tx = ve.dm.Transaction.newFromInsertion(
|
|
documentModel, contentBranchModel.getOuterRange().to, emptyParagraph
|
|
);
|
|
}
|
|
} else if ( e.shiftKey && contentBranchModel.hasSignificantWhitespace() ) {
|
|
// Insert newline
|
|
tx = ve.dm.Transaction.newFromInsertion( documentModel, selection.from, '\n' );
|
|
} else {
|
|
// Split
|
|
node.traverseUpstream( function ( node ) {
|
|
if ( !node.canBeSplit() ) {
|
|
return false;
|
|
}
|
|
stack.splice(
|
|
stack.length / 2,
|
|
0,
|
|
{ 'type': '/' + node.type },
|
|
node.model.getClonedElement()
|
|
);
|
|
outermostNode = node;
|
|
if ( e.shiftKey ) {
|
|
return false;
|
|
} else {
|
|
return true;
|
|
}
|
|
} );
|
|
|
|
outerParent = outermostNode.getModel().getParent();
|
|
outerChildrenCount = outerParent.getChildren().length;
|
|
|
|
if (
|
|
// This is a list item
|
|
outermostNode.type === 'listItem' &&
|
|
// This is the last list item
|
|
outerParent.getChildren()[outerChildrenCount - 1] === outermostNode.getModel() &&
|
|
// There is one child
|
|
outermostNode.children.length === 1 &&
|
|
// The child is empty
|
|
node.model.length === 0
|
|
) {
|
|
// Enter was pressed in an empty list item.
|
|
list = outermostNode.getModel().getParent();
|
|
if ( list.getChildren().length === 1 ) {
|
|
// The list item we're about to remove is the only child of the list
|
|
// Remove the list
|
|
tx = ve.dm.Transaction.newFromRemoval(
|
|
documentModel, list.getOuterRange()
|
|
);
|
|
} else {
|
|
// Remove the list item
|
|
tx = ve.dm.Transaction.newFromRemoval(
|
|
documentModel, outermostNode.getModel().getOuterRange()
|
|
);
|
|
this.model.change( tx );
|
|
selection = tx.translateRange( selection );
|
|
// Insert a paragraph
|
|
tx = ve.dm.Transaction.newFromInsertion(
|
|
documentModel, list.getOuterRange().to, emptyParagraph
|
|
);
|
|
}
|
|
advanceCursor = false;
|
|
} else {
|
|
// We must process the transaction first because getRelativeContentOffset can't help us
|
|
// yet
|
|
tx = ve.dm.Transaction.newFromInsertion( documentModel, selection.from, stack );
|
|
}
|
|
}
|
|
|
|
// Commit the transaction
|
|
this.model.change( tx );
|
|
selection = tx.translateRange( selection );
|
|
|
|
// Now we can move the cursor forward
|
|
if ( advanceCursor ) {
|
|
cursor = documentModel.data.getRelativeContentOffset( selection.from, 1 );
|
|
} else {
|
|
cursor = documentModel.data.getNearestContentOffset( selection.from );
|
|
}
|
|
if ( cursor === -1 ) {
|
|
// Cursor couldn't be placed in a nearby content node, so create an empty paragraph
|
|
this.model.change(
|
|
ve.dm.Transaction.newFromInsertion(
|
|
documentModel, selection.from, emptyParagraph
|
|
)
|
|
);
|
|
this.model.setSelection( new ve.Range( selection.from + 1 ) );
|
|
} else {
|
|
this.model.setSelection( new ve.Range( cursor ) );
|
|
}
|
|
// Reset and resume polling
|
|
this.surfaceObserver.clear();
|
|
};
|
|
|
|
/**
|
|
* Handle delete and backspace key down events.
|
|
*
|
|
* @method
|
|
* @param {jQuery.Event} e Delete key down event
|
|
* @param {boolean} backspace Key was a backspace
|
|
*/
|
|
ve.ce.Surface.prototype.handleDelete = function ( e, backspace ) {
|
|
var rangeToRemove = this.model.getSelection(),
|
|
offset = 0,
|
|
docLength, tx, startNode, endNode, endNodeData, nodeToDelete;
|
|
|
|
if ( rangeToRemove.isCollapsed() ) {
|
|
// In case when the range is collapsed use the same logic that is used for cursor left and
|
|
// right movement in order to figure out range to remove.
|
|
rangeToRemove = this.getDocument().getRelativeRange(
|
|
rangeToRemove,
|
|
backspace ? -1 : 1,
|
|
( e.altKey === true || e.ctrlKey === true ) ? 'word' : 'character',
|
|
true
|
|
);
|
|
offset = rangeToRemove.start;
|
|
docLength = this.model.getDocument().data.getLength();
|
|
if ( offset < docLength ) {
|
|
while ( offset < docLength && this.model.getDocument().data.isCloseElementData( offset ) ) {
|
|
offset++;
|
|
}
|
|
// If the user tries to delete a focusable node from a collapsed selection,
|
|
// just select the node and cancel the deletion.
|
|
startNode = this.documentView.getDocumentNode().getNodeFromOffset( offset + 1 );
|
|
if ( startNode.isFocusable() ) {
|
|
this.model.setSelection( startNode.getModel().getOuterRange() );
|
|
return;
|
|
}
|
|
}
|
|
if ( rangeToRemove.isCollapsed() ) {
|
|
// For instance beginning or end of the document.
|
|
return;
|
|
}
|
|
}
|
|
tx = ve.dm.Transaction.newFromRemoval( this.documentView.model, rangeToRemove );
|
|
this.model.change( tx );
|
|
rangeToRemove = tx.translateRange( rangeToRemove );
|
|
if ( !rangeToRemove.isCollapsed() ) {
|
|
// If after processing removal transaction range is not collapsed it means that not
|
|
// everything got merged nicely (at this moment transaction processor is capable of merging
|
|
// nodes of the same type and at the same depth level only), so we process with another
|
|
// merging that takes remaing data from endNode and inserts it at the end of startNode,
|
|
// endNode or recrusivly its parent (if have only one child) gets removed.
|
|
//
|
|
// If startNode has no content then we just delete that node instead of merging.
|
|
// This prevents content being inserted into empty structure which, e.g. and empty heading
|
|
// will be deleted, rather than "converting" the paragraph beneath to a heading.
|
|
|
|
endNode = this.documentView.getNodeFromOffset( rangeToRemove.end, false );
|
|
|
|
// If endNode is within our rangeToRemove, then we shouldn't delete it
|
|
if ( endNode.getModel().getRange().start >= rangeToRemove.end ) {
|
|
startNode = this.documentView.getNodeFromOffset( rangeToRemove.start, false );
|
|
if ( startNode.getModel().getRange().isCollapsed() ) {
|
|
// Remove startNode
|
|
this.model.change( [
|
|
ve.dm.Transaction.newFromRemoval(
|
|
this.documentView.model, startNode.getModel().getOuterRange()
|
|
)
|
|
] );
|
|
} else {
|
|
endNodeData = this.documentView.model.getData( endNode.getModel().getRange() );
|
|
nodeToDelete = endNode;
|
|
nodeToDelete.traverseUpstream( function ( node ) {
|
|
var parent = node.getParent();
|
|
if ( parent.children.length === 1 ) {
|
|
nodeToDelete = parent;
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
} );
|
|
// Move contents of endNode into startNode, and delete nodeToDelete
|
|
this.model.change( [
|
|
ve.dm.Transaction.newFromRemoval(
|
|
this.documentView.model, nodeToDelete.getModel().getOuterRange()
|
|
),
|
|
ve.dm.Transaction.newFromInsertion(
|
|
this.documentView.model, rangeToRemove.start, endNodeData
|
|
)
|
|
] );
|
|
}
|
|
}
|
|
}
|
|
this.model.setSelection( new ve.Range( rangeToRemove.start ) );
|
|
this.surfaceObserver.clear();
|
|
};
|
|
|
|
/**
|
|
* Show selection on a range.
|
|
*
|
|
* @method
|
|
* @param {ve.Range} range Range to show selection on
|
|
*/
|
|
ve.ce.Surface.prototype.showSelection = function ( range ) {
|
|
var start, end,
|
|
rangySel = rangy.getSelection( this.$document[0] ),
|
|
rangyRange = rangy.createRange( this.$document[0] );
|
|
|
|
range = new ve.Range(
|
|
this.getNearestCorrectOffset( range.from, -1 ),
|
|
this.getNearestCorrectOffset( range.to, 1 )
|
|
);
|
|
|
|
if ( !range.isCollapsed() ) {
|
|
start = this.documentView.getNodeAndOffset( range.start );
|
|
end = this.documentView.getNodeAndOffset( range.end );
|
|
rangyRange.setStart( start.node, start.offset );
|
|
rangyRange.setEnd( end.node, end.offset );
|
|
rangySel.removeAllRanges();
|
|
rangySel.addRange( rangyRange, range.start !== range.from );
|
|
} else {
|
|
start = this.documentView.getNodeAndOffset( range.start );
|
|
rangyRange.setStart( start.node, start.offset );
|
|
rangySel.setSingleRange( rangyRange );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Append passed phantoms to phantoms container after emptying it first.
|
|
*
|
|
* @method
|
|
* @param {jQuery} $phantoms Phantoms to append
|
|
*/
|
|
ve.ce.Surface.prototype.replacePhantoms = function ( $phantoms ) {
|
|
this.$phantoms.empty().append( $phantoms );
|
|
};
|
|
|
|
/**
|
|
* Append passed highlights to highlight container after emptying it first.
|
|
*
|
|
* @method
|
|
* @param {jQuery} $highlights Highlights to append
|
|
*/
|
|
ve.ce.Surface.prototype.replaceHighlight = function ( $highlights ) {
|
|
this.$highlights.empty().append( $highlights );
|
|
};
|
|
|
|
/*! Helpers */
|
|
|
|
/**
|
|
* Get the nearest offset that a cursor can be placed at.
|
|
*
|
|
* TODO: Find a better name and a better place for this method
|
|
*
|
|
* @method
|
|
* @param {number} offset Offset to start looking at
|
|
* @param {number} [direction=-1] Direction to look in, +1 or -1
|
|
*/
|
|
ve.ce.Surface.prototype.getNearestCorrectOffset = function ( offset, direction ) {
|
|
var contentOffset, structuralOffset;
|
|
|
|
direction = direction > 0 ? 1 : -1;
|
|
if (
|
|
this.documentView.model.data.isContentOffset( offset ) ||
|
|
this.hasSlugAtOffset( offset )
|
|
) {
|
|
return offset;
|
|
}
|
|
|
|
contentOffset = this.documentView.model.data.getNearestContentOffset( offset, direction );
|
|
structuralOffset =
|
|
this.documentView.model.data.getNearestStructuralOffset( offset, direction, true );
|
|
|
|
if ( !this.hasSlugAtOffset( structuralOffset ) ) {
|
|
return contentOffset;
|
|
}
|
|
|
|
if ( direction === 1 ) {
|
|
if ( contentOffset < offset ) {
|
|
return structuralOffset;
|
|
} else {
|
|
return Math.min( contentOffset, structuralOffset );
|
|
}
|
|
} else {
|
|
if ( contentOffset > offset ) {
|
|
return structuralOffset;
|
|
} else {
|
|
return Math.max( contentOffset, structuralOffset );
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check if an offset is inside a slug.
|
|
*
|
|
* TODO: Find a better name and a better place for this method - probably in a document view?
|
|
*
|
|
* @method
|
|
* @param {number} offset Offset to check for a slug at
|
|
* @returns {boolean} A slug exists at the given offset
|
|
*/
|
|
ve.ce.Surface.prototype.hasSlugAtOffset = function ( offset ) {
|
|
return !!this.documentView.getSlugAtOffset( offset );
|
|
};
|
|
|
|
/**
|
|
* Get the number of consecutive clicks the user has performed.
|
|
*
|
|
* This is required for supporting double, tripple, etc. clicking across all browsers.
|
|
*
|
|
* @method
|
|
* @param {Event} e Native event object
|
|
* @returns {number} Number of clicks detected
|
|
*/
|
|
ve.ce.Surface.prototype.getClickCount = function ( e ) {
|
|
if ( !$.browser.msie ) {
|
|
return e.detail;
|
|
}
|
|
|
|
var i, response = 1;
|
|
|
|
// Add select MouseEvent properties to the beginning of the clickHistory
|
|
this.clickHistory.unshift( {
|
|
x: e.x,
|
|
y: e.y,
|
|
timeStamp: e.timeStamp
|
|
} );
|
|
|
|
// Compare history
|
|
if ( this.clickHistory.length > 1 ) {
|
|
for ( i = 0; i < this.clickHistory.length - 1; i++ ) {
|
|
if (
|
|
this.clickHistory[i].x === this.clickHistory[i + 1].x &&
|
|
this.clickHistory[i].y === this.clickHistory[i + 1].y &&
|
|
this.clickHistory[i].timeStamp - this.clickHistory[i + 1].timeStamp < 500
|
|
) {
|
|
response++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trim old history if necessary
|
|
if ( this.clickHistory.length > 3 ) {
|
|
this.clickHistory.pop();
|
|
}
|
|
|
|
return response;
|
|
};
|
|
|
|
/**
|
|
* Checks if we need to pawn for insertionAnnotations based on the related annotationSet.
|
|
*
|
|
* "Related" is typically to the left, unless at the beginning of a node.
|
|
*
|
|
* We choose to pawn if the related annotationSet doesn't match insertionAnnotations, or if
|
|
* we are at the edge of an annotation that requires pawning (i.e. an annotation requiring pawning
|
|
* is present on the left but not on the right, or vice versa).
|
|
*
|
|
* @method
|
|
* @param {ve.Range} selection
|
|
* @param {ve.dm.AnnotationSet} insertionAnnotations
|
|
* @returns {boolean} Whether we need to pawn
|
|
*/
|
|
ve.ce.Surface.prototype.needsPawn = function ( selection, insertionAnnotations ) {
|
|
var leftAnnotations, rightAnnotations, documentModel = this.model.documentModel;
|
|
|
|
function isForced( annotation ) {
|
|
return ve.ce.annotationFactory.isAnnotationContinuationForced( annotation.constructor.static.name );
|
|
}
|
|
|
|
if ( selection.start > 0 ) {
|
|
leftAnnotations = documentModel.data.getAnnotationsFromOffset( selection.start - 1 );
|
|
}
|
|
if ( selection.start < documentModel.data.getLength() ) {
|
|
rightAnnotations = documentModel.data.getAnnotationsFromOffset( selection.start + 1 );
|
|
}
|
|
|
|
// Take annotations from the left
|
|
// TODO reorganize the logic in this function
|
|
if ( leftAnnotations && !leftAnnotations.compareTo( insertionAnnotations ) ) {
|
|
return true;
|
|
}
|
|
// At the beginning of a node, take from the right
|
|
if (
|
|
rangy.getSelection( this.$document[0] ).anchorOffset === 0 &&
|
|
rightAnnotations &&
|
|
!rightAnnotations.compareTo( insertionAnnotations )
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
leftAnnotations && rightAnnotations &&
|
|
!leftAnnotations.filter( isForced ).compareTo( rightAnnotations.filter( isForced ) )
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
/*! Getters */
|
|
|
|
/**
|
|
* Get the top-level surface.
|
|
*
|
|
* @method
|
|
* @returns {ve.ui.Surface} Surface
|
|
*/
|
|
ve.ce.Surface.prototype.getSurface = function () {
|
|
return this.surface;
|
|
};
|
|
|
|
/**
|
|
* Get the surface model.
|
|
*
|
|
* @method
|
|
* @returns {ve.dm.Surface} Surface model
|
|
*/
|
|
ve.ce.Surface.prototype.getModel = function () {
|
|
return this.model;
|
|
};
|
|
|
|
/**
|
|
* Get the document view.
|
|
*
|
|
* @method
|
|
* @returns {ve.ce.Document} Document view
|
|
*/
|
|
ve.ce.Surface.prototype.getDocument = function () {
|
|
return this.documentView;
|
|
};
|
|
|
|
/**
|
|
* Get the currently focused node.
|
|
*
|
|
* @method
|
|
* @returns {ve.ce.Node|undefined} Focused node
|
|
*/
|
|
ve.ce.Surface.prototype.getFocusedNode = function () {
|
|
return this.focusedNode;
|
|
};
|
|
|
|
/**
|
|
* Check whether there are any render locks
|
|
*
|
|
* @method
|
|
* @returns {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--;
|
|
};
|
|
|
|
/**
|
|
* Surface 'dir' property (Content-Level Direction)
|
|
* @returns {string} 'ltr' or 'rtl'
|
|
*/
|
|
ve.ce.Surface.prototype.getDir = function () {
|
|
return this.$element.css( 'direction' );
|
|
};
|
|
|
|
/**
|
|
* 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.Range} new selection
|
|
* @throws {Error} If calls to this method are nested
|
|
*/
|
|
ve.ce.Surface.prototype.changeModel = function ( transaction, range ) {
|
|
if ( this.newModelSelection !== null ) {
|
|
throw new Error( 'Nested change of newModelSelection' );
|
|
}
|
|
this.newModelSelection = range;
|
|
try {
|
|
this.model.change( transaction, range );
|
|
} finally {
|
|
this.newModelSelection = null;
|
|
}
|
|
};
|
|
|
|
ve.ce.Surface.prototype.setContentBranchNodeChanged = function ( isChanged ) {
|
|
this.contentBranchNodeChanged = isChanged;
|
|
};
|