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