/*!
* VisualEditor ContentEditable Surface class.
*
* @copyright See AUTHORS.txt
*/
/**
* ContentEditable surface.
*
* @class
* @extends OO.ui.Element
* @mixes 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 ) {
// 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.attachedRoot = this.getDocument().getDocumentNode().getNodeFromOffset(
model.getAttachedRoot().getOffset() + ( model.getAttachedRoot().isWrapped() ? 1 : 0 )
);
this.selection = null;
this.readOnly = false;
this.reviewMode = false;
this.surfaceObserver = new ve.ce.SurfaceObserver( this );
this.$window = $( this.getElementWindow() );
this.$document = $( this.getElementDocument() );
this.$attachedRootNode = this.attachedRoot.$element.addClass( 've-ce-attachedRootNode' );
// Deprecated aliases
this.$documentNode = this.$attachedRootNode;
this.root = this.attachedRoot;
// Window.getSelection returns a live singleton representing the document's selection
this.nativeSelection = this.getElementWindow().getSelection();
ve.fixSelectionNodes( this.nativeSelection );
this.eventSequencer = new ve.EventSequencer( [
'keydown', 'keypress', 'keyup',
'compositionstart', 'compositionend',
'beforeinput', 'input', 'mousedown'
] );
this.clipboard = null;
this.clipboardId = Math.random().toString();
this.clipboardIndex = 0;
// The last non-collapsed selection in this VE surface. This will be a NullSelection
// if there has never had a non-collapsed selection, or if the cursor is moved out of
// the surface and a selection is made elsewhere.
this.lastNonCollapsedDocumentSelection = new ve.dm.NullSelection();
this.middleClickPasting = false;
this.middleClickTargetOffset = false;
this.renderLocks = 0;
this.dragging = false;
this.relocatingSelection = null;
this.relocatingNode = null;
this.allowedFile = null;
this.resizing = false;
this.focused = false;
this.deactivated = false;
this.showAsActivated = false;
this.hideSelection = false;
this.$deactivatedSelection = $( '<div>' );
this.userSelectionDeactivate = {};
this.drawnSelections = {};
this.drawnSelectionCache = {};
this.activeNode = null;
this.contentBranchNodeChanged = false;
this.selectionLink = null;
this.delayedSequences = [];
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.beforePasteAnnotationsAtFocus = [];
this.copying = false;
this.pasteSpecial = false;
this.pointerEvents = null;
this.focusedBlockSlug = null;
this.focusedNode = null;
this.activeAnnotations = [];
this.contexedAnnotations = [];
this.previousActiveAnnotations = [];
// 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,
selectionState: null
};
this.cursorDirectionality = null;
this.unicorningNode = null;
this.setUnicorningRecursionGuard = false;
this.cursorHolderBefore = null;
this.cursorHolderAfter = null;
// Events
// Debounce to prevent trying to draw every cursor position in history.
this.onPositionDebounced = ve.debounce( this.onPosition.bind( this ) );
this.connect( this, { position: this.onPositionDebounced } );
this.model.connect( this, {
select: 'onModelSelect',
documentUpdate: 'onModelDocumentUpdate',
insertionAnnotationsChange: 'onInsertionAnnotationsChange'
} );
if ( this.model.synchronizer ) {
this.model.synchronizer.connect( this, {
authorSelect: 'onSynchronizerAuthorUpdate',
authorChange: 'onSynchronizerAuthorUpdate',
authorDisconnect: 'onSynchronizerAuthorDisconnect',
wrongDoc: 'onSynchronizerWrongDoc',
pause: 'onSynchronizerPause'
} );
}
this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
this.$attachedRootNode.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 = ve.debounce( this.onWindowResize.bind( this ), 50 );
this.$window.on( 'resize', this.onWindowResizeHandler );
this.onDocumentFocusInOutHandler = this.onDocumentFocusInOut.bind( this );
this.$document.on( 'focusin focusout', this.onDocumentFocusInOutHandler );
this.debounceFocusChange = ve.debounce( this.onFocusChange.bind( this ) );
// If the document is blurred (but still has a selection) it is
// possible to clear the selection by clicking elsewhere without
// triggering a focus or blur event, so listen to mousedown globally.
this.$document.on( 'mousedown', this.debounceFocusChange );
// It is possible that when focusin fires, the selection is not yet inside
// the document. This happens if the selection is being moved inside itself,
// e.g. the whole html page was previously selected, including the attachedRootNode
// In this case the selection is not moved until mouseup. T157499
this.$attachedRootNode.on( 'mouseup', this.debounceFocusChange );
this.$pasteTarget.add( this.$highlights ).on( {
cut: this.onCut.bind( this ),
copy: this.onCopy.bind( this ),
paste: this.onPaste.bind( this )
} );
this.$attachedRootNode
.on( 'paste', this.onPaste.bind( this ) )
.on( 'focus', 'a', () => {
// Opera <= 12 triggers 'blur' on document node before any link is
// focused and we don't want that
this.$attachedRootNode[ 0 ].focus();
} );
this.onDocumentSelectionChangeHandler = this.onDocumentSelectionChange.bind( this );
this.$document.on( 'selectionchange', this.onDocumentSelectionChangeHandler );
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 ),
beforeinput: this.onDocumentBeforeInput.bind( this ),
input: this.onDocumentInput.bind( this ),
compositionstart: this.onDocumentCompositionStart.bind( this )
} ).after( {
keydown: this.afterDocumentKeyDown.bind( this )
} );
this.mutationObserver = new MutationObserver( this.afterMutations.bind( this ) );
this.mutationObserver.observe(
this.$attachedRootNode[ 0 ],
{ childList: true, subtree: true }
);
// 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' );
// Support: Edge
// Add translate="no" attribute to prevent Chromium Edge's translate feature from
// translating our editable surface, and leaving junk behind... (T267747)
// Some documentation out there says it respects class="notranslate", but it doesn't.
this.$element.attr( 'translate', 'no' );
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' )
// T283853
.attr( 'aria-hidden', true )
.prop( {
tabIndex: -1,
contentEditable: 'true'
} );
// Add elements to the DOM
this.$highlights.append( this.$dropMarker );
this.$element.append( this.$attachedRootNode, 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 ve.ce.Surface#relocationStart
*/
/**
* @event ve.ce.Surface#relocationEnd
*/
/**
* @event ve.ce.Surface#keyup
*/
/**
* When the surface or its contents changes position
* (only after initialize has already been called).
*
* @event ve.ce.Surface#position
* @param {boolean} [wasSynchronizing] The surface was positioned due to
* synchronization (ve.dm.SurfaceSynchronizer)
*/
/**
* 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 ve.ce.Surface#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 ve.ce.Surface#blur
*/
/**
* Surface activation state has changed (i.e. on activate or deactivate)
*
* @event ve.ce.Surface#activation
*/
/* 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'
];
/**
* Values of InputEvent.inputType which map to a command
*
* Currently these are triggered when the user selects
* undo/redo from the context menu in Chrome, or uses the
* selection formatting tools on iOS.
*
* See https://w3c.github.io/input-events/
*
* Values of null will perform no action and preventDefault.
*
* @type {Object.<string,string|null>}
*/
ve.ce.Surface.static.inputTypeCommands = {
historyUndo: 'undo',
historyRedo: 'redo',
formatBold: 'bold',
formatItalic: 'italic',
formatUnderline: 'underline',
formatStrikeThrough: 'strikethrough',
formatSuperscript: 'superscript',
formatSubscript: 'subscript',
formatJustifyFull: null,
formatJustifyCenter: null,
formatJustifyRight: null,
formatJustifyLeft: null,
formatIndent: 'indent',
formatOutdent: 'outdent',
formatRemove: 'clear',
formatSetBlockTextDirection: null,
formatSetInlineTextDirection: null,
formatBackColor: null,
formatFontColor: null,
formatFontName: null
};
/**
* 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' )
.attr( {
role: 'none',
alt: ''
} )
)
.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.
*/
ve.ce.Surface.prototype.destroy = function () {
const attachedRoot = this.attachedRoot;
// 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();
}
// Detach observer and event sequencer
this.surfaceObserver.stopTimerLoop();
this.surfaceObserver.detach();
this.eventSequencer.detach();
// Make document node not live
attachedRoot.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 );
this.$document.off( 'selectionchange', this.onDocumentSelectionChangeHandler );
if ( this.model.synchronizer ) {
// TODO: Move destroy to ve.dm.Surface#destroy
this.model.synchronizer.destroy();
this.model.synchronizer.disconnect( this );
}
// Disconnect DOM events on the window
this.$window.off( 'resize', this.onWindowResizeHandler );
// Remove DOM elements (also disconnects their events)
this.$element.remove();
this.$highlights.remove();
};
/**
* Get linear model offest from a mouse event
*
* @param {Event} e
* @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.surface.$scrollContainer.scrollLeft(),
e.pageY - this.surface.$scrollContainer.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 ) {
const doc = this.getElementDocument();
try {
let offset;
if ( doc.caretPositionFromPoint ) {
// Gecko
// http://dev.w3.org/csswg/cssom-view/#extensions-to-the-document-interface
const 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/
const 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
const textRange = document.body.createTextRange();
textRange.moveToPoint( x, y );
textRange.pasteHTML( '<span class="ve-ce-textRange-drop-marker"> </span>' );
// eslint-disable-next-line no-jquery/no-global-selector
const $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;
};
/**
* Get block directionality at selection
*
* @return {string} 'rtl' or 'ltr'
*/
ve.ce.Surface.prototype.getSelectionDirectionality = function () {
return this.getSelection().getDirectionality( this.getDocument() );
};
/* Initialization */
/**
* Initialize surface.
*
* This should be called after the surface has been attached to the DOM.
*
* @fires ve.ce.Surface#position
*/
ve.ce.Surface.prototype.initialize = function () {
this.attachedRoot.setLive( true );
if ( $.client.profile().layout === 'gecko' ) {
// Support: Firefox < 64
// 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
// These are disabled by default since Firefox 64.
try {
this.$document[ 0 ].execCommand( 'enableObjectResizing', false, false );
this.$document[ 0 ].execCommand( 'enableInlineTableEditing', false, false );
} catch ( e ) { /* Silently ignore */ }
}
this.emit( 'position' );
};
/**
* Set the read-only state of the surface
*
* @param {boolean} readOnly Make surface read-only
*/
ve.ce.Surface.prototype.setReadOnly = function ( readOnly ) {
this.readOnly = !!readOnly;
this.$element.toggleClass( 've-ce-surface-readOnly', this.readOnly );
};
/**
* Check if the surface is read-only
*
* @return {boolean}
*/
ve.ce.Surface.prototype.isReadOnly = function () {
return this.readOnly;
};
/**
* Set the review mode state of the surface
*
* In review mode the surface can't be interacted with by the user
* (unlike the read-only mode where the user can select text and
* inspect nodes).
*
* Review mode does not restrict changes to the model by other means,
* so programmatic changes can still be made from other tools.
*
* @param {boolean} reviewMode Set surface to review mode
* @param {ve.ce.Node[]} highlightNodes Nodes to highlight while in review mode
*/
ve.ce.Surface.prototype.setReviewMode = function ( reviewMode, highlightNodes ) {
this.reviewMode = !!reviewMode;
this.$element.toggleClass( 've-ce-surface-reviewMode', this.reviewMode );
this.$element.toggleClass( 've-ce-surface-reviewMode-highlightNodes', this.reviewMode && !!highlightNodes );
if ( reviewMode && highlightNodes ) {
highlightNodes.forEach( ( node ) => {
node.$element
.addClass( 've-ce-surface-reviewMode-highlightNode' )
.parentsUntil( '.ve-ce-attachedRootNode' )
.addClass( 've-ce-surface-reviewMode-highlightNode' );
} );
} else {
this.$element.find( '.ve-ce-surface-reviewMode-highlightNode' )
.removeClass( 've-ce-surface-reviewMode-highlightNode' );
}
};
/**
* Give focus to the surface, reapplying the model selection, or selecting the first visible 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 () {
if ( !this.attachedRoot.isLive() ) {
OO.ui.warnDeprecation( 'Tried to focus an un-initialized surface view. Wait for the ve.ui.Surface `ready` event to fire.' );
return;
}
let selection = this.getSelection();
if ( selection.getModel().isNull() ) {
this.selectFirstVisibleStartContentOffset();
selection = this.getSelection();
}
// Focus the contentEditable for text selections, or the pasteTarget for focusedNode selections
if ( selection.isFocusedNode() ) {
this.$pasteTarget[ 0 ].focus();
} else if ( selection.isNativeCursor() ) {
let nodeAndOffset;
try {
nodeAndOffset = this.getDocument().getNodeAndOffset( selection.getModel().getRange().start );
} catch ( e ) {
// Unexplained failures causing log spam: T262487
nodeAndOffset = null;
}
if ( nodeAndOffset ) {
$( nodeAndOffset.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( () => {
// 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 ( !this.isFocused() ) {
this.selectFirstVisibleStartContentOffset();
}
} );
// 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.removeRangesAndBlur();
// This won't trigger focusin/focusout events, so trigger focus change manually
this.onFocusChange();
if ( OO.ui.isMobile() ) {
this.updateActiveAnnotations();
this.contexedAnnotations = [];
}
};
/**
* Remove all native selection ranges, and blur any active element
*
* This should hide all virtual keyboards when present.
*/
ve.ce.Surface.prototype.removeRangesAndBlur = function () {
this.nativeSelection.removeAllRanges();
if ( this.getElementDocument().activeElement === this.$attachedRootNode[ 0 ] ) {
// Blurring the activeElement ensures the keyboard is hidden on iOS
this.getElementDocument().activeElement.blur();
}
};
/**
* 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 () {
this.debounceFocusChange();
};
/**
* Handle global focus change.
*/
ve.ce.Surface.prototype.onFocusChange = function () {
const surfaceNodes = [
this.$attachedRootNode[ 0 ],
this.$pasteTarget[ 0 ],
this.$highlights[ 0 ]
];
const hasFocus = OO.ui.contains(
surfaceNodes,
this.nativeSelection.anchorNode,
true
) && OO.ui.contains(
surfaceNodes,
document.activeElement,
true
);
if ( this.deactivated ) {
if ( OO.ui.contains( this.$attachedRootNode[ 0 ], this.nativeSelection.anchorNode, true ) ) {
this.onDocumentFocus();
}
} else {
if ( hasFocus && !this.isFocused() ) {
this.onDocumentFocus();
} else if ( !hasFocus && this.isFocused() ) {
this.onDocumentBlur();
} else if ( hasFocus && OO.ui.contains( this.$highlights[ 0 ], document.activeElement, true ) ) {
// Focus ended up in the higlight, e.g. by click on an already visible highlight.
// Move the cursor back to pasteTarget as we do when focusableNode initially selected.
// Without this, arrow key navigation from the focusable node would stop working.
this.preparePasteTargetForCopy();
}
}
};
/**
* Check if the surface is deactivated.
*
* @return {boolean} Surface is deactivated
*/
ve.ce.Surface.prototype.isDeactivated = function () {
return this.deactivated;
};
/**
* Check if the surface is visibly deactivated.
*
* Only true if the surface was decativated by the user
* in a way that is expected to change the rendering.
*
* @return {boolean} Surface is visibly deactivated
*/
ve.ce.Surface.prototype.isShownAsDeactivated = function () {
return this.deactivated && !this.showAsActivated;
};
/**
* 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.
*
* @param {boolean} [showAsActivated=true] Surface should still show as activated
* @param {boolean} [noSelectionChange] Don't change the native selection.
* @param {boolean} [hideSelection] Completely hide the selection
* @fires ve.ce.Surface#activation
*/
ve.ce.Surface.prototype.deactivate = function ( showAsActivated, noSelectionChange, hideSelection ) {
this.showAsActivated = showAsActivated === undefined || !!showAsActivated;
this.hideSelection = hideSelection;
if ( !this.deactivated ) {
// Disable the surface observer, there can be no observable changes
// until the surface is activated
this.surfaceObserver.disable();
this.deactivated = true;
this.previousActiveAnnotations = this.activeAnnotations;
this.findAndExecuteDelayedSequences();
this.$element.addClass( 've-ce-surface-deactivated' );
// Remove ranges so the user can't accidentally type into the document,
// and so virtual keyboards are hidden.
if ( !noSelectionChange ) {
this.removeRangesAndBlur();
// iOS Safari will sometimes restore the selection immediately (T293661)
setTimeout( () => {
if (
// Surface may have been immediately re-activated deliberately
this.deactivated &&
OO.ui.contains( this.$attachedRootNode[ 0 ], this.nativeSelection.anchorNode, true )
) {
this.removeRangesAndBlur();
}
} );
}
this.updateDeactivatedSelection();
this.clearKeyDownState();
this.emit( 'activation' );
}
};
/**
* Reactivate the surface and restore the native selection
*
* @fires ve.ce.Surface#activation
* @fires ve.dm.Surface#contextChange
*/
ve.ce.Surface.prototype.activate = function () {
if ( this.deactivated ) {
this.deactivated = false;
this.showAsActivated = false;
this.hideSelection = false;
this.updateDeactivatedSelection();
this.surfaceObserver.enable();
this.$element.removeClass( 've-ce-surface-deactivated' );
if ( OO.ui.isMobile() ) {
// Activating triggers a context hide on mobile
this.model.emit( 'contextChange' );
}
const previousSelection = this.getModel().getSelection();
if ( OO.ui.contains( this.$attachedRootNode[ 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();
}
const newSelection = this.getModel().getSelection();
if (
previousSelection.getCoveringRange() &&
newSelection.getCoveringRange() &&
previousSelection.getCoveringRange().containsRange(
newSelection.getCoveringRange()
)
) {
// If the user reactivates by clicking on their previous selection, use that selection.
this.getModel().setSelection( previousSelection );
// Restore active annotations
if ( this.previousActiveAnnotations.length ) {
const annotationClasses = this.previousActiveAnnotations.map( ( ann ) => ann.constructor );
this.selectAnnotation( ( view ) => ve.isInstanceOfAny( view, annotationClasses ) );
}
}
this.emit( 'activation' );
}
};
/**
* 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 () {
const selection = this.getSelection();
// Check we have a deactivated surface and a native selection
if ( this.deactivated && selection.isNativeCursor() && !this.hideSelection ) {
let textColor;
// For collapsed selections, work out the text color to use for the cursor
const isCollapsed = selection.getModel().isCollapsed();
if ( isCollapsed ) {
const currentNode = this.getDocument().getBranchNodeFromOffset(
selection.getModel().getCoveringRange().start
);
if ( currentNode ) {
// This isn't perfect as it doesn't take into account annotations.
textColor = currentNode.$element.css( 'color' );
}
}
const classes = [];
if ( this.isShownAsDeactivated() ) {
classes.push( 've-ce-surface-selections-deactivated-showAsDeactivated' );
}
if ( isCollapsed ) {
classes.push( 've-ce-surface-selections-deactivated-collapsed' );
}
// Generates ve-ce-surface-selections-deactivated CSS class
this.drawSelections( 'deactivated', [ selection ], {
color: textColor,
wrapperClass: classes.join( ' ' )
} );
} else {
// Generates ve-ce-surface-selections-deactivated CSS class
this.drawSelections( 'deactivated', [] );
}
};
/**
* Check if the surface has a native cursor selection
*
* On mobile platforms, this means it is likely the virtual
* keyboard is visible.
*
* @return {boolean} Surface has a native cursor selection
*/
ve.ce.Surface.prototype.hasNativeCursorSelection = function () {
return !this.isDeactivated() && this.getSelection().isNativeCursor();
};
/**
* Draw selections.
*
* @param {string} name Unique name for the selection being drawn
* @param {ve.ce.Selection[]} selections Selections to draw
* @param {Object} [options]
* @param {string} options.color CSS color for the selection. Should usually
* be set in a stylesheet using the generated class name.
* @param {string} options.wrapperClass Additional CSS class string to add to the $selections wrapper.
* mapped to the same index.
* @param {string} options.label Label shown above each selection
*/
ve.ce.Surface.prototype.drawSelections = function ( name, selections, options ) {
options = options || {};
if ( !Object.prototype.hasOwnProperty.call( this.drawnSelections, name ) ) {
this.drawnSelections[ name ] = {};
}
const drawnSelection = this.drawnSelections[ name ];
drawnSelection.$selections = drawnSelection.$selections ||
// The following classes are used here:
// * ve-ce-surface-selections-deactived
// * ve-ce-surface-selections-<name>
$( '<div>' ).addClass( 've-ce-surface-selections ve-ce-surface-selections-' + name ).appendTo( this.surface.$selections );
const oldSelections = drawnSelection.selections || [];
const oldOptions = drawnSelection.options || {};
drawnSelection.selections = selections;
drawnSelection.options = options;
// Always set the 'class' attribute to ensure previously-set classes are cleared.
drawnSelection.$selections.attr(
'class',
've-ce-surface-selections ve-ce-surface-selections-' + name + ' ' +
( options.wrapperClass || '' )
);
const selectionsJustShown = {};
selections.forEach( ( selection ) => {
let $selection = this.getDrawnSelection( name, selection.getModel(), options );
if ( !$selection ) {
let rects = selection.getSelectionRects();
if ( !rects ) {
return;
}
rects = ve.minimizeRects( rects );
$selection = $( '<div>' ).addClass( 've-ce-surface-selection' );
rects.forEach( ( rect ) => {
const $rect = $( '<div>' ).css( {
top: rect.top,
left: rect.left,
// Collapsed selections can have a width of 0, so expand
width: Math.max( rect.width, 1 ),
height: rect.height
} );
$selection.append( $rect );
if ( options.color ) {
$rect.css( 'background-color', options.color );
}
} );
if ( options.label ) {
const boundingRect = selection.getSelectionBoundingRect();
$selection.append(
$( '<div>' )
.addClass( 've-ce-surface-selection-label' )
.text( options.label )
.css( {
top: boundingRect.top,
left: boundingRect.left,
'background-color': options.color || ''
} )
);
}
}
if ( !$selection.parent().length ) {
drawnSelection.$selections.append( $selection );
}
const cacheKey = this.storeDrawnSelection( $selection, name, selection.getModel(), options );
selectionsJustShown[ cacheKey ] = true;
} );
// Remove any selections that were not in the latest list of selections
oldSelections.forEach( ( oldSelection ) => {
const cacheKey = this.getDrawnSelectionCacheKey( name, oldSelection.getModel(), oldOptions );
if ( !selectionsJustShown[ cacheKey ] ) {
const $oldSelection = this.getDrawnSelection( name, oldSelection.getModel(), oldOptions );
if ( $oldSelection ) {
$oldSelection.detach();
}
}
} );
};
/**
* Get a cache key for a drawn selection
*
* @param {string} name Name of selection group
* @param {ve.dm.Selection} selection Selection model
* @param {Object} [options] Selection options
* @return {string} Cache key
*/
ve.ce.Surface.prototype.getDrawnSelectionCacheKey = function ( name, selection, options ) {
options = options || {};
return name + '-' + JSON.stringify( selection ) + '-' + ( options.color || '' ) + '-' + ( options.label || '' );
};
/**
* Get an already drawn selection from the cache
*
* @param {string} name Name of selection group
* @param {ve.dm.Selection} selection Selection model
* @param {Object} [options] Selection options
* @return {jQuery} Drawn selection
*/
ve.ce.Surface.prototype.getDrawnSelection = function ( name, selection, options ) {
const cacheKey = this.getDrawnSelectionCacheKey( name, selection, options );
return Object.prototype.hasOwnProperty.call( this.drawnSelectionCache, cacheKey ) ? this.drawnSelectionCache[ cacheKey ] : null;
};
/**
* Store an already drawn selection in the cache
*
* @param {jQuery} $selection Drawn selection
* @param {string} name Name of selection group
* @param {ve.dm.Selection} selection Selection model
* @param {Object} [options] Selection options
* @return {string} Cache key
*/
ve.ce.Surface.prototype.storeDrawnSelection = function ( $selection, name, selection, options ) {
const cacheKey = this.getDrawnSelectionCacheKey( name, selection, options );
this.drawnSelectionCache[ cacheKey ] = $selection;
return cacheKey;
};
/**
* Redraw selections
*
* This is triggered by a surface 'position' event, which fires when the surface
* changes size, or when the document is modified. The drawnSelectionCache is
* cleared as these two things will cause any previously calculated rectangles
* to be incorrect.
*/
ve.ce.Surface.prototype.redrawSelections = function () {
Object.keys( this.drawnSelections ).forEach( ( name ) => {
const drawnSelection = this.drawnSelections[ name ];
drawnSelection.$selections.empty();
} );
this.drawnSelectionCache = {};
Object.keys( this.drawnSelections ).forEach( ( name ) => {
const drawnSelection = this.drawnSelections[ name ];
this.drawSelections( name, drawnSelection.selections, drawnSelection.options );
} );
};
/**
* Handle document focus events.
*
* This is triggered by a global focusin/focusout event noticing a selection on the document.
*
* @fires ve.ce.Surface#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.selectFirstVisibleStartContentOffset();
}
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.
*
* @fires ve.ce.Surface#blur
*/
ve.ce.Surface.prototype.onDocumentBlur = function () {
const nullSelectionOnBlur = this.surface.nullSelectionOnBlur;
if ( !nullSelectionOnBlur ) {
// Set noSelectionChange as we already know the selection has left
// the document and we don't want #deactivate to move it again.
this.deactivate( false, true );
}
this.eventSequencer.detach();
this.surfaceObserver.stopTimerLoop();
this.surfaceObserver.pollOnce();
this.surfaceObserver.clear();
// Setting focused to false blocks selection change handler, so fire one last time here
this.onDocumentSelectionChange();
this.setDragging( false );
this.focused = false;
if ( nullSelectionOnBlur ) {
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.
*
* @param {jQuery.Event} e Mouse down event
*/
ve.ce.Surface.prototype.onDocumentMouseDown = function ( e ) {
if ( e.which !== OO.ui.MouseButtons.LEFT ) {
if ( e.which === OO.ui.MouseButtons.MIDDLE ) {
// When middle click is also focusig the document, the selection may not end up
// where you clicked, so record the offset from the click coordinates. (T311733)
let targetOffset = -1;
if ( this.getModel().getSelection().isNull() ) {
targetOffset = this.getOffsetFromEventCoords( e );
}
this.middleClickTargetOffset = targetOffset !== -1 ? targetOffset : null;
this.middleClickPasting = true;
this.$document.one( 'mouseup', () => {
// Stay true until other events have run, e.g. paste
setTimeout( () => {
this.middleClickPasting = false;
} );
} );
}
return;
}
const offset = this.getOffsetFromEventCoords( e );
if ( offset !== -1 ) {
const contexedAnnotations = this.annotationsAtNode(
e.target,
( view ) => this.surface.context.getRelatedSourcesFromModels( [ view.model ] ).length
);
if (
OO.ui.isMobile() &&
// The user has clicked on contexed annotations and ...
contexedAnnotations.length && (
// ... was previously on a focusable node or ...
this.focusedNode ||
// ... previously had different annotations selected ...
!(
// Shallow strict equality check
this.contexedAnnotations.length === contexedAnnotations.length &&
this.contexedAnnotations.every( ( ann, i ) => ann === contexedAnnotations[ i ] )
)
)
) {
const node = e.target;
setTimeout( () => {
this.getModel().setLinearSelection( new ve.Range( offset ) );
// HACK: Re-activate flag so selection is repositioned
this.activate();
this.deactivate( false, false, true );
this.updateActiveAnnotations( node );
} );
this.contexedAnnotations = contexedAnnotations;
e.preventDefault();
return;
}
this.contexedAnnotations = contexedAnnotations;
}
// Remember the mouse is down
this.setDragging( 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() ) );
// Handle triple click
if ( e.originalEvent.detail >= 3 ) {
// Browser default behaviour for triple click won't behave as we want
e.preventDefault();
const 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.
*
* @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.setDragging( 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 ) {
if ( !selectionBefore.isNativeCursor() ) {
return;
}
const newSelection = this.getSelection();
if ( newSelection.getModel().isCollapsed() && !newSelection.equals( selectionBefore ) ) {
this.getModel().setLinearSelection(
new ve.Range(
selectionBefore.getModel().getRange().from,
newSelection.getModel().getRange().to
)
);
}
};
/**
* Set a flag when the user is dragging a selection
*
* @param {boolean} dragging Dragging (mouse is down)
*/
ve.ce.Surface.prototype.setDragging = function ( dragging ) {
this.dragging = !!dragging;
// Class can be used to suppress hover states, such as branch slugs.
this.$element.toggleClass( 've-ce-surface-dragging', this.dragging );
};
/**
* Handle document selection change events.
*
* @param {jQuery.Event} e Selection change event
*/
ve.ce.Surface.prototype.onDocumentSelectionChange = function () {
const selection = this.getModel().getSelection();
if (
// There is a non-empty selection in the VE surface. Use this if middle-click-to-paste is triggered later.
!selection.isCollapsed() ||
// There is no surface selection, and a native selection has been made elsewhere.
// Null the lastNonCollapsedDocumentSelection so native middle-click-to-paste happens instead.
( selection.isNull() && this.nativeSelection.rangeCount && !this.nativeSelection.getRangeAt( 0 ).collapsed )
) {
this.lastNonCollapsedDocumentSelection = selection;
}
// selectionChange events are only emitted from window.document, so ignore
// any events which are fired when the document is blurred or deactivated.
if ( !this.focused || this.deactivated ) {
return;
}
this.fixupCursorPosition( 0, this.dragging );
this.updateActiveAnnotations();
this.surfaceObserver.pollOnceSelection();
};
/**
* Handle document drag start events.
*
* @param {jQuery.Event} e Drag start event
* @fires ve.ce.Surface#relocationStart
*/
ve.ce.Surface.prototype.onDocumentDragStart = function ( e ) {
this.onCopy( e );
this.startRelocation();
};
/**
* Handle document drag over events.
*
* @param {jQuery.Event} e Drag over event
*/
ve.ce.Surface.prototype.onDocumentDragOver = function ( e ) {
const dataTransferHandlerFactory = this.getSurface().dataTransferHandlerFactory,
dataTransfer = e.originalEvent.dataTransfer;
let isContent = true;
if ( this.readOnly ) {
return;
}
let nodeType;
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 ( let i = 0, l = dataTransfer.items.length; i < l; i++ ) {
const item = dataTransfer.items[ i ];
if ( item.kind !== 'string' ) {
const fakeItem = new ve.ui.DataTransferItem( item.kind, item.type );
if ( dataTransferHandlerFactory.getHandlerNameForItem( fakeItem ) ) {
this.allowedFile = true;
break;
}
}
}
} else if ( dataTransfer.files ) {
for ( let i = 0, l = dataTransfer.files.length; i < l; i++ ) {
const item = dataTransfer.items[ i ];
const fakeItem = new ve.ui.DataTransferItem( item.kind, item.type );
if ( dataTransferHandlerFactory.getHandlerNameForItem( fakeItem ) ) {
this.allowedFile = true;
break;
}
}
} else if ( Array.prototype.indexOf.call( dataTransfer.types || [], 'Files' ) !== -1 ) {
// Support: Firefox
// If we have no metadata (e.g. in Firefox) assume it is droppable
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 ) {
node.parent.traverseUpstream( ( n ) => {
if ( n.shouldIgnoreChildren() ) {
node = null;
return false;
}
} );
return node;
}
}
if ( !isContent ) {
e.preventDefault();
const $target = $( e.target ).closest( '.ve-ce-branchNode, .ve-ce-leafNode' );
let $dropTarget;
let dropPosition;
if ( $target.length ) {
// Find the nearest node which will accept this node type
let dropTargetNode = getNearestDropTarget( $target.data( 'view' ) );
if ( dropTargetNode ) {
$dropTarget = dropTargetNode.$element;
dropPosition = e.originalEvent.pageY - $dropTarget.offset().top > $dropTarget.outerHeight() / 2 ? 'bottom' : 'top';
} else {
const targetOffset = this.getOffsetFromEventCoords( e.originalEvent );
if ( targetOffset !== -1 ) {
dropTargetNode = getNearestDropTarget( this.getDocument().getBranchNodeFromOffset( targetOffset ) );
if ( dropTargetNode ) {
$dropTarget = dropTargetNode.$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
) ) {
const targetPosition = $dropTarget.position();
// Go beyond margins as they can overlap
let top = targetPosition.top + parseFloat( $dropTarget.css( 'margin-top' ) );
const 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.
*
* @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.
*
* @param {jQuery.Event} e Drop event
* @fires ve.ce.Surface#relocationEnd
*/
ve.ce.Surface.prototype.onDocumentDrop = function ( e ) {
// Properties may be nullified by other events, so cache before setTimeout
const surfaceModel = this.getModel(),
dataTransfer = e.originalEvent.dataTransfer,
$dropTarget = this.$lastDropTarget,
dropPosition = this.lastDropPosition,
platformKey = ve.getSystemPlatform() === 'mac' ? 'mac' : 'pc';
// Prevent native drop event from modifying view
e.preventDefault();
if ( this.readOnly ) {
return;
}
let targetOffset;
// Determine drop position
if ( $dropTarget ) {
// Block level drag and drop: use the lastDropTarget to get the targetOffset
if ( $dropTarget ) {
const 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;
}
}
const targetFragment = surfaceModel.getLinearFragment( new ve.Range( targetOffset ) );
const targetViewNode = this.getDocument().getBranchNodeFromOffset(
targetFragment.getSelection().getCoveringRange().from
);
// TODO: Support sanitized drop on a single line node (removing line breaks)
const isMultiline = targetViewNode.isMultiline();
// Internal drop
if ( this.relocatingSelection ) {
// Get a fragment and data of the node being dragged
const originFragment = surfaceModel.getFragment( this.relocatingSelection );
let originData;
if ( !isMultiline ) {
// Data needs to be balanced to be sanitized
const slice = this.model.documentModel.shallowCloneFromRange( originFragment.getSelection().getCoveringRange() );
const linearData = new ve.dm.ElementLinearData(
originFragment.getDocument().getStore(),
slice.getBalancedData()
);
linearData.sanitize( { singleLine: true } );
originData = linearData.data;
// Unwrap CBN
if ( originData[ 0 ].type && ve.dm.nodeFactory.canNodeContainContent( originData[ 0 ].type ) ) {
originData = originData.slice( 1, originData.length - 1 );
}
} else {
originData = originFragment.getData();
}
// Start staging so we can abort in the catch later
surfaceModel.pushStaging();
// Dragging performs cut-and-paste by default (remove content from old location).
// If Ctrl on PC, or Opt (alt) on Mac, is held, it performs copy-and-paste instead.
if ( ( platformKey === 'pc' && !e.ctrlKey ) || ( platformKey === 'mac' && !e.altKey ) ) {
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
// TODO: Support sanitized external drop into single line contexts
if ( isMultiline ) {
this.handleDataTransfer( dataTransfer, false, targetFragment );
}
}
this.endRelocation();
};
/**
* Handle document key down events.
*
* @param {jQuery.Event} e Key down event
*/
ve.ce.Surface.prototype.onDocumentKeyDown = function ( e ) {
const selection = this.getModel().getSelection();
let updateFromModel = false;
if ( selection.isNull() ) {
return;
}
if ( e.which === 229 ) {
// Support: Chrome
// Ignore fake IME events (emitted in 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 {
const trigger = new ve.ui.Trigger( e );
if ( trigger.isComplete() ) {
const executed = this.surface.executeWithSource( trigger, false, 'trigger' );
if ( executed || this.isBlockedTrigger( trigger ) ) {
e.preventDefault();
e.stopPropagation();
updateFromModel = true;
}
}
}
if (
!e.isDefaultPrevented() &&
e.keyCode === OO.ui.Keys.TAB &&
// Not modified (excluding shift)
!( e.metaKey || e.ctrlKey || e.altKey )
) {
// Manually move focus to the next/previous focusable element (T341687)
const surfaceNode = this.$element[ 0 ];
const treeWalker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_ELEMENT,
( n ) => {
if ( surfaceNode.contains( n ) ) {
return NodeFilter.FILTER_REJECT;
}
if ( OO.ui.isFocusableElement( $( n ) ) ) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
}
);
treeWalker.currentNode = surfaceNode;
if ( e.shiftKey ) {
treeWalker.previousNode();
} else {
treeWalker.nextNode();
}
if ( treeWalker.currentNode ) {
treeWalker.currentNode.focus();
e.preventDefault();
e.stopPropagation();
return;
}
}
if (
this.readOnly && !(
// Allowed keystrokes in readonly mode:
// Arrows, simple navigation
ve.ce.LinearArrowKeyDownHandler.static.keys.indexOf( e.keyCode ) !== -1 ||
// Potential commands:
// Function keys...
( e.keyCode >= 112 && e.keyCode <= 123 ) ||
// ... or anything modified (e.g. copy / select-all), excluding shift
e.metaKey || e.ctrlKey || e.altKey
// Keys already handled in keyDownHandlers above do not need to be exempt here
)
) {
e.preventDefault();
e.stopPropagation();
return;
}
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 disabled), we should still preventDefault so ContentEditable
* native commands don't occur, leaving the view out of sync with the model.
*
* @param {ve.ui.Trigger} trigger Trigger to check
* @return {boolean} Trigger should preventDefault
*/
ve.ce.Surface.prototype.isBlockedTrigger = function ( trigger ) {
const triggerString = trigger.toString(),
platformKey = ve.getSystemPlatform() === 'mac' ? 'mac' : 'pc',
blockedIfRegisteredTriggers = [ 'tab', 'shift+tab' ],
blockedTriggers = {
mac: [ 'cmd+b', 'cmd+i', 'cmd+u', 'cmd+z', 'cmd+y', 'cmd+shift+z', 'cmd+[', 'cmd+]' ],
pc: [ 'ctrl+b', 'ctrl+i', 'ctrl+u', 'ctrl+z', 'ctrl+y', 'ctrl+shift+z' ]
};
// Special case: only block Tab/Shift+Tab if indentation commands are enabled on this surface,
// otherwise allow them to change focus
if ( blockedIfRegisteredTriggers.indexOf( triggerString ) !== -1 ) {
return !!this.surface.triggerListener.getCommandByTrigger( triggerString );
}
return blockedTriggers[ platformKey ].indexOf( triggerString ) !== -1;
};
/**
* Handle document key press events.
*
* @param {jQuery.Event} e Key press event
*/
ve.ce.Surface.prototype.onDocumentKeyPress = function ( e ) {
let 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() ).isNull() )
) {
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 ) {
const documentModel = this.getModel().getDocument(),
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
);
let keyDownSelectionState = null;
/**
* 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} node DOM node of cursor position
* @param {number} offset Offset of cursor position
* @param {number} dir Cursor motion direction (1=forward, -1=backward)
* @return {ve.ce.Node|null} node, or null if not in a focusable node
*/
const getSurroundingFocusableNode = ( node, offset, dir ) => {
let focusNode;
if ( node.nodeType === Node.TEXT_NODE ) {
focusNode = node;
} else if ( dir > 0 && offset < node.childNodes.length ) {
focusNode = node.childNodes[ offset ];
} else if ( dir < 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
*/
const getDirection = () => (
isArrow &&
keyDownSelectionState &&
ve.compareDocumentOrder(
this.nativeSelection.focusNode,
this.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
) {
const 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();
}
const dmSelection = this.model.getSelection();
if ( dmSelection instanceof ve.dm.LinearSelection ) {
const dmFocus = dmSelection.getRange().end;
const 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.
const ceSelection = new ve.SelectionState( this.nativeSelection );
this.nativeSelection.removeAllRanges();
this.showSelectionState( ceSelection );
if ( inNonSlug ) {
return;
}
}
// Only fixup cursoring on linear selections.
if ( isArrow && !( this.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.
const $focusNode = $( this.nativeSelection.focusNode );
let direction;
let focusableNode;
let range;
// eslint-disable-next-line no-jquery/no-class-state
if ( $focusNode.hasClass( 've-ce-cursorHolder' ) ) {
// eslint-disable-next-line no-jquery/no-class-state
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 &&
keyDownSelectionState.isCollapsed &&
this.nativeSelection.isCollapsed &&
( direction = getDirection() ) !== null
) {
focusableNode = getSurroundingFocusableNode(
this.nativeSelection.focusNode,
this.nativeSelection.focusOffset,
direction
);
if ( !focusableNode ) {
let startOffset, endOffset, offsetDiff;
// 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 = documentModel.documentNode
.getNodeFromOffset( ( startOffset + endOffset ) / 2 );
if ( focusableNode.isFocusable() ) {
range = new ve.Range( startOffset, endOffset );
} else {
focusableNode = undefined;
}
}
}
}
if (
isArrow &&
direction > 0 &&
this.getActiveNode() instanceof ve.ce.TableCaptionNode &&
this.getActiveNode() !== $focusNode.closest( '.ve-ce-tableCaptionNode' ).data( 'view' )
) {
// We cursored down out of the table caption; move to the first table cell
const tableNode = this.getActiveNode().getParent();
this.model.setSelection( new ve.dm.TableSelection( tableNode.getOuterRange(), 0, 0 ) );
}
if ( focusableNode ) {
if ( !range ) {
range = focusableNode.getOuterRange();
if ( direction < 0 ) {
range = range.flip();
}
}
if ( focusableNode instanceof ve.ce.TableNode ) {
if ( direction > 0 ) {
let captionNode;
if ( ( captionNode = focusableNode.getModel().getCaptionNode() ) ) {
this.model.setLinearSelection(
documentModel.getRelativeRange( new ve.Range( captionNode.getRange().start ), 1 )
);
} else {
this.model.setSelection( new ve.dm.TableSelection(
range, 0, 0
) );
}
} else {
const matrix = focusableNode.getModel().getMatrix();
const row = matrix.getRowCount() - 1;
const col = matrix.getColCount( row ) - 1;
this.model.setSelection( new ve.dm.TableSelection(
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();
}
const fixupCursorForUnicorn = (
!e.shiftKey &&
( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.RIGHT )
);
const 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 ) {
if ( !this.unicorningNode || !this.unicorningNode.unicorns ) {
return false;
}
const preUnicorn = this.unicorningNode.unicorns[ 0 ];
if ( !this.$attachedRootNode[ 0 ].contains( preUnicorn ) ) {
return false;
}
if ( this.nativeSelection.rangeCount === 0 ) {
// XXX do we want to clear unicorns in this case?
return false;
}
const 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.
let 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)
let fixup;
if ( ve.compareDocumentOrder(
range.endContainer,
range.endOffset,
preUnicorn.parentNode,
ve.parentIndex( preUnicorn )
) <= 0 ) {
// Before the pre-unicorn (including in the equality case, because the selection
// endpoint is an offset between sibling positions)
fixup = -1;
} else {
// At or after the pre-unicorn (actually must be after the post-unicorn)
fixup = 1;
}
const contentBranchNodeBefore = this.getSelectedContentBranchNode();
if ( this.unicorningNode !== contentBranchNodeBefore ) {
this.setNotUnicorningAll();
return true;
}
// Apply the DOM selection to the model
const veRange = ve.ce.veRangeFromSelection( this.nativeSelection );
if ( veRange ) {
this.incRenderLock();
try {
// 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( veRange ) );
if ( fixupCursor ) {
this.moveModelCursor( fixup );
}
} finally {
this.decRenderLock();
}
}
const contentBranchNodeAfter = this.getSelectedContentBranchNode();
if ( contentBranchNodeAfter ) {
contentBranchNodeAfter.renderContents();
}
if ( contentBranchNodeBefore && contentBranchNodeBefore !== contentBranchNodeAfter ) {
contentBranchNodeBefore.renderContents();
}
this.showModelSelection();
return true;
};
/**
* Handle document key up events.
*
* @param {jQuery.Event} e Key up event
* @fires ve.ce.Surface#keyup
*/
ve.ce.Surface.prototype.onDocumentKeyUp = function () {
this.emit( 'keyup' );
};
/**
* Handle cut events.
*
* @param {jQuery.Event} e Cut event
*/
ve.ce.Surface.prototype.onCut = function ( e ) {
const selection = this.getModel().getSelection();
if ( selection.isCollapsed() ) {
return;
}
this.onCopy( e );
// setTimeout: postpone until after the setTimeout in onCopy
setTimeout( () => {
// 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(), this, e );
} );
};
/**
* Handle copy (including cut) and dragstart events.
*
* @param {jQuery.Event} e Copy event
* @param {ve.dm.Selection} selection Optional selection to simulate a copy on
*/
ve.ce.Surface.prototype.onCopy = function ( e, selection ) {
// Copy or cut, but not dragstart
const isClipboard = e.type === 'copy' || e.type === 'cut',
htmlDoc = this.getModel().getDocument().getHtmlDocument(),
clipboardData = isClipboard ? e.originalEvent.clipboardData : e.originalEvent.dataTransfer;
selection = selection || this.getModel().getSelection();
this.$pasteTarget.empty();
if ( selection.isCollapsed() ) {
return;
}
const slice = this.model.documentModel.shallowCloneFromSelection( selection );
// Clone the elements in the slice
slice.data.cloneElements( true );
ve.dm.converter.getDomSubtreeFromModel( slice, this.$pasteTarget[ 0 ], ve.dm.Converter.static.CLIPBOARD_MODE );
// 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' );
// When paste has no text content browsers do extreme normalization…
if ( this.$pasteTarget.text() === '' ) {
// …so put nbsp's in empty leaves
// eslint-disable-next-line no-jquery/no-sizzle
this.$pasteTarget.find( '*:not( :has( * ) )' ).text( '\u00a0' );
}
// Resolve attributes (in particular, expand 'href' and 'src' using the right base)
ve.resolveAttributes(
this.$pasteTarget[ 0 ],
htmlDoc,
ve.dm.Converter.static.computedAttributes
);
// Support: Firefox
// Some attributes (e.g RDFa attributes in Firefox) aren't preserved by copy
const unsafeSelector = '[' + ve.ce.Surface.static.unsafeAttributes.join( '],[' ) + ']';
this.$pasteTarget.find( unsafeSelector ).each( ( n, element ) => {
const attrs = {},
ua = ve.ce.Surface.static.unsafeAttributes;
let i = ua.length;
while ( i-- ) {
const val = element.getAttribute( ua[ i ] );
if ( val !== null ) {
attrs[ ua[ i ] ] = val;
}
}
element.setAttribute( 'data-ve-attributes', JSON.stringify( attrs ) );
} );
this.clipboardIndex++;
const clipboardKey = this.clipboardId + '-' + this.clipboardIndex;
this.clipboard = { slice: slice, hash: null };
// Support: Firefox<48
// Writing the key to text/xcustom won't work in Firefox<48, so write
// it to the HTML instead
if ( isClipboard && !ve.isClipboardDataFormatsSupported( e ) ) {
this.$pasteTarget.prepend(
$( '<span>' ).attr( 'data-ve-clipboard-key', clipboardKey ).text( '\u00a0' )
);
// 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 ( isClipboard ) {
// 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 ( isClipboard && ve.isClipboardDataFormatsSupported( e, true ) ) {
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() || ' ' );
ve.track( 'activity.clipboard', { action: e.type } );
};
/**
* Get the annotation set that was a the user focus before a paste started
*
* @return {ve.dm.AnnotationSet} Annotation set
*/
ve.ce.Surface.prototype.getBeforePasteAnnotationSet = function () {
const store = this.getModel().getDocument().getStore();
const dmAnnotations = this.beforePasteAnnotationsAtFocus.map( ( view ) => view.getModel() );
return new ve.dm.AnnotationSet( store, store.hashAll( dmAnnotations ) );
};
/**
* Handle native paste event
*
* @param {jQuery.Event} e Paste event
* @return {boolean|undefined} False if the event is cancelled
*/
ve.ce.Surface.prototype.onPaste = function ( e ) {
// Prevent pasting until after we are done
if ( this.pasting || this.readOnly ) {
return false;
}
this.beforePaste( e );
this.surfaceObserver.disable();
this.pasting = true;
// setTimeout: postpone until after the default paste action
setTimeout( () => {
let afterPastePromise = ve.createDeferred().resolve().promise();
try {
if ( !e.isDefaultPrevented() ) {
afterPastePromise = this.afterPaste( e );
}
} finally {
afterPastePromise.always( () => {
this.surfaceObserver.clear();
this.surfaceObserver.enable();
// Allow pasting again
this.pasting = false;
this.pasteSpecial = false;
this.beforePasteData = null;
// Restore original clipboard metadata if requred (was overridden by middle-click
// paste logic in beforePaste)
if ( this.originalClipboardMetdata ) {
this.clipboardIndex = this.originalClipboardMetdata.clipboardIndex;
this.clipboard = this.originalClipboardMetdata.clipboard;
}
ve.track( 'activity.clipboard', { action: 'paste' } );
} );
}
} );
};
/**
* Handle pre-paste events.
*
* @param {jQuery.Event} e Paste event
*/
ve.ce.Surface.prototype.beforePaste = function ( e ) {
const selection = this.getModel().getSelection(),
clipboardData = e.originalEvent.clipboardData,
surfaceModel = this.getModel(),
fragment = surfaceModel.getFragment(),
documentModel = surfaceModel.getDocument();
let range;
if ( selection instanceof ve.dm.LinearSelection ) {
range = fragment.getSelection().getRange();
} else if ( selection instanceof ve.dm.TableSelection ) {
range = new ve.Range( selection.getRanges( documentModel )[ 0 ].start );
} else {
e.preventDefault();
return;
}
this.beforePasteAnnotationsAtFocus = this.annotationsAtFocus();
this.beforePasteData = {};
this.originalClipboardMetdata = null;
if ( this.middleClickPasting && !this.lastNonCollapsedDocumentSelection.isNull() ) {
// Paste was triggered by middle click, and the last non-collapsed document selection was in
// this VE surface. Simulate a fake copy to load DM data into the clipboard. If we let the
// native middle-click paste happen, it would load CE data into the clipboard.
// Store original clipboard metadata so it can be restored after paste,
// and we can continue to use internal paste.
this.originalClipboardMetdata = {
clipboardIndex: this.clipboardIndex,
clipboard: this.clipboard
};
// Use a fake clipboard index for middle click, will be restored in afterPaste
this.clipboardIndex = -1;
this.clipboard = {
slice: this.model.documentModel.shallowCloneFromSelection( this.lastNonCollapsedDocumentSelection ),
hash: null
};
this.beforePasteData.custom = this.clipboardId + '-' + this.clipboardIndex;
} else 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.surface.$scrollContainer.scrollTop();
this.$pasteTarget.empty();
// Get node from cursor position
const 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.
let textStart = 0, textEnd = 0;
const contextElement = startNode.getClonedElement();
// Make sure that context doesn't have any attributes that might confuse
// the importantElement check in afterPaste.
$( documentModel.getStore().value( contextElement.originalDomElementsHash ) ).removeAttr( 'id typeof rel' );
const context = [ contextElement ];
// If there is content to the left of the cursor, put a placeholder
// character to the left of the cursor
let leftText, rightText;
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
const 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;
// If we are middle click pasting we can't change the native selection, so
// just make the text a placeholder to the left. (T311723)
if ( this.middleClickPasting ) {
leftText = '☁';
textStart = 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();
const nativeRange = this.getElementDocument().createRange();
// Assume that the DM node only generated one child
const 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.surface.$scrollContainer.scrollTop( this.beforePasteData.scrollTop );
};
/**
* Handle post-paste events.
*
* @param {jQuery.Event} e Paste event
* @return {jQuery.Promise} Promise which resolves when the content has been pasted
*/
ve.ce.Surface.prototype.afterPaste = function () {
const surfaceModel = this.getModel(),
documentModel = surfaceModel.getDocument(),
fragment = surfaceModel.getFragment(),
beforePasteData = this.beforePasteData || {},
done = ve.createDeferred().resolve().promise();
let targetFragment = surfaceModel.getFragment( null, true );
// If the selection doesn't collapse after paste then nothing was inserted
if ( !this.nativeSelection.isCollapsed ) {
return done;
}
if ( this.getModel().getFragment().isNull() ) {
return done;
}
if ( this.middleClickTargetOffset ) {
targetFragment = targetFragment.clone( new ve.dm.LinearSelection( new ve.Range( this.middleClickTargetOffset ) ) );
} else if ( this.middleClickPasting ) {
// Middle click pasting should always collapse the selection before pasting
targetFragment = targetFragment.collapseToEnd();
}
// Immedately remove any <style> tags from the pasteTarget that might
// be changing the rendering of the whole page (T235068)
this.$pasteTarget.find( 'style' ).remove();
const pasteData = this.afterPasteExtractClipboardData();
// Handle pastes into a table
if ( fragment.getSelection() instanceof ve.dm.TableSelection ) {
// Internal table-into-table paste can be shortcut
if ( fragment.getSelection() instanceof ve.dm.TableSelection && pasteData.slice instanceof ve.dm.TableSlice ) {
const tableAction = new ve.ui.TableAction( this.getSurface() );
tableAction.importTable( pasteData.slice.getTableNode( documentModel ) );
return ve.createDeferred().resolve().promise();
}
// For table selections the target is the first cell
targetFragment = surfaceModel.getLinearFragment( fragment.getSelection().getRanges( documentModel )[ 0 ], true );
}
// Are we pasting into a multiline context?
const isMultiline = this.getDocument().getBranchNodeFromOffset(
targetFragment.getSelection().getCoveringRange().from
).isMultiline();
let pending;
if ( pasteData.slice ) {
pending = this.afterPasteAddToFragmentFromInternal( pasteData.slice, fragment, targetFragment, isMultiline );
} else {
pending = this.afterPasteAddToFragmentFromExternal( pasteData.clipboardKey, pasteData.$clipboardHtml, fragment, targetFragment, isMultiline );
}
return pending.then( () => {
if ( this.getSelection().isNativeCursor() ) {
// Restore focus and scroll position
this.$attachedRootNode[ 0 ].focus();
this.surface.$scrollContainer.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( () => {
this.surface.$scrollContainer.scrollTop( beforePasteData.scrollTop );
} );
}
// If original selection was linear, switch to end of pasted text
if ( fragment.getSelection() instanceof ve.dm.LinearSelection ) {
targetFragment.collapseToEnd().select();
this.findAndExecuteSequences( /* isPaste */ true );
}
} );
};
/**
* @typedef {Object} ClipboardData
* @memberof ve.ce
* @property {string|undefined} clipboardKey Clipboard key, if present
* @property {jQuery|undefined} $clipboardHtml Clipboard html, if used to extract the clipboard key
* @property {ve.dm.DocumentSlice|undefined} slice Relevant slice of this document, if the key points to it
*/
/**
* Extract the clipboard key and other relevant data from beforePasteData / the paste target
*
* @return {ve.ce.ClipboardData} Data
*/
ve.ce.Surface.prototype.afterPasteExtractClipboardData = function () {
const beforePasteData = this.beforePasteData || {};
let clipboardKey, clipboardHash, $clipboardHtml;
// Find the clipboard key
if ( beforePasteData.custom ) {
// text/xcustom was present, and requires no further processing
clipboardKey = beforePasteData.custom;
} else {
if ( beforePasteData.html ) {
// text/html was present, so we can check if a key was hidden in it
$clipboardHtml = $( ve.sanitizeHtml( beforePasteData.html ) ).filter( ( i, element ) => {
const val = element.getAttribute && element.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( $clipboardHtml );
} else {
// fall back on checking the pasteTarget
// HTML in pasteTarget may 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 );
}
}
let slice;
// 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;
// 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 ( !slice && !$clipboardHtml && beforePasteData.html ) {
$clipboardHtml = $( ve.sanitizeHtml( beforePasteData.html ) );
}
return {
clipboardKey: clipboardKey,
$clipboardHtml: $clipboardHtml,
slice: slice
};
};
/**
* LinearData sanitize helper, for pasted data
*
* @param {ve.dm.LinearData} linearData Data to sanitize
* @param {boolean} isMultiline Sanitize for a multiline context
* @param {boolean} isExternal Treat as external content
*/
ve.ce.Surface.prototype.afterPasteSanitize = function ( linearData, isMultiline, isExternal ) {
const importRules = this.afterPasteImportRules( isMultiline );
if ( isExternal ) {
linearData.sanitize( importRules.external || {} );
}
linearData.sanitize( importRules.all || {} );
};
/**
* Helper to build import rules for pasted data
*
* @param {boolean} isMultiline Get rules for a multiline context
* @return {Object.<string,Object>} Import rules
*/
ve.ce.Surface.prototype.afterPasteImportRules = function ( isMultiline ) {
let importRules = !this.pasteSpecial ? this.getSurface().getImportRules() : { all: { plainText: true, keepEmptyContentBranches: true } };
if ( !isMultiline ) {
importRules = {
all: ve.extendObject( {}, importRules.all, { singleLine: true } ),
external: ve.extendObject( {}, importRules.external, { singleLine: true } )
};
}
return importRules;
};
/**
* After paste handler for pastes from the same document
*
* @param {ve.dm.DocumentSlice} slice Slice of document to paste
* @param {ve.dm.SurfaceFragment} fragment Current fragment
* @param {ve.dm.SurfaceFragment} targetFragment Fragment to insert into
* @param {boolean} isMultiline Pasting to a multiline context
* @return {jQuery.Promise} Promise which resolves when the content has been inserted
*/
ve.ce.Surface.prototype.afterPasteAddToFragmentFromInternal = function ( slice, fragment, targetFragment, isMultiline ) {
// Pasting non-table content into table: just replace 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();
}
// Temporary tracking for T362358
const pastedRefs = slice.getNodesByType( 'mwReference' );
if ( pastedRefs.length > 0 ) {
const documentRefKeys = fragment.getDocument().getNodesByType( 'mwReference' ).map(
( ref ) => ref.registeredListGroup + '\n' + ref.registeredListKey
);
if ( pastedRefs.some(
( ref ) => documentRefKeys.indexOf( ref.registeredListGroup + '\n' + ref.registeredListKey ) !== -1
) ) {
ve.track( 'activity.clipboard', { action: 'paste-ref-internal-reuse' } );
} else {
ve.track( 'activity.clipboard', { action: 'paste-ref-internal-new' } );
}
}
// Only try original data in multiline contexts, for single line we must use balanced data
let linearData, insertionPromise;
// Original data + fixupInsertion
if ( isMultiline ) {
// Take a copy to prevent the data being annotated a second time in the balanced data path
// and to prevent actions in the data model affecting view.clipboard
linearData = new ve.dm.ElementLinearData(
slice.getStore(),
ve.copy( slice.getOriginalData() )
);
if ( this.pasteSpecial ) {
this.afterPasteSanitize( linearData, isMultiline );
}
// ve.dm.Document#fixupInsertion may fail, in which case we fall back to balanced data
try {
insertionPromise = this.afterPasteInsertInternalData( targetFragment, linearData.getData() );
} catch ( e ) {}
}
// Balanaced data
if ( !insertionPromise ) {
// Take a copy to prevent actions in the data model affecting view.clipboard
linearData = new ve.dm.ElementLinearData(
slice.getStore(),
ve.copy( slice.getBalancedData() )
);
if ( this.pasteSpecial || !isMultiline ) {
this.afterPasteSanitize( linearData, isMultiline );
}
let data = linearData.getData();
if ( !isMultiline ) {
// Unwrap single CBN
if ( data[ 0 ].type ) {
data = data.slice( 1, data.length - 1 );
}
}
insertionPromise = this.afterPasteInsertInternalData( targetFragment, data );
}
return insertionPromise;
};
/**
* Insert some pasted data from an internal source
*
* @param {ve.dm.SurfaceFragment} targetFragment Fragment to insert into
* @param {Array} data Data to insert
* @return {jQuery.Promise} Promise which resolves when the content has been inserted
*/
ve.ce.Surface.prototype.afterPasteInsertInternalData = function ( targetFragment, data ) {
targetFragment.insertContent( data, this.getBeforePasteAnnotationSet() );
return targetFragment.getPending();
};
/**
* After paste handler for pastes from the another document
*
* @param {string|undefined} clipboardKey Clipboard key for pasted data
* @param {jQuery|undefined} $clipboardHtml Clipboard HTML, if used to find the key
* @param {ve.dm.SurfaceFragment} fragment Current fragment
* @param {ve.dm.SurfaceFragment} targetFragment Fragment to insert into
* @param {boolean} [isMultiline] Pasting to a multiline context
* @param {boolean} [forceClipboardData] Ignore the paste target, and use only clipboard html
* @return {jQuery.Promise} Promise which resolves when the content has been inserted
*/
ve.ce.Surface.prototype.afterPasteAddToFragmentFromExternal = function ( clipboardKey, $clipboardHtml, fragment, targetFragment, isMultiline, forceClipboardData ) {
const importantElement = '[id],[typeof],[rel],figure',
items = [],
surfaceModel = this.getModel(),
documentModel = surfaceModel.getDocument(),
beforePasteData = this.beforePasteData || {};
let htmlDoc;
// There are two potential sources of HTML to choose from:
// * this.$pasteTarget where we we let the past happen in a context similar to the surface
// * beforePasteData.html which is read from the clipboard API
//
// If clipboard API data is available, then make sure important elements haven't been dropped.
//
// The only reason we don't use clipboard API data unconditionally is that for simpler pastes,
// the $pasteTarget method does a good job of merging content, e.g. paragraps into paragraphs.
//
// If we could do a better job of mimicking how browsers merge content, the clipboard API data
// would produce much more consistent results, as the pasteTarget approach can also re-order
// and destroy nodes.
if (
$clipboardHtml && (
forceClipboardData ||
// FIXME T126045: Allow the test runner to force the use of clipboardData
clipboardKey === 'useClipboardData-0' ||
$clipboardHtml.find( importantElement ).addBack( importantElement ).length > this.$pasteTarget.find( importantElement ).length
)
) {
// CE destroyed an important element, so revert to using clipboard data
htmlDoc = ve.sanitizeHtmlToDocument( beforePasteData.html );
$( htmlDoc )
// Remove the pasteProtect class. See #onCopy.
.find( 'span' ).removeClass( 've-pasteProtect' ).end()
// Remove the clipboard key
.find( 'span[data-ve-clipboard-key]' ).remove().end()
// Remove ve-attributes, we trust that clipboard data preserved these attributes
.find( '[data-ve-attributes]' ).removeAttr( 'data-ve-attributes' );
beforePasteData.context = null;
}
if ( !htmlDoc ) {
// If we're using $pasteTarget, let CE do its sanitizing as it may
// contain disruptive metadata (head tags etc.)
htmlDoc = ve.sanitizeHtmlToDocument( 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
const $body = $( htmlDoc.body );
const $images = $body.children( 'img[src^=data\\:]' );
// Check the body contained just images.
// TODO: In the future this may want to trigger image uploads *and* paste the HTML.
if ( $images.length && $images.length === $body.children().length ) {
for ( let 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 ve.createDeferred().resolve().promise();
}
}
this.afterPasteSanitizeExternal( $( htmlDoc.body ) );
// 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( ( n, element ) => {
if ( element.previousElementSibling ) {
element.previousElementSibling.appendChild( element );
} else {
// List starts double indented. This is invalid and a semantic nightmare.
// Just wrap with an extra list item
$( element ).wrap( '<li>' );
}
} );
// HACK: Fix invalid HTML from copy-pasting `display: inline` lists (T239550).
$( htmlDoc.body ).find( 'li, dd, dt' ).each( ( n, element ) => {
const listType = { li: 'ul', dd: 'dl', dt: 'dl' },
tag = element.tagName.toLowerCase(),
// Parent node always exists because we're searching inside <body>
parentTag = element.parentNode.tagName.toLowerCase();
let list;
if (
( tag === 'li' && ( parentTag !== 'ul' && parentTag !== 'ol' ) ) ||
( ( tag === 'dd' || tag === 'dt' ) && parentTag !== 'dl' )
) {
// This list item's parent node is not a list. This breaks expectations in DM code.
// Wrap this node and its list item siblings in a list node.
list = htmlDoc.createElement( listType[ tag ] );
element.parentNode.insertBefore( list, element );
while (
list.nextElementSibling &&
listType[ list.nextElementSibling.tagName.toLowerCase() ] === listType[ tag ]
) {
list.appendChild( list.nextElementSibling );
}
}
} );
// HTML sanitization
const htmlBlacklist = ve.getProp( this.afterPasteImportRules( isMultiline ), 'external', 'htmlBlacklist' );
if ( htmlBlacklist && !clipboardKey ) {
if ( htmlBlacklist.remove ) {
Object.keys( htmlBlacklist.remove ).forEach( ( selector ) => {
if ( htmlBlacklist.remove[ selector ] ) {
$( htmlDoc.body ).find( selector ).remove();
}
} );
}
if ( htmlBlacklist.unwrap ) {
Object.keys( htmlBlacklist.unwrap ).forEach( ( selector ) => {
if ( htmlBlacklist.unwrap[ selector ] ) {
$( htmlDoc.body ).find( selector ).contents().unwrap();
}
} );
}
}
// External paste
const pastedDocumentModel = ve.dm.converter.getModelFromDom( htmlDoc, {
targetDoc: documentModel.getHtmlDocument(),
fromClipboard: true
} );
const data = pastedDocumentModel.data;
// Clone again
data.cloneElements( true );
// Sanitize
this.afterPasteSanitize( data, isMultiline, !clipboardKey );
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
) {
const tableAction = new ve.ui.TableAction( this.getSurface() );
tableAction.importTable( pastedDocumentModel.documentNode.children[ 0 ], true );
return ve.createDeferred().resolve().promise();
}
// Pasting non-table content into table: just replace 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();
}
let contextRange;
if ( beforePasteData.context ) {
// If the paste was given context, calculate the range of the inserted data
contextRange = this.afterPasteFromExternalContextRange( pastedDocumentModel, isMultiline, forceClipboardData );
if ( !contextRange ) {
return this.afterPasteAddToFragmentFromExternal( clipboardKey, $clipboardHtml, fragment, targetFragment, isMultiline, true );
}
} else {
contextRange = pastedDocumentModel.getDocumentRange();
}
const pastedNodes = pastedDocumentModel.selectNodes( contextRange, 'siblings' )
// Ignore nodes where nothing is selected
.filter( ( node ) => !( node.range && node.range.isCollapsed() ) );
// Unwrap single content branch nodes to match internal copy/paste behaviour
// (which wouldn't put the open and close tags in the clipboard to begin with).
if (
pastedNodes.length === 1 &&
pastedNodes[ 0 ].node.canContainContent()
) {
if ( contextRange.containsRange( pastedNodes[ 0 ].nodeRange ) ) {
contextRange = pastedNodes[ 0 ].nodeRange;
}
}
return this.afterPasteInsertExternalData( targetFragment, pastedDocumentModel, contextRange );
};
/**
* Insert some pasted data from an external source
*
* @param {ve.dm.SurfaceFragment} targetFragment Fragment to insert into
* @param {ve.dm.Document} pastedDocumentModel Model generated from pasted data
* @param {ve.Range} contextRange Range of data in generated model to consider
* @return {jQuery.Promise} Promise which resolves when the content has been inserted
*/
ve.ce.Surface.prototype.afterPasteInsertExternalData = function ( targetFragment, pastedDocumentModel, contextRange ) {
// Temporary tracking for T362358
if ( pastedDocumentModel.getInternalList().getItemNodeCount() > 0 ) {
ve.track( 'activity.clipboard', { action: 'paste-ref-external' } );
}
let handled;
// If the external HTML turned out to be plain text after sanitization
// then run it as a plain text transfer item. In core this will never
// do anything, but implementations can provide their own handler for
// conversion actions here.
if ( pastedDocumentModel.data.isPlainText( contextRange, true, undefined, true ) ) {
const 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, this.getBeforePasteAnnotationSet() );
}
return targetFragment.getPending();
};
/**
* Helper to work out the context range for an external paste
*
* @param {ve.dm.Document} pastedDocumentModel Model for pasted data
* @param {boolean} isMultiline Whether pasting to a multiline context
* @param {boolean} forceClipboardData Whether the current attempted paste is the result of forcing use of clipboard data
* @return {ve.Range|boolean} Context range, or false if data appeared corrupted
*/
ve.ce.Surface.prototype.afterPasteFromExternalContextRange = function ( pastedDocumentModel, isMultiline, forceClipboardData ) {
const data = pastedDocumentModel.data,
documentRange = pastedDocumentModel.getDocumentRange(),
beforePasteData = this.beforePasteData || {},
context = new ve.dm.ElementLinearData(
pastedDocumentModel.getStore(),
ve.copy( beforePasteData.context )
);
// Sanitize context to match data
this.afterPasteSanitize( context, isMultiline );
let leftText = beforePasteData.leftText;
let rightText = beforePasteData.rightText;
// Remove matching context from the left
let left = 0;
while (
context.getLength() &&
ve.dm.ElementLinearData.static.compareElementsUnannotated(
data.getData( left ),
data.isElementData( left ) ? context.getData( 0 ) : leftText
)
) {
if ( !data.isElementData( left ) ) {
// Text context is removed
leftText = '';
}
left++;
context.splice( 0, 1 );
}
// Remove matching context from the right
let right = documentRange.end;
while (
right > 0 &&
context.getLength() &&
ve.dm.ElementLinearData.static.compareElementsUnannotated(
data.getData( right - 1 ),
data.isElementData( right - 1 ) ? context.getData( context.getLength() - 1 ) : rightText
)
) {
if ( !data.isElementData( right - 1 ) ) {
// Text context is removed
rightText = '';
}
right--;
context.splice( context.getLength() - 1, 1 );
}
if ( ( leftText || rightText ) && !forceClipboardData ) {
// If any text context is left over, assume the paste target got corrupted
// so we should start again and try to use clipboardData instead. T193110
return false;
}
// Support: Chrome
// FIXME T126046: Strip trailing linebreaks probably introduced by Chrome bug
while ( right > 0 && data.getType( right - 1 ) === 'break' ) {
right--;
}
return new ve.Range( left, right );
};
/**
* Helper to clean up externally pasted HTML (via pasteTarget).
*
* @param {jQuery} $element Root element containing pasted stuff to sanitize
*/
ve.ce.Surface.prototype.afterPasteSanitizeExternal = function ( $element ) {
const metadataIdRegExp = ve.init.platform.getMetadataIdRegExp();
// Remove the clipboard key
$element.find( 'span[data-ve-clipboard-key]' ).remove();
// Remove style tags (T185532)
$element.find( 'style' ).remove();
// If this is from external, run extra sanitization:
// Do some simple transforms to catch content that is using
// spans+styles instead of regular tags. This is very much targeted at
// the output of Google Docs, but should work with anything fairly-
// similar. This is *fragile*, but more in the sense that small
// deviations will stop it from working, rather than it being terribly
// likely to incorrectly over-format things.
// TODO: This might be cleaner if we could move the sanitization into
// dm.converter entirely.
$element.find( 'span' ).each( ( i, node ) => {
// Later sanitization will replace completely-empty spans with
// their contents, so we can lazily-wrap here without cleaning
// up.
if ( !node.style ) {
return;
}
const $node = $( node );
if ( +node.style.fontWeight >= 700 || node.style.fontWeight === 'bold' ) {
$node.wrap( '<b>' );
}
if ( node.style.fontStyle === 'italic' ) {
$node.wrap( '<i>' );
}
if ( node.style.textDecorationLine === 'underline' ) {
$node.wrap( '<u>' );
}
if ( node.style.textDecorationLine === 'line-through' ) {
$node.wrap( '<s>' );
}
if ( node.style.verticalAlign === 'super' ) {
$node.wrap( '<sup>' );
}
if ( node.style.verticalAlign === 'sub' ) {
$node.wrap( '<sub>' );
}
} );
// Remove style attributes. Any valid styles will be restored by data-ve-attributes.
$element.find( '[style]' ).removeAttr( 'style' );
if ( metadataIdRegExp ) {
$element.find( '[id]' ).each( ( i, el ) => {
const $el = $( el );
if ( metadataIdRegExp.test( $el.attr( 'id' ) ) ) {
$el.removeAttr( 'id' );
}
} );
}
// Remove the pasteProtect class (see #onCopy) and unwrap empty spans.
$element.find( 'span' ).each( ( i, el ) => {
const $el = $( el );
$el.removeClass( 've-pasteProtect' );
if ( $el.attr( 'class' ) === '' ) {
$el.removeAttr( 'class' );
}
// Unwrap empty spans
if ( !el.attributes.length ) {
// childNodes is a NodeList
// eslint-disable-next-line no-jquery/no-append-html
$el.replaceWith( el.childNodes );
}
} );
// Restore attributes. See #onCopy.
$element.find( '[data-ve-attributes]' ).each( ( i, el ) => {
const attrsJSON = el.getAttribute( 'data-ve-attributes' );
// Always remove the attribute, even if the JSON has been corrupted
el.removeAttribute( 'data-ve-attributes' );
let attrs;
try {
attrs = JSON.parse( attrsJSON );
} catch ( err ) {
// Invalid JSON
return;
}
$( el ).attr( attrs );
} );
};
/**
* 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 ) {
const items = [],
htmlStringData = dataTransfer.getData( 'text/html' );
// Rules for clipboard content selection:
// 1. If the clipboard has only HTML, proceed parsing such HTML.
// 2. If the clipboard has only files, process them as-is.
// 3. If the clipboard has both:
// a. If the HTML in the clipboard contains only images and other elements with no text, process the image files.
// b. Otherwise, ignore the files and process the HTML.
//
// Notes:
// - If a file is pasted/dropped, it may have HTML fallback, such as an IMG node with alt text, for example.
// - 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 ( let 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 ( let i = 0, l = dataTransfer.files.length; i < l; i++ ) {
items.push( ve.ui.DataTransferItem.static.newFromBlob( dataTransfer.files[ i ], htmlStringData ) );
}
}
} else if ( dataTransfer.files ) {
const htmlPreParse = $.parseHTML( htmlStringData );
let imgCount = 0;
let hasContent = false;
for ( let i = 0; i < htmlPreParse.length; i++ ) {
// Count images in root nodes
if ( htmlPreParse[ i ].nodeName === 'IMG' ) {
imgCount++;
} else if (
( htmlPreParse[ i ].nodeType === 1 || htmlPreParse[ i ].nodeType === 3 ) &&
htmlPreParse[ i ].textContent &&
htmlPreParse[ i ].textContent.trim() !== ''
) {
// Only count element nodes (type 1) or text nodes (type 3)
// that have non empty text content.
hasContent = true;
}
// Count images in children
if ( typeof htmlPreParse[ i ].querySelectorAll === 'function' ) {
imgCount += htmlPreParse[ i ].querySelectorAll( 'img' ).length;
}
}
if ( !hasContent && imgCount === dataTransfer.files.length ) {
for ( let i = 0, l = dataTransfer.files.length; i < l; i++ ) {
// TODO: should we use image node outerHTML instead of htmlStringData?
items.push( ve.ui.DataTransferItem.static.newFromBlob( dataTransfer.files[ i ], htmlStringData ) );
}
}
}
if ( dataTransfer.items ) {
// Extract "string" types.
for ( let i = 0, l = dataTransfer.items.length; i < l; i++ ) {
if (
dataTransfer.items[ i ].kind === 'string' &&
dataTransfer.items[ i ].type.slice( 0, 5 ) === 'text/'
) {
items.push( ve.ui.DataTransferItem.static.newFromString(
dataTransfer.getData( dataTransfer.items[ i ].type ),
dataTransfer.items[ i ].type,
htmlStringData
) );
}
}
}
// We care a little bit about the order of items, as the first one matched
// is going to be the one we handle, and don't trust dataTransfer.items to
// be in the fallback order we'd prefer. In practice, this just means that
// we want to text/html and text/plain to be at the end of the list, as
// they tend to show up as common fallbacks.
const pushItemToBack = function ( array, type ) {
for ( let j = 0, jlen = array.length; j < jlen; j++ ) {
if ( array[ j ].type === type ) {
return array.push( array.splice( j, 1 )[ 0 ] );
}
}
};
pushItemToBack( items, 'text/html' );
pushItemToBack( items, 'text/plain' );
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 ) {
targetFragment = targetFragment || this.getModel().getFragment();
function insert( docOrData ) {
// For non-paste transfers, don't overwrite the selection
const resultFragment = !isPaste ? targetFragment.collapseToEnd() : targetFragment;
if ( docOrData instanceof ve.dm.Document ) {
const rootChildren = docOrData.getDocumentNode().children;
if (
rootChildren[ 0 ] &&
rootChildren[ 0 ].type === 'paragraph' &&
( !rootChildren[ 1 ] || rootChildren[ 1 ].type === 'internalList' )
) {
resultFragment.insertDocument(
docOrData,
rootChildren[ 0 ].getRange()
);
} else {
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();
}
const dataTransferHandlerFactory = this.getSurface().dataTransferHandlerFactory;
let handled = false;
for ( let i = 0, l = items.length; i < l; i++ ) {
const item = items[ i ];
const 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 () {
const selection = this.getModel().getSelection(),
dmDoc = this.getModel().getDocument();
if ( selection instanceof ve.dm.LinearSelection ) {
const activeNode = this.getActiveNode();
let range;
if ( activeNode ) {
range = activeNode.getRange();
range = new ve.Range( range.from + 1, range.to - 1 );
} else {
const documentRange = this.getModel().getDocument().getDocumentRange();
range = new ve.Range(
dmDoc.getNearestCursorOffset( 0, 1 ),
dmDoc.getNearestCursorOffset( documentRange.end, -1 )
);
}
this.getModel().setLinearSelection( range );
} else if ( selection instanceof ve.dm.TableSelection ) {
const matrix = selection.getTableNode( dmDoc ).getMatrix();
this.getModel().setSelection(
new ve.dm.TableSelection(
selection.tableRange,
0, 0, matrix.getMaxColCount() - 1, matrix.getRowCount() - 1
)
);
}
};
/**
* Handle beforeinput events.
*
* @param {jQuery.Event} e The input event
*/
ve.ce.Surface.prototype.onDocumentBeforeInput = function ( e ) {
if ( this.getSelection().isNativeCursor() ) {
const inputType = e.originalEvent ? e.originalEvent.inputType : null;
// Support: Chrome (Android, Gboard)
// Handle IMEs that emit text fragments with a trailing newline on Enter keypress (T312558)
if (
( inputType === 'insertText' || inputType === 'insertCompositionText' ) &&
e.originalEvent.data && e.originalEvent.data.slice( -1 ) === '\n'
) {
// The event will have inserted a newline into the CE view,
// so fix up the DM accordingly depending on the context.
this.eventSequencer.afterOne( {
beforeinput: this.fixupChromiumNativeEnter.bind( this )
} );
}
}
};
/**
* Remove unwanted DOM elements from the CE view, inserted by Chromium's native Enter handling.
* We preventDefault an Enter keydown event, but the native handling can still happen with some
* IME combinations, e.g. Gboard on Android Chromium in many languages including English when
* there is candidate text (T312558).
*/
ve.ce.Surface.prototype.fixupChromiumNativeEnter = function () {
const setCursorToEnd = ( element ) => {
if ( !element ) {
return;
}
const range = this.getElementDocument().createRange();
range.setStart( element, element.childNodes.length );
range.setEnd( element, element.childNodes.length );
this.nativeSelection.removeAllRanges();
this.nativeSelection.addRange( range );
};
let fixedUp = false;
// Test for Chromium native Enter inside a <li class="foo"><p class="bar">...</p></li>
// creating unwanted trailing <li class="foo"><p class="bar"><br></p></li>,
// assuming it (initially) leaves the selection in the original list item.
const listItemNode = $( this.nativeSelection.focusNode ).closest( 'li.ve-ce-branchNode' )[ 0 ];
if ( listItemNode ) {
const nextNode = listItemNode.nextElementSibling;
if ( nextNode && !$.data( nextNode, 'view' ) ) {
// We infer the native Enter action added a spurious DOM node that does
// not exist in the view. Remove it and flag that we need to perform a
// VE Enter action.
nextNode.parentNode.removeChild( nextNode );
fixedUp = true;
}
}
// Test for Chromium native Enter inside a <p class="foo"></p>, while there is
// candidate text, creating a spurious trailing <div><br></div>, and (immediately)
// putting the cursor inside it.
//
// Note if the paragraph is a grandchild of the list item, it's not clear under what
// circumstances Chromium creates a div vs a list item, so perform this check even if
// a fixup already happened in the lines of code above.
const div = $( this.nativeSelection.focusNode ).closest( 'div' )[ 0 ];
if ( div && this.$documentNode[ 0 ].contains( div ) && !$.data( div, 'view' ) ) {
// The div is inside a branch node, but has no view. We infer the native Enter
// action added a spurious DOM node that does not exist in the view. Remove it and
// flag that we need to perform a VE enter action.
setCursorToEnd( div.previousElementSibling );
div.parentNode.removeChild( div );
fixedUp = true;
}
// If we found nodes to fixup, that means the VE Enter handler never ran (since it would
// have prevented the Enter event), so execute it now to perform the context-appropriate
// operation.
if ( fixedUp ) {
// First, poll current node for content changes, because any autocorrect change
// will not have reached the model. The logic above ensures the cursor will be
// inside the ContentBranchNode where the user was typing in either case.
this.surfaceObserver.pollOnce();
ve.ce.keyDownHandlerFactory.lookup( 'linearEnter' ).static.execute( this, new Event( 'dummy' ) );
}
};
/**
* Handle input events.
*
* @param {jQuery.Event} e The input event
*/
ve.ce.Surface.prototype.onDocumentInput = function ( e ) {
// Synthetic events don't have the originalEvent property (T176104)
const inputType = e.originalEvent ? e.originalEvent.inputType : null;
// Special handling of NBSP insertions. T53045
// NBSPs are converted to normal spaces in ve.ce.TextState as they can be
// inserted by ContentEditable in unexpected places, or accidentally imported
// by copy-paste. Usually they are not intended, but if we detect an NBSP in
// an insertion event that means it was probably intentional, e.g. inserted
// by a specific keyboard shortcut, or IME sequence.
if (
this.getSelection().isNativeCursor() &&
( inputType === 'insertText' || inputType === 'insertCompositionText' ) &&
e.originalEvent.data === '\u00a0'
) {
// Wait for the insertion to happen
setTimeout( () => {
const fragment = this.getModel().getFragment().adjustLinearSelection( -1 );
let nbspContent = ' ';
if ( this.getSurface().getMode() === 'visual' ) {
nbspContent = ve.init.platform.decodeEntities( nbspContent );
}
// Check a plain space was inserted and replace it with an NBSP.
if ( fragment.getText() === ' ' ) {
fragment.insertContent( nbspContent ).collapseToEnd().select();
}
} );
}
const inputTypeCommands = this.constructor.static.inputTypeCommands;
if (
inputType &&
Object.prototype.hasOwnProperty.call( inputTypeCommands, inputType )
) {
// Value can be null, in which case we still want to preventDefault.
if ( inputTypeCommands[ inputType ] ) {
this.getSurface().executeCommand( this.constructor.static.inputTypeCommands[ inputType ] );
}
e.preventDefault();
return;
}
this.incRenderLock();
try {
this.surfaceObserver.pollOnce();
} finally {
this.decRenderLock();
}
};
/**
* Handle compositionstart events.
* Note that their meaning varies between browser/OS/IME combinations
*
* @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 <= ~51
// 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 () {
const selection = this.getModel().getSelection();
setTimeout( this.findAndExecuteDelayedSequences.bind( this ) );
this.cursorDirectionality = null;
this.contentBranchNodeChanged = false;
this.selection = null;
if ( selection.isNull() ) {
this.removeCursorHolders();
}
if ( selection instanceof ve.dm.LinearSelection ) {
const 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();
}
}
const focusedNode = this.findFocusedNode( selection.getRange() );
if ( this.isDeactivated() && !this.isShownAsDeactivated() && !blockSlug && !focusedNode ) {
// If deactivated without showing (e.g. by preparePasteTargetForCopy),
// reactivate when changing selection (T221291)
// TODO: It is really messy that the surface can get reactivated based on the state of
// a flag that should just be used for rendering (isShownAsDeactivated). This led to
// T236400 so should be fixed.
this.activate();
}
// 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 root, clear the SurfaceObserver's
// selection state. Otherwise, if the user places the selection back into the root
// in exactly the same place where it was before, the observer won't consider that a change.
this.surfaceObserver.clear();
}
}
}
} else {
if ( selection instanceof ve.dm.TableSelection ) {
this.preparePasteTargetForCopy();
}
if ( this.focusedNode ) {
this.focusedNode.setFocused( false );
}
this.focusedNode = null;
}
// Deactivate immediately if mobile and read-only to avoid showing keyboard (T281771)
if ( this.isReadOnly() && OO.ui.isMobile() ) {
this.deactivate( false, false, true );
}
// 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
*
* @param {boolean} force Force a native selection, even on mobile (used for click-to-copy)
*/
ve.ce.Surface.prototype.preparePasteTargetForCopy = function ( force ) {
// 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 ( force || !OO.ui.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 (T202278)
this.deactivate( true );
}
};
/**
* 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 ) {
if ( !range ) {
return this.focusedNode;
}
const 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
* @throws {Error} If range is inside internal list
*/
ve.ce.Surface.prototype.findBlockSlug = function ( range ) {
if ( !range.isCollapsed() ) {
return null;
}
const node = this.documentView.getBranchNodeFromOffset( range.end );
if ( !node || !node.canHaveChildrenNotContent() ) {
// Node can not have block slugs (only inline slugs)
return null;
}
return node.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 ) {
const documentNode = this.getDocument().getDocumentNode();
// Detect when only a single focusable element is selected
let startNode;
if ( !range.isCollapsed() ) {
startNode = documentNode.getNodeFromOffset( range.start + 1 );
if ( startNode && startNode.isFocusable() ) {
const 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.
*
* @fires ve.ce.Surface#position
*/
ve.ce.Surface.prototype.onModelDocumentUpdate = function () {
if ( this.contentBranchNodeChanged ) {
// Update the selection state from model
this.onModelSelect();
}
// Update the state of the SurfaceObserver
this.surfaceObserver.pollOnceNoCallback();
const wasSynchronizing = !!( this.getModel().synchronizer && this.getModel().synchronizer.applying );
// setTimeout: Wait for other documentUpdate listeners to run before emitting
setTimeout( () => {
this.emit( 'position', wasSynchronizing );
} );
};
/**
* Handle insertionAnnotationsChange events on the surface model.
*
* @param {ve.dm.AnnotationSet} insertionAnnotations
*/
ve.ce.Surface.prototype.onInsertionAnnotationsChange = function () {
const changed = this.renderSelectedContentBranchNode();
if ( !changed ) {
return;
}
// Must re-apply the selection after re-rendering
this.forceShowModelSelection();
this.surfaceObserver.pollOnceNoCallback();
};
/**
* Get the ContentBranchNode containing the selection focus, if any
*
* @return {ve.ce.ContentBranchNode|null} ContentBranchNode containing selection focus, or null
*/
ve.ce.Surface.prototype.getSelectedContentBranchNode = function () {
const selection = this.model.getSelection();
if ( !( selection instanceof ve.dm.LinearSelection ) ) {
return null;
}
const node = this.documentView.getBranchNodeFromOffset( selection.getRange().to );
if ( !node || !( node instanceof ve.ce.ContentBranchNode ) ) {
return null;
}
return node;
};
/**
* Re-render the ContentBranchNode containing the selection focus, if any
*
* @return {boolean} Whether a re-render actually happened
*/
ve.ce.Surface.prototype.renderSelectedContentBranchNode = function () {
const node = this.getSelectedContentBranchNode();
if ( !node ) {
return false;
}
return node.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 ) {
const dmDoc = this.getModel().getDocument();
let insertedText = false,
removedText = false;
if ( newState.contentChanged ) {
if ( this.readOnly ) {
newState.node.renderContents();
this.showModelSelection();
return;
} else {
const 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.some( ( op ) => op.type === 'replace' && op.insert.length );
removedText = transaction.operations.some( ( op ) => op.type === 'replace' && op.remove.length );
}
}
}
if (
!this.readOnly &&
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 )
) ) {
let newSelection;
if ( newState.veRange ) {
if ( newState.veRange.isCollapsed() ) {
const offset = dmDoc.getNearestCursorOffset( newState.veRange.from, 0 );
if ( offset === -1 ) {
// First, if we're in a document which outright doesn't
// have any content to select, don't try to set one. These
// would be niche documents, since slugs normally exist
// and catch those cases.
newSelection = new ve.dm.NullSelection();
// TODO: Unset whatever native selection got us here, to match
// the model state (assuming it is in the CE document)
} else {
// If we're placing the cursor, make sure it winds up in a
// cursorable location. Failure to do this can result in
// strange behavior when inserting content immediately after
// clicking on the surface.
newSelection = new ve.dm.LinearSelection( new ve.Range( offset ) );
}
} else {
newSelection = new ve.dm.LinearSelection( newState.veRange );
}
} else {
newSelection = new ve.dm.NullSelection();
}
this.incRenderLock();
try {
this.changeModel( null, newSelection );
if ( newSelection instanceof ve.dm.LinearSelection && newSelection.isCollapsed() ) {
const blockSlug = this.findBlockSlug( newSelection.getRange() );
if ( blockSlug ) {
// Set the DOM selection, in case the model selection did not change but
// the DOM selection did (T201599).
this.preparePasteTargetForCopy();
this.surfaceObserver.pollOnceNoCallback();
}
}
} finally {
this.decRenderLock();
}
const removedUnicorns = this.cleanupUnicorns( false );
if ( removedUnicorns ) {
this.surfaceObserver.pollOnceNoCallback();
}
// Ensure we don't observe a selection that breaks out of the active node
const activeNode = this.getActiveNode();
const coveringRange = newSelection.getCoveringRange();
if ( activeNode && coveringRange ) {
const nodeRange = activeNode.getRange();
const containsStart = nodeRange.containsRange( new ve.Range( coveringRange.start ) );
const 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.
if ( containsStart !== containsEnd ) {
newSelection = oldState && oldState.veRange ?
new ve.dm.LinearSelection( oldState.veRange ) :
new ve.dm.NullSelection();
// TODO: setTimeout: document purpose
setTimeout( () => {
this.changeModel( null, newSelection );
this.showModelSelection();
} );
}
}
// 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 ) {
this.afterRenderLock( () => {
this.findAndExecuteSequences();
this.maybeSetBreakpoint();
} );
} else if ( removedText ) {
this.afterRenderLock( () => {
this.findAndExecuteSequences( false, true );
this.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?)
this.getModel().breakpoint();
}
};
/**
* Create a slug out of a DOM element
*
* @param {HTMLElement} element Slug element
* @fires ve.ce.Surface#position
*/
ve.ce.Surface.prototype.createSlug = function ( element ) {
const offset = ve.ce.getOffsetOfSlug( element ),
documentModel = this.getModel().getDocument(),
slugHeight = element.scrollHeight;
this.changeModel( ve.dm.TransactionBuilder.static.newFromInsertion(
documentModel, offset, [
{ type: 'paragraph', internal: { generated: 'slug' } },
{ type: '/paragraph' }
]
), new ve.dm.LinearSelection( new ve.Range( offset + 1 ) ) );
// Animate the slug open
const $slug = this.getDocument().getDocumentNode().getNodeFromOffset( offset + 1 ).$element;
const verticalPadding = $slug.innerHeight() - $slug.height();
const targetMargin = $slug.css( 'margin' );
const targetPadding = $slug.css( 'padding' );
$slug.addClass( 've-ce-branchNode-newSlug' ).css( 'min-height', slugHeight - verticalPadding );
requestAnimationFrame( () => {
$slug.addClass( 've-ce-branchNode-newSlug-open' ).css( {
margin: targetMargin,
padding: targetPadding,
'min-height': $slug.css( 'line-height' )
} );
$slug.one( 'transitionend', () => {
this.emit( 'position' );
// Animation finished, cleanup
$slug
.removeClass( 've-ce-branchNode-newSlug ve-ce-branchNode-newSlug-open' )
.css( { margin: '', padding: '', 'min-height': '' } );
} );
} );
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 ) {
// Default to moving start-wards, to mimic typical Chromium behaviour
direction = direction > 0 ? 1 : -1;
if ( this.nativeSelection.rangeCount === 0 ) {
return;
}
let node = this.nativeSelection.focusNode;
let offset = this.nativeSelection.focusOffset;
if ( node.nodeType !== Node.ELEMENT_NODE ) {
return;
}
const previousNode = node.childNodes[ offset - 1 ];
const 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
let 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
} ) );
};
/**
* Find sequence matches at the current surface offset
*
* @param {boolean} [isPaste] Whether this in the context of a paste
* @param {boolean} [isDelete] Whether this is after content being deleted
* @return {ve.ui.SequenceRegistry.Match[]}
*/
ve.ce.Surface.prototype.findMatchingSequences = function ( isPaste, isDelete ) {
const selection = this.getSelection();
if ( !selection.isNativeCursor() ) {
return [];
}
return this.getSurface().sequenceRegistry.findMatching(
this.getModel().getDocument().data,
selection.getModel().getCoveringRange().end,
isPaste,
isDelete
);
};
/**
* Find sequence matches at the current surface offset and execute them
*
* @param {boolean} [isPaste] Whether this in the context of a paste
* @param {boolean} [isDelete] Whether this is after content being deleted
*/
ve.ce.Surface.prototype.findAndExecuteSequences = function ( isPaste, isDelete ) {
this.executeSequences( this.findMatchingSequences( isPaste, isDelete ) );
};
// Deprecated alias
ve.ce.Surface.prototype.checkSequences = ve.ce.Surface.prototype.findAndExecuteSequences;
/**
* Check if any of the previously delayed sequences no longer match with current offset,
* and therefore should be executed.
*/
ve.ce.Surface.prototype.findAndExecuteDelayedSequences = function () {
const sequences = [],
selection = this.getSelection();
let matchingSequences;
if ( this.deactivated || !selection.isNativeCursor() ) {
matchingSequences = [];
} else {
matchingSequences = this.findMatchingSequences();
}
const matchingByName = {};
let i;
for ( i = 0; i < matchingSequences.length; i++ ) {
matchingByName[ matchingSequences[ i ].sequence.getName() ] = matchingSequences[ i ];
}
for ( i = 0; i < this.delayedSequences.length; i++ ) {
const matchingSeq = matchingByName[ this.delayedSequences[ i ].sequence.getName() ];
if (
!matchingSeq ||
matchingSeq.range.start !== this.delayedSequences[ i ].range.start
) {
// This sequence stopped matching; execute it with the previously saved range
this.delayedSequences[ i ].wasDelayed = true;
sequences.push( this.delayedSequences[ i ] );
}
}
// Discard any delayed sequences; they will be checked for again when the user starts typing
this.delayedSequences = [];
this.executeSequences( sequences );
};
// Deprecated alias
ve.ce.Surface.prototype.checkDelayedSequences = ve.ce.Surface.prototype.findAndExecuteDelayedSequences;
/**
* Execute matched sequences
*
* @param {ve.ui.SequenceRegistry.Match[]} sequences
*/
ve.ce.Surface.prototype.executeSequences = function ( sequences ) {
let executed = false;
// sequences.length will likely be 0 or 1 so don't cache
for ( let i = 0; i < sequences.length; i++ ) {
if ( sequences[ i ].sequence.delayed && !sequences[ i ].wasDelayed ) {
// Save the sequence and match range for execution later
this.delayedSequences.push( sequences[ i ] );
} else {
executed = sequences[ i ].sequence.execute( this.surface, sequences[ i ].range ) || executed;
}
}
if ( executed ) {
this.delayedSequences = [];
this.showModelSelection();
}
};
/**
* See if the just-entered content fits our criteria for setting a history breakpoint
*/
ve.ce.Surface.prototype.maybeSetBreakpoint = function () {
const 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.
const offset = selection.getModel().getCoveringRange().end - 1;
const data = this.getModel().getDocument().data;
if ( data.getWordRange( offset ).end === offset ) {
this.getModel().breakpoint();
}
};
/**
* Handle window resize event.
*
* @param {jQuery.Event} e Window resize event
* @fires ve.ce.Surface#position
*/
ve.ce.Surface.prototype.onWindowResize = function () {
this.emit( 'position' );
if ( OO.ui.isMobile() && !ve.init.platform.constructor.static.isIos() ) {
// A resize event on mobile is probably a keyboard open/close (or rotate).
// Either way, ensure the cursor is still visible (T204388).
// On iOS, window is resized whenever you start scrolling down and the "address bar" is
// minimized. So don't scroll back up…
this.getSurface().scrollSelectionIntoView();
}
};
/* Relocation */
/**
* Start a relocation action.
*
* @fires ve.ce.Surface#relocationStart
*/
ve.ce.Surface.prototype.startRelocation = function () {
// Cache the selection and selectedNode when the drag starts, to
// avoid having to recompute them while dragging.
this.relocatingSelection = this.getModel().getSelection();
this.relocatingNode = this.getModel().getSelectedNode();
this.emit( 'relocationStart' );
};
/**
* Complete a relocation action.
*
* @fires ve.ce.Surface#relocationEnd
*/
ve.ce.Surface.prototype.endRelocation = function () {
this.relocatingSelection = null;
this.relocatingNode = null;
// Trigger a drag leave event to clear markers
this.onDocumentDragLeave();
this.emit( 'relocationEnd' );
};
/**
* 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 ) {
const 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 () {
// 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).
const range = this.model.getSelection().getRange();
let 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 expands outside the active 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 () {
const activeNode = this.getActiveNode(),
activeRange = activeNode && activeNode.getRange();
let currentRange;
if (
activeRange &&
( currentRange = ve.ce.veRangeFromSelection( this.nativeSelection ) ) &&
( !currentRange.isCollapsed() || activeNode.trapsCursor() ) &&
!activeRange.containsRange( currentRange )
) {
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 ) {
const activeNode = this.getActiveNode(),
forward = direction > 0;
let 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 ) {
const 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 () {
this.updateCursorHolderBefore();
this.updateCursorHolderAfter();
};
/**
* Insert cursor holder between selection focus and subsequent ce=false node, if required as a cursor target
*/
ve.ce.Surface.prototype.updateCursorHolderBefore = function () {
const doc = this.getElementDocument(),
nodeBelow = this.findAdjacentUneditableBranchNode( 1 );
if (
!( nodeBelow === null && this.cursorHolderBefore === null ) &&
!( nodeBelow && this.cursorHolderBefore && nodeBelow.nextSibling === this.cursorHolderBefore )
) {
// cursorHolderBefore is not correct for nodeBelow; update it
this.removeCursorHolderBefore();
if ( nodeBelow ) {
this.cursorHolderBefore = doc.importNode( this.constructor.static.cursorHolderTemplate, true );
this.cursorHolderBefore.classList.add( 've-ce-cursorHolder-before' );
if ( ve.inputDebug ) {
this.cursorHolderBefore.classList.add( 've-ce-cursorHolder-debug' );
}
// this.cursorHolderBefore is a Node
// eslint-disable-next-line no-jquery/no-append-html
$( nodeBelow ).before( this.cursorHolderBefore );
}
}
};
/**
* Insert cursor holder between selection focus and preceding ce=false node, if required as a cursor target
*/
ve.ce.Surface.prototype.updateCursorHolderAfter = function () {
const doc = this.getElementDocument(),
nodeAbove = this.findAdjacentUneditableBranchNode( -1 );
if (
!( nodeAbove === null && this.cursorHolderAfter === null ) &&
!( nodeAbove && this.cursorHolderAfter && nodeAbove.nextSibling === this.cursorHolderAfter )
) {
// cursorHolderAfter is not correct for nodeAbove; update it
this.removeCursorHolderAfter();
if ( nodeAbove ) {
this.cursorHolderAfter = doc.importNode( this.constructor.static.cursorHolderTemplate, true );
this.cursorHolderAfter.classList.add( 've-ce-cursorHolder-after' );
if ( ve.inputDebug ) {
this.cursorHolderAfter.classList.add( 've-ce-cursorHolder-debug' );
}
// this.cursorHolderAfter is a Node
// eslint-disable-next-line no-jquery/no-append-html
$( nodeAbove ).after( this.cursorHolderAfter );
}
}
};
/**
* Remove cursor holders, if they exist
*/
ve.ce.Surface.prototype.removeCursorHolders = function () {
this.removeCursorHolderBefore();
this.removeCursorHolderAfter();
};
/**
* Remove cursorHolderBefore, if it exists
*/
ve.ce.Surface.prototype.removeCursorHolderBefore = function () {
if ( this.cursorHolderBefore ) {
if ( this.cursorHolderBefore.parentNode ) {
this.cursorHolderBefore.parentNode.removeChild( this.cursorHolderBefore );
}
this.cursorHolderBefore = null;
}
};
/**
* Remove cursorHolderAfter, if it exists
*/
ve.ce.Surface.prototype.removeCursorHolderAfter = function () {
if ( this.cursorHolderAfter ) {
if ( this.cursorHolderAfter.parentNode ) {
this.cursorHolderAfter.parentNode.removeChild( this.cursorHolderAfter );
}
this.cursorHolderAfter = null;
}
};
/**
* Handle insertion of content.
*/
ve.ce.Surface.prototype.handleInsertion = function () {
const surfaceModel = this.getModel(),
fragment = surfaceModel.getFragment();
let selection = this.getSelection();
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;
}
const range = selection.getModel().getRange();
// Handles removing expanded selection before inserting new text
if (
this.selectionSplitsNailedAnnotation() ||
( !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();
this.surfaceObserver.clear();
this.storeKeyDownState( this.keyDownState.event );
this.surfaceObserver.stopTimerLoop();
this.surfaceObserver.pollOnce();
}
};
/**
* Place the selection at the next content offset which is selectable.
*
* For the purposes of this method, offsets within ve.ce.ActiveNode's
* are not considered selectable when they are not active.
*
* @param {number} startOffset Offset to start from
* @param {number} direction Search direction, -1 for left and 1 for right
* @param {number} [endOffset] End offset to stop searching at
* @return {number} Content offset, or -1 of not found
*/
ve.ce.Surface.prototype.getRelativeSelectableContentOffset = function ( startOffset, direction, endOffset ) {
const documentView = this.getDocument(),
linearData = this.getModel().getDocument().data;
let nextOffset = linearData.getRelativeOffset(
startOffset,
direction,
( offset ) => {
// Check we are at a content offset, according to the model
if ( !linearData.isContentOffset( offset ) ) {
return false;
}
const branchNode = documentView.getBranchNodeFromOffset( offset );
if ( !branchNode ) {
// This shouldn't happen in a content offset
return false;
}
// traverseUpstream stops on a false return
const noAutoFocusContainer = branchNode.traverseUpstream( ( node ) => node.autoFocus() );
if ( noAutoFocusContainer ) {
// Don't try to place the cursor in a node which has a container with autoFocus set to false
return false;
}
return true;
}
);
if (
endOffset !== undefined && (
( direction > 0 && nextOffset > endOffset ) ||
( direction < 0 && nextOffset < endOffset )
)
) {
nextOffset = -1;
}
return nextOffset;
};
/**
* Select the offset returned by #getRelativeSelectableContentOffset
*
* @param {number} startOffset
* @param {number} direction
* @param {number} [endOffset]
*/
ve.ce.Surface.prototype.selectRelativeSelectableContentOffset = function ( startOffset, direction, endOffset ) {
const offset = this.getRelativeSelectableContentOffset( startOffset, direction, endOffset );
if ( offset !== -1 ) {
// Found an offset
this.getModel().setLinearSelection( new ve.Range( offset ) );
} else {
// Nowhere sensible to put the cursor
this.getModel().setNullSelection();
}
};
/**
* Select the first content offset which is selectable.
*
* See #selectRelativeSelectableContentOffset for the definition of selectable.
*/
ve.ce.Surface.prototype.selectFirstSelectableContentOffset = function () {
this.selectRelativeSelectableContentOffset(
this.getModel().getAttachedRoot().getOffset(),
1
);
};
/**
* Select the last content offset which is selectable.
*
* See #selectRelativeSelectableContentOffset for the definition of selectable.
*/
ve.ce.Surface.prototype.selectLastSelectableContentOffset = function () {
this.selectRelativeSelectableContentOffset(
this.getModel().getDocument().getDocumentRange().end,
-1
);
};
/**
* 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.
*
* If the document doesn't contain any content offsets (e.g. it only contains
* a transclusion), the returned range will cover the entire document. If the
* single element is particularly large this might be very distinct from the
* visible content.
*
* @param {boolean} [covering] Get a range which fully covers the viewport, otherwise
* get a range which is full contained within the viewport.
* @param {number} [padding=0] Increase computed size of viewport by this amount at the top and bottom
* @return {ve.Range|null} Range covering data visible in the viewport, null if the surface is not attached
*/
ve.ce.Surface.prototype.getViewportRange = function ( covering, padding ) {
const documentModel = this.getModel().getDocument(),
data = documentModel.data,
dimensions = this.surface.getViewportDimensions();
if ( !dimensions ) {
// Surface is not attached
return null;
}
padding = padding || 0;
const top = Math.max( 0, dimensions.top - padding );
const bottom = dimensions.bottom + ( padding * 2 );
const documentRange = this.attachedRoot === this.getDocument().getDocumentNode() ?
this.getModel().getDocument().getDocumentRange() :
this.attachedRoot.getRange();
const highestIgnoreChildrenNode = ( childNode ) => {
let ignoreChildrenNode = null;
childNode.traverseUpstream( ( node ) => {
if ( node.shouldIgnoreChildren() ) {
ignoreChildrenNode = node;
}
} );
return ignoreChildrenNode;
};
/**
* @param {number} offset Vertical offset to find
* @param {ve.Range} range Document range
* @param {string} side Side of the viewport align with, 'bottom' or 'top'
* @param {boolean} isStart Find the start of the range, otherwise the end
* @return {number} DM offset
*/
const binarySearch = ( offset, range, side, isStart ) => {
let start = range.start,
end = range.end,
lastLength = Infinity;
while ( range.getLength() < lastLength ) {
lastLength = range.getLength();
let mid = Math.round( ( range.start + range.end ) / 2 );
let midNode = documentModel.documentNode.getNodeFromOffset( mid );
const ignoreChildrenNode = highestIgnoreChildrenNode( midNode );
if ( ignoreChildrenNode ) {
const nodeRange = ignoreChildrenNode.getOuterRange();
mid = side === 'top' ? nodeRange.end : nodeRange.start;
} else {
mid = data.getNearestContentOffset( mid );
if ( mid === -1 ) {
// There is no content offset available in this document.
// Return early, with a range that'll be covering the entire document.
return isStart ? start : end;
}
// Never search outisde the original range
mid = Math.max( Math.min( mid, range.end ), range.start );
}
let rect = null;
while ( !rect ) {
// Try to create a selection of one character for more reliable
// behaviour when text wraps.
let contentRange;
if ( data.isContentOffset( mid + 1 ) ) {
contentRange = new ve.Range( mid, mid + 1 );
} else if ( data.isContentOffset( mid - 1 ) ) {
contentRange = new ve.Range( mid - 1, mid );
} else {
contentRange = new ve.Range( mid );
}
rect = this.getSelection( new ve.dm.LinearSelection( contentRange ) ).getSelectionBoundingRect();
// Node at contentRange is not rendered, find rendered parent
if ( !rect ) {
if ( !midNode ) {
throw new Error( 'Offset has no rendered node container' );
}
const midNodeRange = midNode.getOuterRange();
// Find the nearest content offset outside the invisible node
mid = side === 'top' ?
data.getRelativeContentOffset( midNodeRange.end, 1 ) :
data.getRelativeContentOffset( midNodeRange.start, -1 );
// Never search outisde the original range
mid = Math.max( Math.min( mid, range.end ), range.start );
// Check we didn't end up inside the invisible node again
if ( midNodeRange.containsRange( new ve.Range( mid ) ) ) {
return isStart ? start : end;
}
// Ensure we check parent in next iteration
midNode = midNode.parent;
}
}
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, covering ? 'bottom' : 'top', true ),
binarySearch( bottom, documentRange, covering ? 'top' : 'bottom', false )
);
};
/**
* Move the selection to the first visible "start content offset" in the viewport
*
* Where "start content offset" is the first offset within a content branch node.
*
* The following are used as fallbacks when such offsets can't be found:
* - The first visible content offset (at any position in the CBN)
* - The first selectable content offset in the doc (if fallbackToFirst is set)
*
* @param {boolean} [fallbackToFirst] Whether to select the first content offset if a visible offset can't be found
*/
ve.ce.Surface.prototype.selectFirstVisibleStartContentOffset = function ( fallbackToFirst ) {
// When scrolled. add about one line height of padding so the browser doesn't try to scroll the line above the cursor into view
const dimensions = this.surface.getViewportDimensions();
const offset = dimensions && dimensions.top ? -20 : 0;
const visibleRange = this.getViewportRange( false, offset );
if ( visibleRange ) {
const model = this.getModel();
let startNodeOffset = -1;
const contentOffset = this.getRelativeSelectableContentOffset( Math.max( visibleRange.start - 1, 0 ), 1, visibleRange.end );
if ( contentOffset !== -1 ) {
const branchNodeRange = model.getDocument().getBranchNodeFromOffset( contentOffset ).getRange();
if ( contentOffset === branchNodeRange.start ) {
// We luckily landed and the start of a branch node
startNodeOffset = contentOffset;
} else {
// We are in the middle of a branch node, move to the end, then find the next
// content offset, which should be the start of the next CBN
const nextContentOffset = this.getRelativeSelectableContentOffset( branchNodeRange.end, 1, visibleRange.end );
// If there isn't a content offset after the end of the current node, fallback to the mid-node contentOffset
startNodeOffset = nextContentOffset !== -1 ? nextContentOffset : contentOffset;
}
}
if ( startNodeOffset !== -1 ) {
// Found an offset
model.setLinearSelection( new ve.Range( startNodeOffset ) );
} else {
// Nowhere sensible to put the cursor
model.setNullSelection();
}
}
if ( fallbackToFirst && this.getSelection().getModel().isNull() ) {
// If a visible range couldn't be determined, or a selection couldn't
// be made for some reason, fall back to the actual first content offset.
this.selectFirstSelectableContentOffset();
}
};
/**
* Apply a DM selection to the DOM, even if the old DOM selection is different but DM-equivalent
*
* @return {boolean} Whether the selection actually changed
*/
ve.ce.Surface.prototype.forceShowModelSelection = function () {
return this.showModelSelection( true );
};
/**
* Apply a DM selection to the DOM
*
* @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 ) {
if ( this.deactivated ) {
// setTimeout: Defer until view has updated
setTimeout( this.updateDeactivatedSelection.bind( this ) );
return false;
}
const selection = this.getSelection();
let modelRange;
if ( selection.getModel().isNull() ) {
if (
!this.nativeSelection.focusNode ||
!this.$element[ 0 ].contains( this.nativeSelection.focusNode )
) {
// Native selection is already null, or outside the document
return false;
}
modelRange = null;
} else {
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.$attachedRootNode.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.
let impliedModelRange;
try {
impliedModelRange = new ve.Range(
ve.ce.getOffset(
this.nativeSelection.anchorNode,
this.nativeSelection.anchorOffset
),
ve.ce.getOffset(
this.nativeSelection.focusNode,
this.nativeSelection.focusOffset
)
);
} catch ( e ) {
// The nativeSelection appears to end up outside the documentNode
// sometimes, e.g. when deleting in Safari (T306218)
impliedModelRange = null;
}
if ( modelRange.equals( impliedModelRange ) ) {
// Current native selection fits model range; don't change
return false;
}
}
}
const 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 ) {
const sel = this.nativeSelection;
let extendedBackwards = false,
newSel = selection;
if ( newSel.equalsSelection( sel ) ) {
this.updateActiveAnnotations();
return false;
}
if ( !newSel.getNativeRange( this.getElementDocument() ) ) {
// You can still set a linear selection if the document doesn't have any cursorable positions.
// That's when you end up here.
sel.removeAllRanges();
return true;
}
if ( newSel.isBackwards ) {
if ( ve.supportsSelectionExtend ) {
// Set the range at the anchor, and extend backwards to the focus
const 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.updateActiveAnnotations();
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
const $focusTarget = $( newSel.focusNode ).closest( '[contenteditable=true]' );
if ( $focusTarget.get( 0 ) === this.getElementDocument().activeElement ) {
// Already focused, do nothing.
} else if ( $focusTarget.length && !OO.ui.contains( $focusTarget.get( 0 ), this.getElementDocument().activeElement ) ) {
// Check $focusTarget is non-empty (T259531)
// 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.
const scrollTop = this.surface.$scrollContainer.scrollTop();
$focusTarget.trigger( 'focus' );
// Support: Safari
// Safari tries to scroll the CE surface into view when focusing,
// causing unwanted page jumps (T258847)
this.surface.$scrollContainer.scrollTop( scrollTop );
} else {
// Scroll the node into view
ve.scrollIntoView(
$( newSel.focusNode ).closest( '*' ).get( 0 )
);
}
this.updateActiveAnnotations();
return true;
};
/**
* Update the activeAnnotations property and apply CSS classes accordingly
*
* An active annotation is one containing the DOM cursor, which may not be well
* defined at annotation boundaries, except for links which use nails.
*
* Also the order of .activeAnnotations may not be well defined.
*
* @param {boolean|Node} [fromModelOrNode] If `true`, gather annotations from the model,
* instead of the cusor focus point. If a Node is passed, gather annotations from that node.
* @fires ve.dm.Surface#contextChange
*/
ve.ce.Surface.prototype.updateActiveAnnotations = function ( fromModelOrNode ) {
const canBeActive = function ( view ) {
return view.canBeActive();
};
let activeAnnotations;
if ( fromModelOrNode === true ) {
activeAnnotations = this.annotationsAtModelSelection( canBeActive );
} else if ( fromModelOrNode instanceof Node ) {
activeAnnotations = this.annotationsAtNode( fromModelOrNode, canBeActive );
} else {
activeAnnotations = this.annotationsAtFocus( canBeActive );
}
let changed = false;
// Iterate over previously active annotations
this.activeAnnotations.forEach( ( annotation ) => {
// If not in the new list, turn off
if ( activeAnnotations.indexOf( annotation ) === -1 ) {
annotation.$element.removeClass( 've-ce-annotation-active' );
changed = true;
}
} );
// Iterate over newly active annotations
activeAnnotations.forEach( ( annotation ) => {
// If not in the old list, turn on
if ( this.activeAnnotations.indexOf( annotation ) === -1 ) {
annotation.$element.addClass( 've-ce-annotation-active' );
changed = true;
}
} );
if ( changed ) {
this.activeAnnotations = activeAnnotations;
this.model.emit( 'contextChange' );
}
};
/**
* Update the selection to contain the contents of a node
*
* @param {HTMLElement} node
* @param {string} [collapse] Collaspse to 'start' or 'end'
* @return {boolean} Whether the selection changed
*/
ve.ce.Surface.prototype.selectNodeContents = function ( node, collapse ) {
if ( !node ) {
return false;
}
let anchor = ve.ce.nextCursorOffset( node.childNodes[ 0 ] );
let focus = ve.ce.previousCursorOffset( node.childNodes[ node.childNodes.length - 1 ] );
if ( collapse === 'start' ) {
focus = anchor;
} else if ( collapse === 'end' ) {
anchor = focus;
}
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
} ) );
};
/**
* Select the inner contents of the closest annotation
*
* @param {Function} [filter] Function to filter view nodes by.
*/
ve.ce.Surface.prototype.selectAnnotation = function ( filter ) {
const annotations = this.annotationsAtModelSelection( filter );
if ( annotations.length ) {
this.selectNodeContents( annotations[ 0 ].$element[ 0 ] );
}
};
/**
* Get the annotation views at the current model selection
*
* TODO: This doesn't work for annotations that span fewer
* than one character, as getNodeAndOffset will never return
* an offset inside that annotation.
*
* @param {Function} [filter] Function to filter view nodes by.
* @param {number} [offset] Model offset. Defaults to start of current selection.
* @return {ve.ce.Annotation[]} Annotation views
*/
ve.ce.Surface.prototype.annotationsAtModelSelection = function ( filter, offset ) {
const documentRange = this.getModel().getDocument().getDocumentRange();
if ( offset === undefined ) {
offset = this.getModel().getSelection().getCoveringRange().start;
}
// getNodeAndOffset can throw when offset is out of bounds (T262354)
// and other undiagnosed situations (T136780, T262487, T259154, T262303)
// TODO: For annotation boundaries we have to search one place left and right
// to find the text inside the annotation. This will give too many results for
// adjancent annotations, and will fail for one character annotations. (T221967)
let nodeAndOffset;
let annotations = [];
if ( offset > documentRange.start ) {
try {
nodeAndOffset = this.getDocument().getNodeAndOffset( offset - 1 );
} catch ( e ) {
nodeAndOffset = null;
}
annotations = nodeAndOffset ? this.annotationsAtNode( nodeAndOffset.node, filter ) : [];
}
if ( offset < documentRange.end ) {
try {
nodeAndOffset = this.getDocument().getNodeAndOffset( offset + 1 );
} catch ( e ) {
nodeAndOffset = null;
}
annotations = OO.unique( annotations.concat( nodeAndOffset ? this.annotationsAtNode( nodeAndOffset.node, filter ) : [] ) );
}
return annotations;
};
/**
* Get the annotation views containing the cursor focus
*
* @param {Function} [filter] Function to filter view nodes by.
* @return {ve.ce.Annotation[]} Annotation views
*/
ve.ce.Surface.prototype.annotationsAtFocus = function ( filter ) {
return this.annotationsAtNode( this.nativeSelection.focusNode, filter );
};
/**
* Get the annotation views containing the cursor focus
*
* Only returns annotations which can be active.
*
* @param {Node} node Node at which to search for annotations
* @param {Function} [filter] Function to filter view nodes by. Takes one argument which
* is the view node and returns a boolean.
* @return {ve.ce.Annotation[]} Annotation views
*/
ve.ce.Surface.prototype.annotationsAtNode = function ( node, filter ) {
const annotations = [];
$( node ).parents( '.ve-ce-annotation' ).addBack( '.ve-ce-annotation' ).each( ( i, element ) => {
const view = $( element ).data( 'view' );
if ( view && ( !filter || filter( view ) ) ) {
annotations.push( view );
}
} );
return annotations;
};
/**
* 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).
*
* @param {ve.Range|null} range Range to get selection for
* @return {ve.SelectionState} The selection
*/
ve.ce.Surface.prototype.getSelectionState = function ( range ) {
const dmDoc = this.getModel().getDocument();
if ( !range ) {
return ve.SelectionState.static.newNullSelection();
}
// Anchor/focus at the nearest correct position in the direction that
// grows the selection.
const from = dmDoc.getNearestCursorOffset( range.from, range.isBackwards() ? 1 : -1 );
if ( from === -1 ) {
return ve.SelectionState.static.newNullSelection();
}
let anchor;
try {
anchor = this.documentView.getNodeAndOffset( from );
} catch ( e ) {
return ve.SelectionState.static.newNullSelection();
}
let focus;
if ( range.isCollapsed() ) {
focus = anchor;
} else {
const to = dmDoc.getNearestCursorOffset( range.to, range.isBackwards() ? -1 : 1 );
if ( to === -1 ) {
return ve.SelectionState.static.newNullSelection();
}
try {
focus = this.documentView.getNodeAndOffset( to );
} catch ( e ) {
return ve.SelectionState.static.newNullSelection();
}
}
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 ) {
let 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.
*
* @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.
*
* @return {ve.ui.Surface}
*/
ve.ce.Surface.prototype.getSurface = function () {
return this.surface;
};
/**
* Get the surface model.
*
* @return {ve.dm.Surface} Surface model
*/
ve.ce.Surface.prototype.getModel = function () {
return this.model;
};
/**
* Get the document view.
*
* @return {ve.ce.Document} Document view
*/
ve.ce.Surface.prototype.getDocument = function () {
return this.documentView;
};
/**
* Check whether there are any render locks
*
* @return {boolean} Render is locked
*/
ve.ce.Surface.prototype.isRenderingLocked = function () {
return this.renderLocks > 0 && !this.readOnly;
};
/**
* Add a single render lock (to disable rendering)
*/
ve.ce.Surface.prototype.incRenderLock = function () {
this.renderLocks++;
};
/**
* Remove a single render lock
*/
ve.ce.Surface.prototype.decRenderLock = function () {
this.renderLocks--;
};
/**
* Escape the current render lock
*
* @param {Function} callback Function to run after 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
*
* @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|null} node The node claiming the unicorn, null to release (by rerendering) without claiming
*/
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, without rerendering
*
* If the node doesn't hold the current unicorn, nothing happens.
*
* @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 active 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 () {
if ( !( this.model.selection instanceof ve.dm.LinearSelection ) ) {
return [];
}
let models = this.model.getFragment().getSelectedModels();
if ( this.model.selection.isCollapsed() ) {
const fragmentAfter = this.model.getFragment( new ve.dm.LinearSelection(
new ve.Range(
this.model.selection.range.start,
this.model.selection.range.start + 1
)
) );
models = OO.unique( [].concat(
models,
fragmentAfter.getSelectedModels()
) );
}
const activeModels = this.activeAnnotations.map( ( view ) => view.getModel() );
if ( this.model.sourceMode ) {
return models;
} else {
return models.filter( ( annModel ) => {
// If the model is an annotation that can be active, only show it if it *is* active
if ( annModel instanceof ve.dm.Annotation && ve.ce.annotationFactory.canAnnotationBeActive( annModel.getType() ) ) {
return activeModels.indexOf( annModel ) !== -1;
}
return true;
} );
}
};
/**
* Tests whether the selection covers part but not all of a nailed annotation
*
* @return {boolean} True if a nailed annotation is split either at the focus or at the anchor (or both)
*/
ve.ce.Surface.prototype.selectionSplitsNailedAnnotation = function () {
return ve.ce.nailedAnnotationAt( this.nativeSelection.anchorNode ) !==
ve.ce.nailedAnnotationAt( this.nativeSelection.focusNode );
};
/**
* Called when the synchronizer receives a remote author selection or name change
*
* @param {number} authorId The author ID
*/
ve.ce.Surface.prototype.onSynchronizerAuthorUpdate = function ( authorId ) {
this.paintAuthor( authorId );
};
/**
* Called when the synchronizer receives a remote author disconnect
*
* @param {number} authorId The author ID
*/
ve.ce.Surface.prototype.onSynchronizerAuthorDisconnect = function ( authorId ) {
this.drawSelections( 'otherUserSelection-' + authorId, [] );
this.drawSelections( 'otherUserCursor-' + authorId, [] );
};
/**
* Called when the synchronizer reconnects and their is a server doc ID mismatch
*/
ve.ce.Surface.prototype.onSynchronizerWrongDoc = function () {
OO.ui.alert(
ve.msg( 'visualeditor-rebase-missing-document-error' ),
{ title: ve.msg( 'visualeditor-rebase-missing-document-title' ) }
);
};
/**
* Handle pause events from the synchronizer
*
* Drops the opacity of the surface to indicate that no updates are
* being received from other users.
*/
ve.ce.Surface.prototype.onSynchronizerPause = function () {
this.$element.toggleClass( 've-ce-surface-paused', !!this.model.synchronizer.paused );
};
/**
* Paint a remote author's current selection, as stored in the synchronizer
*
* @param {number} authorId The author ID
*/
ve.ce.Surface.prototype.paintAuthor = function ( authorId ) {
const synchronizer = this.model.synchronizer,
authorData = synchronizer.getAuthorData( authorId ),
selection = synchronizer.authorSelections[ authorId ];
if ( !authorData || !selection || authorId === synchronizer.getAuthorId() ) {
return;
}
const color = '#' + authorData.color;
if ( !Object.prototype.hasOwnProperty.call( this.userSelectionDeactivate, authorId ) ) {
this.userSelectionDeactivate[ authorId ] = ve.debounce( () => {
// TODO: Transition away the user label when inactive, maybe dim selection
if ( Object.prototype.hasOwnProperty.call( this.drawnSelections, 'otherUserSelection-' + authorId ) ) {
this.drawnSelections[ 'otherUserSelection-' + authorId ].$selections.addClass( 've-ce-surface-selections-otherUserSelection-inactive' );
}
if ( Object.prototype.hasOwnProperty.call( this.drawnSelections, 'otherUserCursor-' + authorId ) ) {
this.drawnSelections[ 'otherUserCursor-' + authorId ].$selections.addClass( 've-ce-surface-selections-otherUserCursor-inactive' );
}
}, 5000 );
}
this.userSelectionDeactivate[ authorId ]();
if ( !selection || selection.isNull() ) {
this.drawSelections( 'otherUserSelection-' + authorId, [] );
this.drawSelections( 'otherUserCursor-' + authorId, [] );
return;
}
this.drawSelections(
'otherUserSelection-' + authorId,
[ ve.ce.Selection.static.newFromModel( selection, this ) ],
{
wrapperClass: 've-ce-surface-selections-otherUserSelection',
color: color
}
);
const cursorSelection = selection instanceof ve.dm.LinearSelection && this.getFocusedNode( selection.getRange() ) ?
selection : selection.collapseToTo();
this.drawSelections(
'otherUserCursor-' + authorId,
[ ve.ce.Selection.static.newFromModel( cursorSelection, this ) ],
{
wrapperClass: 've-ce-surface-selections-otherUserCursor',
color: color,
// Label is attached to cursor for 100% opacity, but it should probably be attached
// to the selection, so the cursor can be selectively rendered just for LinearSelection's.
label: authorData.name
}
);
};
/**
* Respond to a position event on this surface
*/
ve.ce.Surface.prototype.onPosition = function () {
this.redrawSelections();
if ( this.model.synchronizer ) {
// Defer to allow surface synchronizer to adjust for transactions
setTimeout( () => {
const authorSelections = this.model.synchronizer.authorSelections;
for ( const authorId in authorSelections ) {
this.onSynchronizerAuthorUpdate( +authorId );
}
} );
}
};
/**
* Handler for mutation observer
*
* Identifies deleted DOM nodes, and finds and deletes corresponding model structural nodes.
* Mutation observers run asynchronously (on the microtask queue) so the current document state
* may differ from when the mutations happened. Therefore this handler rechecks node attachment,
* document ranges etc.
*
* @param {MutationRecord[]} mutationRecords Records of the mutations observed
*/
ve.ce.Surface.prototype.afterMutations = function ( mutationRecords ) {
const removedNodes = [];
mutationRecords.forEach( ( mutationRecord ) => {
if ( !mutationRecord.removedNodes ) {
return;
}
mutationRecord.removedNodes.forEach( ( removedNode ) => {
const view = $.data( removedNode, 'view' );
if ( view && view.isContent && !view.isContent() ) {
removedNodes.push( view );
}
} );
mutationRecord.addedNodes.forEach( ( addedNode ) => {
const view = $.data( addedNode, 'view' );
if ( view && view.isContent && !view.isContent() ) {
const idx = removedNodes.indexOf( view );
if ( idx !== -1 ) {
// This is a move, not a removal. See T365052#9811638
removedNodes.splice( idx, 1 );
}
}
} );
} );
const removals = removedNodes.map( ( node ) => ( { node: node, range: node.getOuterRange() } ) );
removals.sort( ( x, y ) => x.range.start - y.range.start );
for ( let i = 0, iLen = removals.length; i < iLen; i++ ) {
// Remove any overlapped range (which in a tree must be a nested range)
if ( i > 0 && removals[ i ].range.start < removals[ i - 1 ].range.end ) {
removals.splice( i, 1 );
i--;
continue;
}
}
removals.forEach( ( removal ) => {
const tx = ve.dm.TransactionBuilder.static.newFromRemoval(
this.getModel().getDocument(),
removal.node.getOuterRange()
);
this.getModel().change( tx );
} );
};