/*!
* VisualEditor ContentEditable ClipboardHandler class.
*
* @copyright See AUTHORS.txt
*/
/**
* Clipboard handler
*
* Handles copy and paste events on the surface.
*
* @param {ve.ce.Surface} surface
*/
ve.ce.ClipboardHandler = function VeCeClipboardHandler( surface ) {
// Parent constructor
ve.ce.ClipboardHandler.super.call( this );
this.surface = surface;
this.clipboard = null;
this.clipboardId = ve.init.platform.generateUniqueId();
this.clipboardIndex = 0;
this.pasting = false;
this.beforePasteAnnotationsAtFocus = [];
this.copying = false;
this.pasteSpecial = false;
this.middleClickPasting = false;
this.middleClickTargetOffset = false;
this.$element
.add( this.getSurface().$highlights )
.add( this.getSurface().$attachedRootNode )
.on( {
cut: this.onCut.bind( this ),
copy: this.onCopy.bind( this ),
paste: this.onPaste.bind( this )
} );
this.$element
.addClass( 've-ce-surface-clipboardHandler' )
// T283853
.attr( 'aria-hidden', true )
.prop( {
tabIndex: -1,
contentEditable: 'true'
} );
};
/* Inheritance */
OO.inheritClass( ve.ce.ClipboardHandler, OO.ui.Element );
/* 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.ClipboardHandler.static.unsafeAttributes = [
// Support: Firefox
// RDFa: Firefox ignores these
'about',
'content',
'datatype',
'property',
'rel',
'resource',
'rev',
'typeof',
// CSS: Values are often added or modified
'style'
];
/**
* Private (x), vendor-specific (vnd) MIME type for clipboard key data
*
* @type {string}
*/
ve.ce.ClipboardHandler.static.clipboardKeyMimeType = 'application/x-vnd.wikimedia.visualeditor.clipboardkey';
/**
* Paste source detectors examine a clipboardData DataTransfer object
* and identify the source of the copied data.
*
* @type {Object}
*/
ve.ce.ClipboardHandler.static.pasteSourceDetectors = {
googleDocs: ( clipboardData ) => clipboardData.types.some( ( type ) => type.startsWith( 'application/x-vnd.google-docs' ) ) ||
clipboardData.getData( 'text/html' ).match( /id=['"]?docs-internal-guid/i ),
libreOffice: ( clipboardData ) => clipboardData.getData( 'text/html' ).match( /content=['"]?LibreOffice/i ),
microsoftOffice: ( clipboardData ) => {
const html = clipboardData.getData( 'text/html' );
// Word365 (Desktop)
return html.match( /content=Word.Document/i ) ||
// Word365 (web)
( html.match( /data-contrast=["']/i ) && html.includes( 'TextRun' ) );
},
plainText: ( clipboardData ) => clipboardData.types.length === 1 && clipboardData.types[ 0 ] === 'text/plain'
};
/* 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.ClipboardHandler.static.getClipboardHash = function ( $elements, 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 */
/**
* Get the handled surface
*
* @return {ve.ce.Surface}
*/
ve.ce.ClipboardHandler.prototype.getSurface = function () {
return this.surface;
};
/**
* Handle cut events.
*
* @param {jQuery.Event} e Cut event
*/
ve.ce.ClipboardHandler.prototype.onCut = function ( e ) {
const selection = this.getSurface().getModel().getSelection();
if ( selection.isCollapsed() ) {
return;
}
this.onCopy( e );
// setTimeout: postpone until after the setTimeout in onCopy
setTimeout( () => {
// Trigger a fake backspace to remove the content: this behaves differently based on the selection,
// e.g. in a TableSelection.
ve.ce.keyDownHandlerFactory.executeHandlersForKey( OO.ui.Keys.BACKSPACE, selection.getName(), this.getSurface(), e );
} );
};
/**
* Handle copy (including cut) and dragstart events.
*
* @param {jQuery.Event} e Copy event
* @param {ve.dm.Selection} selection Optional selection to simulate a copy on
*/
ve.ce.ClipboardHandler.prototype.onCopy = function ( e, selection ) {
// Copy or cut, but not dragstart
const isClipboard = e.type === 'copy' || e.type === 'cut',
htmlDoc = this.getSurface().getModel().getDocument().getHtmlDocument(),
clipboardData = isClipboard ? e.originalEvent.clipboardData : e.originalEvent.dataTransfer;
selection = selection || this.getSurface().getModel().getSelection();
this.$element.empty();
if ( selection.isCollapsed() ) {
return;
}
const slice = this.getSurface().getModel().getDocument().shallowCloneFromSelection( selection );
// Clone the elements in the slice
slice.data.cloneElements( true );
ve.dm.converter.getDomSubtreeFromModel( slice, this.$element[ 0 ], ve.dm.Converter.static.CLIPBOARD_MODE );
// Some browsers strip out spans when they match the styling of the
// clipboard handler element (e.g. plain spans) so we must protect against this
// by adding a dummy class, which we can remove after paste.
this.$element.find( 'span' ).addClass( 've-pasteProtect' );
// When paste has no text content browsers do extreme normalization…
if ( this.$element.text() === '' ) {
// …so put nbsp's in empty leaves
// eslint-disable-next-line no-jquery/no-sizzle
this.$element.find( '*:not( :has( * ) )' ).text( '\u00a0' );
}
// Resolve attributes (in particular, expand 'href' and 'src' using the right base)
ve.resolveAttributes(
this.$element[ 0 ],
htmlDoc,
ve.dm.Converter.static.computedAttributes
);
// Support: Firefox
// Some attributes (e.g RDFa attributes in Firefox) aren't preserved by copy
const unsafeSelector = '[' + this.constructor.static.unsafeAttributes.join( '],[' ) + ']';
this.$element.find( unsafeSelector ).each( ( n, element ) => {
const attrs = {},
ua = this.constructor.static.unsafeAttributes;
let i = ua.length;
while ( i-- ) {
const val = element.getAttribute( ua[ i ] );
if ( val !== null ) {
attrs[ ua[ i ] ] = val;
}
}
element.setAttribute( 'data-ve-attributes', JSON.stringify( attrs ) );
} );
this.clipboardIndex++;
const clipboardKey = this.clipboardId + '-' + this.clipboardIndex;
this.clipboard = { slice, hash: null };
// Support: Firefox<48
// Writing a custom clipboard key won't work in Firefox<48, so write
// it to the HTML instead
if ( isClipboard && !ve.isClipboardDataFormatsSupported( e ) ) {
this.$element.prepend(
$( '<span>' ).attr( 'data-ve-clipboard-key', clipboardKey ).text( '\u00a0' )
);
// To ensure the contents with the clipboardKey isn't modified in an external editor,
// store a hash of the contents for later validation.
this.clipboard.hash = this.constructor.static.getClipboardHash( this.$element.contents() );
}
if ( isClipboard ) {
// Disable the default event so we can override the data
e.preventDefault();
}
// Only write a custom mime type if we think the browser supports it, otherwise
// we will have already written a key to the HTML above.
if ( isClipboard && ve.isClipboardDataFormatsSupported( e, true ) ) {
clipboardData.setData( this.constructor.static.clipboardKeyMimeType, clipboardKey );
}
clipboardData.setData( 'text/html', this.$element.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.$element[ 0 ].innerText || this.$element.text() || ' ' );
ve.track( 'activity.clipboard', { action: e.type } );
};
/**
* Get the annotation set that was a the user focus before a paste started
*
* Returns annotations with applyToInsertedContent set (e.g. not importedData).
*
* @return {ve.dm.AnnotationSet} Annotation set
*/
ve.ce.ClipboardHandler.prototype.getBeforePasteAnnotationSet = function () {
const store = this.getSurface().getModel().getDocument().getStore();
const dmAnnotations = this.beforePasteAnnotationsAtFocus
.map( ( view ) => view.getModel() )
.filter( ( ann ) => ann.constructor.static.applyToInsertedContent );
return new ve.dm.AnnotationSet( store, store.hashAll( dmAnnotations ) );
};
/**
* Handle native paste event
*
* @param {jQuery.Event} e Paste event
* @return {boolean|undefined} False if the event is cancelled
*/
ve.ce.ClipboardHandler.prototype.onPaste = function ( e ) {
// Prevent pasting until after we are done
if ( this.pasting || this.getSurface().isReadOnly() ) {
return false;
}
this.beforePaste( e );
this.getSurface().surfaceObserver.disable();
this.pasting = true;
// setTimeout: postpone until after the default paste action
setTimeout( () => {
let afterPastePromise = ve.createDeferred().resolve().promise();
try {
if ( !e.isDefaultPrevented() || e.type === 'drop' ) {
afterPastePromise = this.afterPaste( e );
}
} finally {
afterPastePromise.always( () => {
this.getSurface().surfaceObserver.clear();
this.getSurface().surfaceObserver.enable();
// Allow pasting again
this.pasting = false;
this.pasteSpecial = false;
this.beforePasteData = null;
// Restore original clipboard metadata if requred (was overridden by middle-click
// paste logic in beforePaste)
if ( this.originalClipboardMetdata ) {
this.clipboardIndex = this.originalClipboardMetdata.clipboardIndex;
this.clipboard = this.originalClipboardMetdata.clipboard;
}
ve.track( 'activity.clipboard', { action: 'paste' } );
} );
}
} );
};
/**
* Handle pre-paste events.
*
* @param {jQuery.Event} e Paste event
*/
ve.ce.ClipboardHandler.prototype.beforePaste = function ( e ) {
const surface = this.getSurface(),
selection = surface.getModel().getSelection(),
clipboardData = e.originalEvent.clipboardData || e.originalEvent.dataTransfer,
surfaceModel = surface.getModel(),
fragment = surfaceModel.getFragment(),
documentModel = surfaceModel.getDocument();
let range;
if ( selection instanceof ve.dm.LinearSelection ) {
range = fragment.getSelection().getRange();
} else if ( selection instanceof ve.dm.TableSelection ) {
range = new ve.Range( selection.getRanges( documentModel )[ 0 ].start );
} else {
e.preventDefault();
return;
}
this.beforePasteAnnotationsAtFocus = surface.annotationsAtFocus();
this.beforePasteData = {
isPaste: e.type === 'paste'
};
this.originalClipboardMetdata = null;
if ( this.middleClickPasting && !surface.lastNonCollapsedDocumentSelection.isNull() ) {
// Paste was triggered by middle click, and the last non-collapsed document selection was in
// this VE surface. Simulate a fake copy to load DM data into the clipboard. If we let the
// native middle-click paste happen, it would load CE data into the clipboard.
// Store original clipboard metadata so it can be restored after paste,
// and we can continue to use internal paste.
this.originalClipboardMetdata = {
clipboardIndex: this.clipboardIndex,
clipboard: this.clipboard
};
// Use a fake clipboard index for middle click, will be restored in afterPaste
this.clipboardIndex = -1;
this.clipboard = {
slice: surface.getModel().getDocument().shallowCloneFromSelection( surface.lastNonCollapsedDocumentSelection ),
hash: null
};
this.beforePasteData.clipboardKey = this.clipboardId + '-' + this.clipboardIndex;
} else if ( clipboardData ) {
if ( surface.handleDataTransfer( clipboardData, true ) ) {
e.preventDefault();
return;
}
this.beforePasteData.clipboardKey = clipboardData.getData( this.constructor.static.clipboardKeyMimeType ) ||
// Backwards compatability with older versions of VE which used text/xcustom
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]*$/, '' );
}
}
let source = null;
if ( !this.beforePasteData.clipboardKey ) {
const pasteSourceDetectors = this.constructor.static.pasteSourceDetectors;
for ( const s in pasteSourceDetectors ) {
if ( pasteSourceDetectors[ s ]( clipboardData ) ) {
source = s;
break;
}
}
} else {
source = 'visualEditor';
}
this.beforePasteData.source = source;
// Save scroll position before changing focus to "offscreen" clipboard handler element
this.beforePasteData.scrollTop = surface.getSurface().$scrollContainer.scrollTop();
this.$element.empty();
// Get node from cursor position
const startNode = documentModel.getBranchNodeFromOffset( range.start );
if ( startNode.canContainContent() ) {
// If this is a content branch node, then add its DM HTML
// to the clipboard handler element to give CE some context.
let textStart = 0, textEnd = 0;
const contextElement = startNode.getClonedElement();
// Make sure that context doesn't have any attributes that might confuse
// the importantElement check in afterPaste.
$( documentModel.getStore().value( contextElement.originalDomElementsHash ) ).removeAttr( 'id typeof rel' );
const context = [ contextElement ];
// If there is content to the left of the cursor, put a placeholder
// character to the left of the cursor
let leftText, rightText;
if ( range.start > startNode.getRange().start ) {
leftText = '☀';
context.push( leftText );
textStart = textEnd = 1;
}
// If there is content to the right of the cursor, put a placeholder
// character to the right of the cursor
const endNode = documentModel.getBranchNodeFromOffset( range.end );
if ( range.end < endNode.getRange().end ) {
rightText = '☂';
context.push( rightText );
}
// If there is no text context, select some text to be replaced
if ( !leftText && !rightText ) {
context.push( '☁' );
textEnd = 1;
// If we are middle click pasting we can't change the native selection, so
// just make the text a placeholder to the left. (T311723)
if ( this.middleClickPasting ) {
leftText = '☁';
textStart = 1;
}
}
context.push( { type: '/' + context[ 0 ].type } );
// Throw away 'internal', specifically inner whitespace,
// before conversion as it can affect textStart/End offsets.
delete contextElement.internal;
ve.dm.converter.getDomSubtreeFromModel(
documentModel.cloneWithData( context, true ),
this.$element[ 0 ]
);
// Giving the clipboard handler element focus too late can cause problems in FF (!?)
// so do it up here.
this.$element[ 0 ].focus();
const nativeRange = surface.getElementDocument().createRange();
// Assume that the DM node only generated one child
const textNode = this.$element.children().contents()[ 0 ];
// Place the cursor between the placeholder characters
nativeRange.setStart( textNode, textStart );
nativeRange.setEnd( textNode, textEnd );
surface.nativeSelection.removeAllRanges();
surface.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.$element[ 0 ].focus();
}
// Restore scroll position after focusing the clipboard handler element
surface.getSurface().$scrollContainer.scrollTop( this.beforePasteData.scrollTop );
};
/**
* Handle post-paste events.
*
* @param {jQuery.Event} e Paste event
* @return {jQuery.Promise} Promise which resolves when the content has been pasted
* @fires ve.ce.Surface#paste
*/
ve.ce.ClipboardHandler.prototype.afterPaste = function () {
const surface = this.getSurface(),
surfaceModel = surface.getModel(),
documentModel = surfaceModel.getDocument(),
fragment = surfaceModel.getFragment(),
beforePasteData = this.beforePasteData || {},
done = ve.createDeferred().resolve().promise();
let targetFragment = surfaceModel.getFragment( null, true );
// If the selection doesn't collapse after paste then nothing was inserted
if ( beforePasteData.isPaste && !surface.nativeSelection.isCollapsed ) {
return done;
}
if ( surface.getModel().getFragment().isNull() ) {
return done;
}
if ( this.middleClickTargetOffset ) {
targetFragment = targetFragment.clone( new ve.dm.LinearSelection( new ve.Range( this.middleClickTargetOffset ) ) );
} else if ( this.middleClickPasting ) {
// Middle click pasting should always collapse the selection before pasting
targetFragment = targetFragment.collapseToEnd();
}
// Immedately remove any <style> tags from the clipboard handler element that might
// be changing the rendering of the whole page (T235068)
this.$element.find( 'style' ).remove();
const pasteData = this.afterPasteExtractClipboardData();
// Handle pastes into a table
if ( fragment.getSelection() instanceof ve.dm.TableSelection ) {
// Internal table-into-table paste can be shortcut
if ( fragment.getSelection() instanceof ve.dm.TableSelection && pasteData.slice instanceof ve.dm.TableSlice ) {
const tableAction = new ve.ui.TableAction( surface.getSurface() );
tableAction.importTable( pasteData.slice.getTableNode( documentModel ) );
return done;
}
// For table selections the target is the first cell
targetFragment = surfaceModel.getLinearFragment( fragment.getSelection().getRanges( documentModel )[ 0 ], true );
}
// Are we pasting into a multiline context?
const isMultiline = surface.getDocument().getBranchNodeFromOffset(
targetFragment.getSelection().getCoveringRange().from
).isMultiline();
let pending;
if ( pasteData.slice ) {
pending = this.afterPasteAddToFragmentFromInternal( pasteData.slice, fragment, targetFragment, isMultiline );
} else {
pending = this.afterPasteAddToFragmentFromExternal( pasteData.clipboardKey, pasteData.$clipboardHtml, fragment, targetFragment, isMultiline );
}
return pending.then( () => {
if ( surface.getSelection().isNativeCursor() ) {
// Restore focus and scroll position
surface.$attachedRootNode[ 0 ].focus();
surface.getSurface().$scrollContainer.scrollTop( beforePasteData.scrollTop );
// setTimeout: Firefox sometimes doesn't change scrollTop immediately when pasting
// line breaks at the end of a line so do it again later.
setTimeout( () => {
surface.getSurface().$scrollContainer.scrollTop( beforePasteData.scrollTop );
} );
}
// If original selection was linear, switch to end of pasted text
if ( fragment.getSelection() instanceof ve.dm.LinearSelection ) {
targetFragment.collapseToEnd().select();
surface.findAndExecuteSequences( /* isPaste */ true );
}
surface.emit( 'paste', {
source: beforePasteData.source,
fragment
} );
} );
};
/**
* @typedef {Object} ClipboardData
* @memberof ve.ce
* @property {string|undefined} clipboardKey Clipboard key, if present
* @property {jQuery|undefined} $clipboardHtml Clipboard html, if used to extract the clipboard key
* @property {ve.dm.DocumentSlice|undefined} slice Relevant slice of this document, if the key points to it
*/
/**
* Extract the clipboard key and other relevant data from beforePasteData / the clipboard handler element
*
* @return {ve.ce.ClipboardData} Data
*/
ve.ce.ClipboardHandler.prototype.afterPasteExtractClipboardData = function () {
const beforePasteData = this.beforePasteData || {};
let clipboardKey, clipboardHash, $clipboardHtml;
// Find the clipboard key
if ( beforePasteData.clipboardKey ) {
// Clipboard key in custom data was present, and requires no further processing
clipboardKey = beforePasteData.clipboardKey;
} else {
if ( beforePasteData.html ) {
// text/html was present, so we can check if a key was hidden in it
$clipboardHtml = $( ve.sanitizeHtml( beforePasteData.html ) ).filter( ( i, element ) => {
const val = element.getAttribute && element.getAttribute( 'data-ve-clipboard-key' );
if ( val ) {
clipboardKey = val;
// Remove the clipboard key span once read
return false;
}
return true;
} );
clipboardHash = this.constructor.static.getClipboardHash( $clipboardHtml );
} else {
// Fall back on checking the clipboard handler element
// HTML in clipboard handler element may get wrapped, so use the recursive $.find to look for the clipboard key
clipboardKey = this.$element.find( 'span[data-ve-clipboard-key]' ).data( 've-clipboard-key' );
// Pass beforePasteData so context gets stripped
clipboardHash = this.constructor.static.getClipboardHash( this.$element, beforePasteData );
}
}
let slice;
// If we have a clipboard key, validate it and fetch data
if ( clipboardKey === this.clipboardId + '-' + this.clipboardIndex ) {
// Hash validation: either custom data 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.clipboardKey || clipboardHash === this.clipboard.hash ) {
slice = this.clipboard.slice;
// Clone again. The elements were cloned on copy, but we need to clone
// on paste too in case the same thing is pasted multiple times.
slice.data.cloneElements( true );
}
}
if ( !slice && !$clipboardHtml && beforePasteData.html ) {
$clipboardHtml = $( ve.sanitizeHtml( beforePasteData.html ) );
}
return {
clipboardKey,
$clipboardHtml,
slice
};
};
/**
* LinearData sanitize helper, for pasted data
*
* @param {ve.dm.LinearData} linearData Data to sanitize
* @param {boolean} isMultiline Sanitize for a multiline context
* @param {boolean} isExternal Treat as external content
*/
ve.ce.ClipboardHandler.prototype.afterPasteSanitize = function ( linearData, isMultiline, isExternal ) {
const importRules = this.afterPasteImportRules( isMultiline );
if ( isExternal ) {
linearData.sanitize( importRules.external || {} );
}
linearData.sanitize( importRules.all || {} );
};
/**
* Helper to build import rules for pasted data
*
* @param {boolean} isMultiline Get rules for a multiline context
* @return {Object.<string,Object>} Import rules
*/
ve.ce.ClipboardHandler.prototype.afterPasteImportRules = function ( isMultiline ) {
let importRules = !this.isPasteSpecial() ? this.getSurface().getSurface().getImportRules() : { all: { plainText: true, keepEmptyContentBranches: true } };
if ( !isMultiline ) {
importRules = {
all: ve.extendObject( {}, importRules.all, { singleLine: true } ),
external: ve.extendObject( {}, importRules.external, { singleLine: true } )
};
}
return importRules;
};
/**
* After clipboard handler for pastes from the same document
*
* @param {ve.dm.DocumentSlice} slice Slice of document to paste
* @param {ve.dm.SurfaceFragment} fragment Current fragment
* @param {ve.dm.SurfaceFragment} targetFragment Fragment to insert into
* @param {boolean} isMultiline Pasting to a multiline context
* @return {jQuery.Promise} Promise which resolves when the content has been inserted
*/
ve.ce.ClipboardHandler.prototype.afterPasteAddToFragmentFromInternal = function ( slice, fragment, targetFragment, isMultiline ) {
// Pasting non-table content into table: just replace the first cell with the pasted content
if ( fragment.getSelection() instanceof ve.dm.TableSelection ) {
// Cell was not deleted in beforePaste to prevent flicker when table-into-table paste is
// about to be triggered.
targetFragment.removeContent();
}
// Temporary tracking for T362358
const pastedRefs = slice.getNodesByType( 'mwReference' );
if ( pastedRefs.length > 0 ) {
const documentRefKeys = fragment.getDocument().getNodesByType( 'mwReference' ).map(
( ref ) => ref.registeredListGroup + '\n' + ref.registeredListKey
);
if ( pastedRefs.some(
( ref ) => documentRefKeys.includes( ref.registeredListGroup + '\n' + ref.registeredListKey )
) ) {
ve.track( 'activity.clipboard', { action: 'paste-ref-internal-reuse' } );
} else {
ve.track( 'activity.clipboard', { action: 'paste-ref-internal-new' } );
}
}
// Only try original data in multiline contexts, for single line we must use balanced data
let linearData, insertionPromise;
// Original data + fixupInsertion
if ( isMultiline ) {
// Take a copy to prevent the data being annotated a second time in the balanced data path
// and to prevent actions in the data model affecting view.clipboard
linearData = new ve.dm.LinearData(
slice.getStore(),
ve.copy( slice.getOriginalData() )
);
if ( this.isPasteSpecial() ) {
this.afterPasteSanitize( linearData, isMultiline );
}
// ve.dm.Document#fixupInsertion may fail, in which case we fall back to balanced data
try {
insertionPromise = this.afterPasteInsertInternalData( targetFragment, linearData.getData() );
} catch ( e ) {}
}
// Balanaced data
if ( !insertionPromise ) {
// Take a copy to prevent actions in the data model affecting view.clipboard
linearData = new ve.dm.LinearData(
slice.getStore(),
ve.copy( slice.getBalancedData() )
);
if ( this.isPasteSpecial() || !isMultiline ) {
this.afterPasteSanitize( linearData, isMultiline );
}
let data = linearData.getData();
if ( !isMultiline ) {
// Unwrap single CBN
if ( data[ 0 ].type ) {
data = data.slice( 1, -1 );
}
}
insertionPromise = this.afterPasteInsertInternalData( targetFragment, data );
}
return insertionPromise;
};
/**
* Insert some pasted data from an internal source
*
* @param {ve.dm.SurfaceFragment} targetFragment Fragment to insert into
* @param {ve.dm.LinearData.Item[]} data Data to insert
* @return {jQuery.Promise} Promise which resolves when the content has been inserted
*/
ve.ce.ClipboardHandler.prototype.afterPasteInsertInternalData = function ( targetFragment, data ) {
targetFragment.insertContent( data, this.getBeforePasteAnnotationSet() );
return targetFragment.getPending();
};
/**
* After clipboard handler for pastes from the another document
*
* @param {string|undefined} clipboardKey Clipboard key for pasted data
* @param {jQuery|undefined} $clipboardHtml Clipboard HTML, if used to find the key
* @param {ve.dm.SurfaceFragment} fragment Current fragment
* @param {ve.dm.SurfaceFragment} targetFragment Fragment to insert into
* @param {boolean} [isMultiline] Pasting to a multiline context
* @param {boolean} [forceClipboardData] Ignore the clipboard handler element, and use only clipboard html
* @return {jQuery.Promise} Promise which resolves when the content has been inserted
*/
ve.ce.ClipboardHandler.prototype.afterPasteAddToFragmentFromExternal = function ( clipboardKey, $clipboardHtml, fragment, targetFragment, isMultiline, forceClipboardData ) {
const importantElement = '[id],[typeof],[rel],figure',
items = [],
surfaceModel = this.getSurface().getModel(),
documentModel = surfaceModel.getDocument(),
beforePasteData = this.beforePasteData || {};
let htmlDoc;
// There are two potential sources of HTML to choose from:
// * this.$element where we we let the past happen in a context similar to the surface
// * beforePasteData.html which is read from the clipboard API
//
// If clipboard API data is available, then make sure important elements haven't been dropped.
//
// The only reason we don't use clipboard API data unconditionally is that for simpler pastes,
// the $element method does a good job of merging content, e.g. paragraps into paragraphs.
//
// If we could do a better job of mimicking how browsers merge content, the clipboard API data
// would produce much more consistent results, as the clipboard handler element approach can also re-order
// and destroy nodes.
if (
$clipboardHtml && (
!beforePasteData.isPaste ||
forceClipboardData ||
// FIXME T126045: Allow the test runner to force the use of clipboardData
clipboardKey === 'useClipboardData-0' ||
$clipboardHtml.find( importantElement ).addBack( importantElement ).length > this.$element.find( importantElement ).length
)
) {
// CE destroyed an important element, so revert to using clipboard data
htmlDoc = ve.sanitizeHtmlToDocument( beforePasteData.html );
$( htmlDoc )
// Remove the pasteProtect class. See #onCopy.
.find( 'span' ).removeClass( 've-pasteProtect' ).end()
// Remove the clipboard key
.find( 'span[data-ve-clipboard-key]' ).remove().end()
// Remove ve-attributes, we trust that clipboard data preserved these attributes
.find( '[data-ve-attributes]' ).removeAttr( 'data-ve-attributes' );
beforePasteData.context = null;
}
if ( !htmlDoc ) {
// If we're using $element, let CE do its sanitizing as it may
// contain disruptive metadata (head tags etc.)
htmlDoc = ve.sanitizeHtmlToDocument( this.$element.html() );
}
// Some browsers don't provide pasted image data through the clipboardData API and
// instead create img tags with data URLs, so detect those here
const $body = $( htmlDoc.body );
const $images = $body.children( 'img[src^=data\\:]' );
// Check the body contained just images.
// TODO: In the future this may want to trigger image uploads *and* paste the HTML.
if ( $images.length && $images.length === $body.children().length ) {
for ( let i = 0; i < $images.length; i++ ) {
items.push( ve.ui.DataTransferItem.static.newFromDataUri(
$images.eq( i ).attr( 'src' ),
$images[ i ].outerHTML
) );
}
if ( this.getSurface().handleDataTransferItems( items, true ) ) {
return ve.createDeferred().resolve().promise();
}
}
this.afterPasteSanitizeExternal( $( htmlDoc.body ) );
// HACK: Fix invalid HTML from Google Docs nested lists (T98100).
// Converts
// <ul><li>A</li><ul><li>B</li></ul></ul>
// to
// <ul><li>A<ul><li>B</li></ul></li></ul>
$( htmlDoc.body ).find( 'ul > ul, ul > ol, ol > ul, ol > ol' ).each( ( n, element ) => {
if ( element.previousElementSibling ) {
element.previousElementSibling.appendChild( element );
} else {
// List starts double indented. This is invalid and a semantic nightmare.
// Just wrap with an extra list item
$( element ).wrap( '<li>' );
}
} );
// HACK: Fix invalid HTML from copy-pasting `display: inline` lists (T239550).
$( htmlDoc.body ).find( 'li, dd, dt' ).each( ( n, element ) => {
const listType = { li: 'ul', dd: 'dl', dt: 'dl' },
tag = element.tagName.toLowerCase(),
// Parent node always exists because we're searching inside <body>
parentTag = element.parentNode.tagName.toLowerCase();
let list;
if (
( tag === 'li' && ( parentTag !== 'ul' && parentTag !== 'ol' ) ) ||
( ( tag === 'dd' || tag === 'dt' ) && parentTag !== 'dl' )
) {
// This list item's parent node is not a list. This breaks expectations in DM code.
// Wrap this node and its list item siblings in a list node.
list = htmlDoc.createElement( listType[ tag ] );
element.parentNode.insertBefore( list, element );
while (
list.nextElementSibling &&
listType[ list.nextElementSibling.tagName.toLowerCase() ] === listType[ tag ]
) {
list.appendChild( list.nextElementSibling );
}
}
} );
// HTML sanitization
const htmlBlacklist = ve.getProp( this.afterPasteImportRules( isMultiline ), 'external', 'htmlBlacklist' );
if ( htmlBlacklist && !clipboardKey ) {
if ( htmlBlacklist.remove ) {
Object.keys( htmlBlacklist.remove ).forEach( ( selector ) => {
if ( htmlBlacklist.remove[ selector ] ) {
$( htmlDoc.body ).find( selector ).remove();
}
} );
}
if ( htmlBlacklist.unwrap ) {
Object.keys( htmlBlacklist.unwrap ).forEach( ( selector ) => {
if ( htmlBlacklist.unwrap[ selector ] ) {
$( htmlDoc.body ).find( selector ).contents().unwrap();
}
} );
}
}
// External paste
const pastedDocumentModel = ve.dm.converter.getModelFromDom( htmlDoc, {
targetDoc: documentModel.getHtmlDocument(),
fromClipboard: true
} );
const data = pastedDocumentModel.data;
// Clone again
data.cloneElements( true );
// Sanitize
this.afterPasteSanitize( data, isMultiline, !clipboardKey );
data.remapInternalListKeys( documentModel.getInternalList() );
// Initialize node tree
pastedDocumentModel.buildNodeTree();
if ( fragment.getSelection() instanceof ve.dm.TableSelection ) {
// External table-into-table paste
if (
pastedDocumentModel.documentNode.children.length === 2 &&
pastedDocumentModel.documentNode.children[ 0 ] instanceof ve.dm.TableNode
) {
const tableAction = new ve.ui.TableAction( this.getSurface().getSurface() );
tableAction.importTable( pastedDocumentModel.documentNode.children[ 0 ], true );
return ve.createDeferred().resolve().promise();
}
// Pasting non-table content into table: just replace the first cell with the pasted content
// Cell was not deleted in beforePaste to prevent flicker when table-into-table paste is about to be triggered.
targetFragment.removeContent();
}
let contextRange;
if ( beforePasteData.context ) {
// If the paste was given context, calculate the range of the inserted data
contextRange = this.afterPasteFromExternalContextRange( pastedDocumentModel, isMultiline, forceClipboardData );
if ( !contextRange ) {
return this.afterPasteAddToFragmentFromExternal( clipboardKey, $clipboardHtml, fragment, targetFragment, isMultiline, true );
}
} else {
contextRange = pastedDocumentModel.getDocumentRange();
}
const pastedNodes = pastedDocumentModel.selectNodes( contextRange, 'siblings' )
// Ignore nodes where nothing is selected
.filter( ( node ) => !( node.range && node.range.isCollapsed() ) );
// Unwrap single content branch nodes to match internal copy/paste behaviour
// (which wouldn't put the open and close tags in the clipboard to begin with).
if (
pastedNodes.length === 1 &&
pastedNodes[ 0 ].node.canContainContent()
) {
if ( contextRange.containsRange( pastedNodes[ 0 ].nodeRange ) ) {
contextRange = pastedNodes[ 0 ].nodeRange;
}
}
return this.afterPasteInsertExternalData( targetFragment, pastedDocumentModel, contextRange );
};
/**
* Insert some pasted data from an external source
*
* @param {ve.dm.SurfaceFragment} targetFragment Fragment to insert into
* @param {ve.dm.Document} pastedDocumentModel Model generated from pasted data
* @param {ve.Range} contextRange Range of data in generated model to consider
* @return {jQuery.Promise} Promise which resolves when the content has been inserted
*/
ve.ce.ClipboardHandler.prototype.afterPasteInsertExternalData = function ( targetFragment, pastedDocumentModel, contextRange ) {
// Temporary tracking for T362358
if ( pastedDocumentModel.getInternalList().getItemNodeCount() > 0 ) {
ve.track( 'activity.clipboard', { action: 'paste-ref-external' } );
}
let handled;
// If the external HTML turned out to be plain text after sanitization
// then run it as a plain text transfer item. In core this will never
// do anything, but implementations can provide their own handler for
// conversion actions here.
if ( pastedDocumentModel.data.isPlainText( contextRange, true, undefined, true ) ) {
const pastedText = pastedDocumentModel.data.getText( true, contextRange );
if ( pastedText ) {
handled = this.getSurface().handleDataTransferItems(
[ ve.ui.DataTransferItem.static.newFromString( pastedText ) ],
true,
targetFragment
);
}
}
if ( !handled ) {
const annotations = this.getBeforePasteAnnotationSet();
const target = this.getSurface().getSurface().getTarget();
if ( target.constructor.static.annotateImportedData ) {
annotations.push(
ve.dm.annotationFactory.createFromElement(
{
type: 'meta/importedData',
attributes: {
source: this.beforePasteData.source,
eventId: ve.init.platform.generateUniqueId()
}
}
)
);
}
targetFragment.insertDocument( pastedDocumentModel, contextRange, annotations );
}
return targetFragment.getPending();
};
/**
* Helper to work out the context range for an external paste
*
* @param {ve.dm.Document} pastedDocumentModel Model for pasted data
* @param {boolean} isMultiline Whether pasting to a multiline context
* @param {boolean} forceClipboardData Whether the current attempted paste is the result of forcing use of clipboard data
* @return {ve.Range|boolean} Context range, or false if data appeared corrupted
*/
ve.ce.ClipboardHandler.prototype.afterPasteFromExternalContextRange = function ( pastedDocumentModel, isMultiline, forceClipboardData ) {
const data = pastedDocumentModel.data,
documentRange = pastedDocumentModel.getDocumentRange(),
beforePasteData = this.beforePasteData || {},
context = new ve.dm.LinearData(
pastedDocumentModel.getStore(),
ve.copy( beforePasteData.context )
);
// Sanitize context to match data
this.afterPasteSanitize( context, isMultiline );
let leftText = beforePasteData.leftText;
let rightText = beforePasteData.rightText;
// Remove matching context from the left
let left = 0;
while (
context.getLength() &&
ve.dm.LinearData.static.compareElementsUnannotated(
data.getData( left ),
data.isElementData( left ) ? context.getData( 0 ) : leftText
)
) {
if ( !data.isElementData( left ) ) {
// Text context is removed
leftText = '';
}
left++;
context.splice( 0, 1 );
}
// Remove matching context from the right
let right = documentRange.end;
while (
right > 0 &&
context.getLength() &&
ve.dm.LinearData.static.compareElementsUnannotated(
data.getData( right - 1 ),
data.isElementData( right - 1 ) ? context.getData( context.getLength() - 1 ) : rightText
)
) {
if ( !data.isElementData( right - 1 ) ) {
// Text context is removed
rightText = '';
}
right--;
context.splice( context.getLength() - 1, 1 );
}
if ( ( leftText || rightText ) && !forceClipboardData ) {
// If any text context is left over, assume the clipboard handler element got corrupted
// so we should start again and try to use clipboardData instead. T193110
return false;
}
// Support: Chrome
// FIXME T126046: Strip trailing linebreaks probably introduced by Chrome bug
while ( right > 0 && data.getType( right - 1 ) === 'break' ) {
right--;
}
return new ve.Range( left, right );
};
/**
* Helper to clean up externally pasted HTML (via clipboard handler element).
*
* @param {jQuery} $element Root element containing pasted stuff to sanitize
*/
ve.ce.ClipboardHandler.prototype.afterPasteSanitizeExternal = function ( $element ) {
const metadataIdRegExp = ve.init.platform.getMetadataIdRegExp();
// Remove the clipboard key
$element.find( 'span[data-ve-clipboard-key]' ).remove();
// Remove style tags (T185532)
$element.find( 'style' ).remove();
// If this is from external, run extra sanitization:
// Do some simple transforms to catch content that is using
// spans+styles instead of regular tags. This is very much targeted at
// the output of Google Docs, but should work with anything fairly-
// similar. This is *fragile*, but more in the sense that small
// deviations will stop it from working, rather than it being terribly
// likely to incorrectly over-format things.
// TODO: This might be cleaner if we could move the sanitization into
// dm.converter entirely.
$element.find( 'span' ).each( ( i, node ) => {
// Later sanitization will replace completely-empty spans with
// their contents, so we can lazily-wrap here without cleaning
// up.
if ( !node.style ) {
return;
}
const $node = $( node );
if (
+node.style.fontWeight >= 700 ||
node.style.fontWeight === 'bold' ||
node.style.fontWeight === 'bolder'
) {
$node.wrap( '<b>' );
}
if (
node.style.fontStyle === 'italic' ||
// Oblique can also add an angle, e.g. "oblique 40deg"
node.style.fontStyle.startsWith( 'oblique' )
) {
$node.wrap( '<i>' );
}
// textDecorationLine can take multiple values, e.g. "underline line-through"
// so use String.prototype.includes
if ( node.style.textDecorationLine.includes( 'underline' ) ) {
$node.wrap( '<u>' );
}
if ( node.style.textDecorationLine.includes( 'line-through' ) ) {
$node.wrap( '<s>' );
}
if ( node.style.verticalAlign === 'super' ) {
$node.wrap( '<sup>' );
}
if ( node.style.verticalAlign === 'sub' ) {
$node.wrap( '<sub>' );
}
} );
// Remove style attributes. Any valid styles will be restored by data-ve-attributes.
$element.find( '[style]' ).removeAttr( 'style' );
if ( metadataIdRegExp ) {
$element.find( '[id]' ).each( ( i, el ) => {
const $el = $( el );
if ( metadataIdRegExp.test( $el.attr( 'id' ) ) ) {
$el.removeAttr( 'id' );
}
} );
}
// Remove the pasteProtect class (see #onCopy) and unwrap empty spans.
$element.find( 'span' ).each( ( i, el ) => {
const $el = $( el );
$el.removeClass( 've-pasteProtect' );
if ( $el.attr( 'class' ) === '' ) {
$el.removeAttr( 'class' );
}
// Unwrap empty spans
if ( !el.attributes.length ) {
// childNodes is a NodeList
// eslint-disable-next-line no-jquery/no-append-html
$el.replaceWith( el.childNodes );
}
} );
// Restore attributes. See #onCopy.
$element.find( '[data-ve-attributes]' ).each( ( i, el ) => {
const attrsJSON = el.getAttribute( 'data-ve-attributes' );
// Always remove the attribute, even if the JSON has been corrupted
el.removeAttribute( 'data-ve-attributes' );
let attrs;
try {
attrs = JSON.parse( attrsJSON );
} catch ( err ) {
// Invalid JSON
return;
}
Object.keys( attrs ).forEach( ( attr ) => {
ve.setAttributeSafe( el, attr, attrs[ attr ] );
} );
} );
};
/**
* Place the native selection in the clipboard handler, ready for a copy
*/
ve.ce.ClipboardHandler.prototype.prepareForCopy = function () {
const focusedNode = this.getSurface().focusedNode;
this.$element.text( ( focusedNode && focusedNode.$element.text().trim() ) || '☢' );
ve.selectElement( this.$element[ 0 ] );
this.$element[ 0 ].focus();
};
/**
* Prepare the clipboard handler to treat the next paste as "speical" (i.e. plain text)
*/
ve.ce.ClipboardHandler.prototype.prepareForPasteSpecial = function () {
this.pasteSpecial = true;
};
/**
* Check if we are in the middle of a special/plain-text paste
*
* @return {boolean}
*/
ve.ce.ClipboardHandler.prototype.isPasteSpecial = function () {
return this.pasteSpecial;
};
ve.ce.ClipboardHandler.prototype.prepareForMiddleClickPaste = function ( e ) {
// When middle click is also focusing the document, the selection may not end up
// where you clicked, so record the offset from the click coordinates. (T311733)
let targetOffset = -1;
if ( this.getSurface().getModel().getSelection().isNull() ) {
targetOffset = this.getSurface().getOffsetFromEventCoords( e );
}
this.middleClickTargetOffset = targetOffset !== -1 ? targetOffset : null;
this.middleClickPasting = true;
this.getSurface().$document.one( 'mouseup', () => {
// Stay true until other events have run, e.g. paste
setTimeout( () => {
this.middleClickPasting = false;
} );
} );
};