mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-11-15 18:39:52 +00:00
b2295a2de3
Change-Id: I413fc5850cd7bcae9b1d955eb951394e1ed9df36
1288 lines
35 KiB
JavaScript
1288 lines
35 KiB
JavaScript
/*global rangy */
|
||
|
||
/**
|
||
* VisualEditor content editable Surface class.
|
||
*
|
||
* @copyright 2011-2012 VisualEditor Team and others; see AUTHORS.txt
|
||
* @license The MIT License (MIT); see LICENSE.txt
|
||
*/
|
||
|
||
/**
|
||
* ContentEditable surface.
|
||
*
|
||
* @class
|
||
* @constructor
|
||
* @extends {ve.EventEmitter}
|
||
* @param {jQuery} $container
|
||
* @param {ve.dm.Surface} model Model to observe
|
||
*/
|
||
ve.ce.Surface = function VeCeSurface( $container, model ) {
|
||
// Parent constructor
|
||
ve.EventEmitter.call( this );
|
||
|
||
// Properties
|
||
this.model = model;
|
||
this.documentView = null; // See initialization below
|
||
this.contextView = null; // See initialization below
|
||
this.surfaceObserver = null; // See initialization below
|
||
this.selectionTimeout = null;
|
||
this.$ = $container;
|
||
this.$document = $( document );
|
||
this.clipboard = {};
|
||
this.locked = false;
|
||
this.sluggable = true;
|
||
|
||
// Events
|
||
this.model.addListenerMethods(
|
||
this, { 'change': 'onChange', 'lock': 'onLock', 'unlock': 'onUnlock' }
|
||
);
|
||
this.on( 'contentChange', ve.bind( this.onContentChange, this ) );
|
||
this.$.on( {
|
||
'cut': ve.bind( this.onCut, this ),
|
||
'copy': ve.bind( this.onCopy, this ),
|
||
'paste': ve.bind( this.onPaste, this ),
|
||
'dragover drop': function ( e ) {
|
||
// Prevent content drag & drop
|
||
e.preventDefault();
|
||
return false;
|
||
}
|
||
} );
|
||
if ( $.browser.msie ) {
|
||
this.$.on( 'beforepaste', ve.bind( this.onPaste, this ) );
|
||
}
|
||
|
||
// Initialization
|
||
try {
|
||
document.execCommand( 'enableObjectResizing', false, false );
|
||
document.execCommand( 'enableInlineTableEditing', false, false );
|
||
} catch ( e ) {
|
||
// Silently ignore
|
||
}
|
||
rangy.init();
|
||
ve.ce.Surface.clearLocalStorage();
|
||
|
||
// Must be initialized after select and transact listeners are added to model so respond first
|
||
this.documentView = new ve.ce.Document( model.getDocument(), this );
|
||
this.contextView = new ve.ui.Context( this );
|
||
this.surfaceObserver = new ve.ce.SurfaceObserver( this.documentView );
|
||
this.$.append( this.documentView.getDocumentNode().$ );
|
||
|
||
// DocumentNode Events
|
||
this.documentView.getDocumentNode().$.on( {
|
||
'focus': ve.bind( this.documentOnFocus, this ),
|
||
'blur': ve.bind( this.documentOnBlur, this )
|
||
} );
|
||
|
||
// SurfaceObserver Events
|
||
this.surfaceObserver.on( 'contentChange', ve.bind( this.onContentChange, this ) );
|
||
this.surfaceObserver.on( 'selectionChange', ve.bind( this.onSelectionChange, this ) );
|
||
};
|
||
|
||
/* Inheritance */
|
||
|
||
ve.inheritClass( ve.ce.Surface, ve.EventEmitter );
|
||
|
||
/* Methods */
|
||
|
||
/**
|
||
* Responds to 'contentChange' events emitted in {ve.ce.SurfaceObserver.prototype.poll}.
|
||
*
|
||
* @method
|
||
* @param {HTMLElement} node DOM node the change occured in
|
||
* @param {Object} previous Old data
|
||
* @param {Object} previous.text Old plain text content
|
||
* @param {Object} previous.hash Old DOM hash
|
||
* @param {Object} previous.range Old selection
|
||
* @param {Object} next New data
|
||
* @param {Object} next.text New plain text content
|
||
* @param {Object} next.hash New DOM hash
|
||
* @param {Object} next.range New selection
|
||
*/
|
||
ve.ce.Surface.prototype.onContentChange = function ( node, previous, next ) {
|
||
var nodeOffset = node.model.getOffset(), // TODO: call getModel() or add getOffset() to view
|
||
offsetDiff = ( previous.range.isCollapsed() && next.range.isCollapsed() )
|
||
? next.range.start - previous.range.start : null,
|
||
lengthDiff = next.text.length - previous.text.length,
|
||
sameLeadingAndTrailing = offsetDiff !== null && ( // TODO: rewrite to static method with tests
|
||
(
|
||
lengthDiff > 0 &&
|
||
previous.text.substring( 0, previous.range.start - nodeOffset - 1 ) ===
|
||
next.text.substring( 0, previous.range.start - nodeOffset - 1 ) &&
|
||
previous.text.substring( previous.range.start - nodeOffset - 1 ) ===
|
||
next.text.substring( next.range.start - nodeOffset - 1 )
|
||
) ||
|
||
(
|
||
lengthDiff < 0 &&
|
||
previous.text.substring( 0, next.range.start - nodeOffset - 1 ) ===
|
||
next.text.substring( 0, next.range.start - nodeOffset - 1 ) &&
|
||
previous.text.substring( previous.range.start - nodeOffset - 1 - lengthDiff + offsetDiff) ===
|
||
next.text.substring( next.range.start - nodeOffset - 1 )
|
||
)
|
||
),
|
||
data,
|
||
range,
|
||
len,
|
||
fromLeft = 0,
|
||
fromRight = 0,
|
||
transactions = [],
|
||
complex;
|
||
|
||
if ( lengthDiff > 0 && offsetDiff === lengthDiff /* && sameLeadingAndTrailing */) {
|
||
data = next.text.substring(
|
||
previous.range.start - nodeOffset - 1,
|
||
next.range.start - nodeOffset - 1
|
||
).split( '' );
|
||
// Apply insertAnnotations
|
||
ve.dm.Document.addAnnotationsToData( data, this.model.getDocument().insertAnnotations );
|
||
this.lock();
|
||
this.model.change(
|
||
ve.dm.Transaction.newFromInsertion(
|
||
this.documentView.model, previous.range.start, data
|
||
),
|
||
next.range
|
||
);
|
||
this.unlock();
|
||
} else if ( ( offsetDiff === 0 || offsetDiff === lengthDiff ) && sameLeadingAndTrailing ) {
|
||
if ( offsetDiff === 0 ) {
|
||
range = new ve.Range( next.range.start, next.range.start - lengthDiff );
|
||
} else {
|
||
range = new ve.Range( next.range.start, previous.range.start );
|
||
}
|
||
this.lock();
|
||
this.model.change(
|
||
ve.dm.Transaction.newFromRemoval( this.documentView.model, range ),
|
||
next.range
|
||
);
|
||
this.unlock();
|
||
} else {
|
||
len = Math.min( previous.text.length, next.text.length );
|
||
// Count same characters from left
|
||
while ( fromLeft < len && previous.text[fromLeft] === next.text[fromLeft] ) {
|
||
++fromLeft;
|
||
}
|
||
// Count same characters from right
|
||
while (
|
||
fromRight < len - fromLeft &&
|
||
previous.text[previous.text.length - 1 - fromRight] ===
|
||
next.text[next.text.length - 1 - fromRight]
|
||
) {
|
||
++fromRight;
|
||
}
|
||
data = next.text.substring( fromLeft, next.text.length - fromRight ).split( '' );
|
||
|
||
// Get annotations to the left of new content and apply
|
||
annotations = this.model.getDocument().getAnnotationsFromOffset( nodeOffset + 1 + fromLeft );
|
||
if ( annotations.getLength() > 0 ) {
|
||
ve.dm.Document.addAnnotationsToData( data, annotations );
|
||
}
|
||
this.model.change(
|
||
ve.dm.Transaction.newFromInsertion(
|
||
this.documentView.model, nodeOffset + 1 + fromLeft, data
|
||
),
|
||
next.range
|
||
);
|
||
if ( fromLeft + fromRight < previous.text.length ) {
|
||
this.model.change(
|
||
ve.dm.Transaction.newFromRemoval(
|
||
this.documentView.model,
|
||
new ve.Range(
|
||
data.length + nodeOffset + 1 + fromLeft,
|
||
data.length + nodeOffset + 1 + previous.text.length - fromRight
|
||
)
|
||
),
|
||
next.range
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Responds to 'selectionChange' events emitted in {ve.ce.SurfaceObserver.prototype.poll}.
|
||
*
|
||
* @method
|
||
*/
|
||
ve.ce.Surface.prototype.onSelectionChange = function ( oldRange, newRange ) {
|
||
// TODO: Explain why we lock here.
|
||
this.lock();
|
||
this.model.change( null, newRange );
|
||
this.unlock();
|
||
};
|
||
|
||
/**
|
||
* Responds to surface lock events.
|
||
*
|
||
* @method
|
||
*/
|
||
ve.ce.Surface.prototype.onLock = function () {
|
||
this.stopPolling();
|
||
};
|
||
|
||
/**
|
||
* Responds to surface lock events.
|
||
*
|
||
* @method
|
||
*/
|
||
ve.ce.Surface.prototype.onUnlock = function () {
|
||
this.clearPollData();
|
||
this.startPolling();
|
||
};
|
||
|
||
/**
|
||
* Responds to document focus events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.documentOnFocus = function () {
|
||
this.$document.off( '.ve-ce-Surface' );
|
||
this.$document.on( {
|
||
'keydown.ve-ce-Surface': ve.bind( this.onKeyDown, this ),
|
||
'keypress.ve-ce-Surface': ve.bind( this.onKeyPress, this ),
|
||
'mousedown.ve-ce-Surface': ve.bind( this.onMouseDown, this ),
|
||
'mouseup.ve-ce-Surface': ve.bind( this.onMouseUp, this ),
|
||
//'compositionstart.ve-ce-Surface': ve.bind( this.onCompositionStart, this ),
|
||
//'compositionend.ve-ce-Surface': ve.bind( this.onCompositionEnd, this ),
|
||
} );
|
||
this.surfaceObserver.start( true );
|
||
};
|
||
|
||
/**
|
||
* Responds to document blur events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.documentOnBlur = function () {
|
||
this.$document.off( '.ve-ce-Surface' );
|
||
this.surfaceObserver.stop( true );
|
||
|
||
if ( this.contextView && !this.contextView.areChildrenCurrentlyVisible() ) {
|
||
this.contextView.clear();
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Responds to document mouse down events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.onMouseDown = function ( e ) {
|
||
// Old code to figure out if user clicked inside the document or not - leave it here for now
|
||
// $( e.target ).closest( '.ve-ce-documentNode' ).length === 0
|
||
|
||
if ( e.which === 1 ) {
|
||
this.surfaceObserver.stop( true );
|
||
}
|
||
|
||
// Block / prevent triple click
|
||
if ( e.originalEvent.detail > 2 ) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Responds to document mouse up events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.onMouseUp = function ( e ) {
|
||
this.surfaceObserver.start();
|
||
};
|
||
|
||
/**
|
||
* Responds to document key down events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.onKeyDown = function ( e ) {
|
||
var offset,
|
||
relativeContentOffset,
|
||
relativeStructuralOffset,
|
||
relativeStructuralOffsetNode,
|
||
hasSlug,
|
||
newOffset,
|
||
annotations,
|
||
annotation;
|
||
switch ( e.keyCode ) {
|
||
// Tab Key
|
||
case 9:
|
||
// If possible, trigger a list indent/outdent
|
||
// FIXME this way of checking whether indenting is possible is extremely hacky
|
||
// Instead, we should allow toolbar tools to subscribe to and intercept keydowns
|
||
if ( $( '.ve-ui-toolbarButtonTool-indent' ).is( ':not(.ve-ui-toolbarButtonTool-disabled)' ) ) {
|
||
e.preventDefault();
|
||
if ( e.shiftKey ) {
|
||
ve.ui.IndentationButtonTool.outdentListItem( this.model );
|
||
} else {
|
||
ve.ui.IndentationButtonTool.indentListItem( this.model );
|
||
}
|
||
}
|
||
break;
|
||
// Left arrow
|
||
case 37:
|
||
if ( !e.metaKey && !e.altKey && !e.shiftKey && this.model.getSelection().getLength() === 0 ) {
|
||
offset = this.model.getSelection().start;
|
||
relativeContentOffset = this.documentView.model.getRelativeContentOffset( offset, -1 );
|
||
relativeStructuralOffset = this.documentView.model.getRelativeStructuralOffset( offset - 1, -1, true );
|
||
relativeStructuralOffsetNode = this.documentView.documentNode.getNodeFromOffset( relativeStructuralOffset );
|
||
hasSlug = this.documentView.getSlugAtOffset( relativeStructuralOffset ) || false;
|
||
if ( hasSlug ) {
|
||
if ( relativeContentOffset > offset ) {
|
||
newOffset = relativeStructuralOffset;
|
||
} else {
|
||
newOffset = Math.max( relativeContentOffset, relativeStructuralOffset );
|
||
}
|
||
} else {
|
||
newOffset = Math.min( offset, relativeContentOffset );
|
||
}
|
||
this.model.change(
|
||
null,
|
||
new ve.Range( newOffset )
|
||
);
|
||
e.preventDefault();
|
||
}
|
||
break;
|
||
// Right arrow
|
||
case 39:
|
||
if (
|
||
!e.metaKey &&
|
||
!e.altKey &&
|
||
!e.shiftKey &&
|
||
this.model.getSelection().getLength() === 0
|
||
) {
|
||
offset = this.model.getSelection().start;
|
||
relativeContentOffset = this.documentView.model.getRelativeContentOffset( offset, 1 );
|
||
relativeStructuralOffset = this.documentView.model.getRelativeStructuralOffset( offset + 1, 1, true );
|
||
relativeStructuralOffsetNode = this.documentView.documentNode.getNodeFromOffset( relativeStructuralOffset );
|
||
hasSlug = this.documentView.getSlugAtOffset( relativeStructuralOffset ) || false;
|
||
if ( hasSlug ) {
|
||
if ( relativeContentOffset < offset ) {
|
||
newOffset = relativeStructuralOffset;
|
||
} else {
|
||
newOffset = Math.min( relativeContentOffset, relativeStructuralOffset );
|
||
}
|
||
} else {
|
||
newOffset = Math.max( offset, relativeContentOffset );
|
||
}
|
||
this.model.change(
|
||
null,
|
||
new ve.Range( newOffset )
|
||
);
|
||
e.preventDefault();
|
||
}
|
||
break;
|
||
// Enter
|
||
case 13:
|
||
e.preventDefault();
|
||
this.handleEnter( e );
|
||
break;
|
||
// Backspace
|
||
case 8:
|
||
this.handleDelete( e, true );
|
||
break;
|
||
// Delete
|
||
case 46:
|
||
this.handleDelete( e, false );
|
||
break;
|
||
// B
|
||
case 66:
|
||
if ( ve.ce.Surface.isShortcutKey( e ) ) {
|
||
// Ctrl+B / Cmd+B, annotate with bold
|
||
e.preventDefault();
|
||
if ( this.model.getSelection().getLength() ) {
|
||
annotations = this.documentView.model.getAnnotationsFromRange( this.model.getSelection() );
|
||
} else {
|
||
annotations = this.model.documentModel.insertAnnotations;
|
||
}
|
||
annotation = { 'type': 'textStyle/bold' };
|
||
|
||
this.model.annotate( annotations.contains( annotation ) ? 'clear' : 'set', annotation );
|
||
}
|
||
break;
|
||
// I
|
||
case 73:
|
||
if ( ve.ce.Surface.isShortcutKey( e ) ) {
|
||
// Ctrl+I / Cmd+I, annotate with italic
|
||
e.preventDefault();
|
||
if ( this.model.getSelection().getLength() ) {
|
||
annotations = this.documentView.model.getAnnotationsFromRange( this.model.getSelection() );
|
||
} else {
|
||
annotations = this.model.documentModel.insertAnnotations;
|
||
}
|
||
annotation = { 'type': 'textStyle/italic' };
|
||
|
||
this.model.annotate( annotations.contains( annotation ) ? 'clear' : 'set', annotation );
|
||
}
|
||
break;
|
||
// K
|
||
case 75:
|
||
if ( ve.ce.Surface.isShortcutKey( e ) ) {
|
||
if ( this.model.getSelection() && this.model.getSelection().getLength() ) {
|
||
e.preventDefault();
|
||
this.contextView.openInspector( 'link' );
|
||
}
|
||
}
|
||
break;
|
||
// Z
|
||
case 90:
|
||
if ( ve.ce.Surface.isShortcutKey( e ) ) {
|
||
if ( e.shiftKey ) {
|
||
// Ctrl+Shift+Z / Cmd+Shift+Z, redo
|
||
e.preventDefault();
|
||
this.stopPolling();
|
||
this.showSelection( this.model.redo() );
|
||
this.clearPollData();
|
||
this.startPolling();
|
||
} else {
|
||
// Ctrl+Z / Cmd+Z, undo
|
||
e.preventDefault();
|
||
this.stopPolling();
|
||
this.showSelection( this.model.undo() );
|
||
this.clearPollData();
|
||
this.startPolling();
|
||
}
|
||
}
|
||
break;
|
||
default:
|
||
// TODO: Filter (do not call stop and start) for [a-zA-Z0-9]
|
||
//if ( this.model.getSelection().isCollapsed() === false ) {
|
||
this.surfaceObserver.stop(true);
|
||
this.surfaceObserver.start();
|
||
//}
|
||
}
|
||
};
|
||
|
||
ve.ce.Surface.prototype.handleInsertAnnotations = function () {
|
||
var selection = this.model.getSelection();
|
||
|
||
// compare annotation stack to annotation for offset - 1, do pawn trick if necessary
|
||
var leftAnnotations = this.model.documentModel.data[selection.start - 1][1];
|
||
var insertAnnotations = this.model.documentModel.insertAnnotations;
|
||
|
||
if ( leftAnnotations == undefined && insertAnnotations.getLength() == 0 ) {
|
||
// plain text for both, do nothing
|
||
} else if ( leftAnnotations != undefined && ve.compareObjects ( leftAnnotations, insertAnnotations ) ) {
|
||
// objects are the same, do nothing
|
||
} else {
|
||
this.model.insertingAnnotations = true;
|
||
|
||
// Add the pawn with annotation, re-render, select pawn
|
||
this.model.change(
|
||
ve.dm.Transaction.newFromInsertion(
|
||
this.documentView.model,
|
||
selection.start,
|
||
[['\u2659', this.model.documentModel.insertAnnotations]]
|
||
),
|
||
new ve.Range( selection.start, selection.start + 1 )
|
||
);
|
||
|
||
// Remove pawn from the model and do not re-render
|
||
this.lock();
|
||
this.model.change(
|
||
ve.dm.Transaction.newFromRemoval(
|
||
this.documentView.model,
|
||
this.model.getSelection()
|
||
)
|
||
);
|
||
this.unlock();
|
||
|
||
// Reset the pawn trick when current event handling is done
|
||
var _this = this;
|
||
setTimeout(function() {
|
||
_this.model.insertingAnnotations = false;
|
||
_this.pollChanges();
|
||
}, 0);
|
||
}
|
||
};
|
||
|
||
|
||
/**
|
||
* Responds to copy events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.onCopy = function () {
|
||
var sel = rangy.getSelection(),
|
||
$frag = $( sel.getRangeAt(0).cloneContents() ),
|
||
dataArray = ve.copyArray( this.documentView.model.getData( this.model.getSelection() ) ),
|
||
key = '';
|
||
|
||
// Create key from text and element names
|
||
$frag.contents().each( function () {
|
||
key += this.textContent || this.nodeName;
|
||
} );
|
||
key = 've-' + key.replace( /\s/gm, '' );
|
||
|
||
// Set clipboard and localStorage
|
||
this.clipboard[key] = dataArray;
|
||
try {
|
||
localStorage.setItem(
|
||
key,
|
||
JSON.stringify( {
|
||
'time': new Date().getTime(),
|
||
'data': dataArray
|
||
} )
|
||
);
|
||
} catch ( e ) {
|
||
// Silently ignore
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Responds to cut events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.onCut = function ( e ) {
|
||
var surface = this;
|
||
|
||
this.stopPolling();
|
||
|
||
this.onCopy( e );
|
||
|
||
setTimeout( function () {
|
||
var selection,
|
||
tx;
|
||
|
||
// We don't like how browsers cut, so let's undo it and do it ourselves.
|
||
document.execCommand( 'undo', false, false );
|
||
|
||
selection = surface.model.getSelection();
|
||
|
||
// Transact
|
||
surface.autoRender = true;
|
||
tx = ve.dm.Transaction.newFromRemoval( surface.documentView.model, selection );
|
||
surface.model.change( tx, new ve.Range( selection.start ) );
|
||
surface.autoRender = false;
|
||
|
||
surface.clearPollData();
|
||
surface.startPolling();
|
||
}, 1 );
|
||
};
|
||
|
||
/**
|
||
* Responds to paste events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.onPaste = function () {
|
||
var tx,
|
||
view = this,
|
||
selection = this.model.getSelection();
|
||
|
||
this.stopPolling();
|
||
|
||
// Pasting into a range? Remove first.
|
||
if ( !rangy.getSelection().isCollapsed ) {
|
||
tx = ve.dm.Transaction.newFromRemoval( view.documentView.model, selection );
|
||
view.model.change( tx );
|
||
}
|
||
|
||
$( '#paste' ).html( '' ).show().focus();
|
||
|
||
setTimeout( function () {
|
||
var key = '',
|
||
pasteData,
|
||
tx;
|
||
|
||
// Create key from text and element names
|
||
$( '#paste' ).hide().contents().each( function () {
|
||
key += this.textContent || this.nodeName;
|
||
} );
|
||
key = 've-' + key.replace( /\s/gm, '' );
|
||
|
||
// Get linear model from clipboard, localStorage, or create array from unknown pasted content
|
||
if ( view.clipboard[key] ) {
|
||
pasteData = view.clipboard[key];
|
||
} else if ( localStorage.getItem( key ) ) {
|
||
pasteData = JSON.parse( localStorage.getItem( key ) ).data;
|
||
} else {
|
||
pasteData = $( '#paste' ).text().split( '' );
|
||
}
|
||
|
||
// Transact
|
||
tx = ve.dm.Transaction.newFromInsertion(
|
||
view.documentView.model,
|
||
selection.start,
|
||
pasteData
|
||
);
|
||
view.model.change( tx, new ve.Range( selection.start + pasteData.length ) );
|
||
view.documentView.documentNode.$.focus();
|
||
|
||
view.clearPollData();
|
||
view.startPolling();
|
||
}, 1 );
|
||
};
|
||
|
||
/**
|
||
* Responds to document key press events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.onKeyPress = function ( e ) {
|
||
return;
|
||
var node, selection, data;
|
||
|
||
ve.log( 'onKeyPress' );
|
||
|
||
if ( ve.ce.Surface.isShortcutKey( e ) || e.which === 13 ) {
|
||
return;
|
||
}
|
||
|
||
selection = this.model.getSelection();
|
||
|
||
if (
|
||
selection.getLength() === 0 &&
|
||
this.sluggable === true &&
|
||
this.hasSlugAtOffset( selection.start )
|
||
) {
|
||
this.sluggable = false;
|
||
this.stopPolling();
|
||
if ( this.documentView.getNodeFromOffset( selection.start ).getLength() !== 0 ) {
|
||
data = [ { 'type' : 'paragraph' }, { 'type' : '/paragraph' } ];
|
||
this.model.change(
|
||
ve.dm.Transaction.newFromInsertion(
|
||
this.documentView.model,
|
||
selection.start,
|
||
data
|
||
),
|
||
new ve.Range( selection.start + 1 )
|
||
);
|
||
node = this.documentView.getNodeFromOffset( selection.start + 1 );
|
||
} else {
|
||
node = this.documentView.getNodeFromOffset( selection.start );
|
||
}
|
||
node.$.empty();
|
||
// TODO: Can this be chained to the above line?
|
||
node.$.append( document.createTextNode( '' ) );
|
||
this.clearPollData();
|
||
this.startPolling();
|
||
}
|
||
|
||
this.handleInsertAnnotations();
|
||
|
||
// Is there an expanded range and something other than keycodes 0 (nothing) or 27 (esc) were pressed?
|
||
if ( selection.getLength() > 0 && ( e.which !== 0 || e.which !== 27 ) ) {
|
||
this.stopPolling();
|
||
this.model.change(
|
||
ve.dm.Transaction.newFromRemoval(
|
||
this.documentView.model,
|
||
selection
|
||
),
|
||
new ve.Range( selection.start )
|
||
);
|
||
this.clearPollData();
|
||
this.startPolling();
|
||
}
|
||
|
||
};
|
||
|
||
/**
|
||
* Called from ve.dm.Surface.prototype.change.
|
||
*
|
||
* @method
|
||
* @param {ve.dm.Transaction|null} transaction
|
||
* @param {ve.Range|undefined} selection
|
||
*/
|
||
ve.ce.Surface.prototype.onChange = function ( transaction, selection ) {
|
||
if ( selection && !this.isLocked() ) {
|
||
this.showSelection( selection );
|
||
|
||
// Responsible for Debouncing the ContextView Icon until select events are finished being
|
||
// fired.
|
||
// TODO: Use ve.debounce method to abstract usage of setTimeout
|
||
clearTimeout( this.selectionTimeout );
|
||
this.selectionTimeout = setTimeout(
|
||
ve.bind( this.updateContextIcon, this ),
|
||
250
|
||
);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Responds to enter key events.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.prototype.handleEnter = function ( e ) {
|
||
var tx, outerParent, outerChildrenCount, list,
|
||
selection = this.model.getSelection(),
|
||
documentModel = this.model.getDocument(),
|
||
emptyParagraph = [{ 'type': 'paragraph' }, { 'type': '/paragraph' }],
|
||
advanceCursor = true,
|
||
node = this.documentView.getNodeFromOffset( selection.from ),
|
||
nodeModel = node.getModel(),
|
||
cursor = selection.from,
|
||
contentBranchModel = nodeModel.isContent() ? nodeModel.getParent() : nodeModel,
|
||
contentBranchModelRange = contentBranchModel.getRange(),
|
||
stack = [],
|
||
outermostNode = null;
|
||
|
||
// Stop polling while we work
|
||
this.stopPolling();
|
||
|
||
// Handle removal first
|
||
if ( selection.from !== selection.to ) {
|
||
tx = ve.dm.Transaction.newFromRemoval( documentModel, selection );
|
||
selection = tx.translateRange( selection );
|
||
this.model.change( tx, selection );
|
||
}
|
||
|
||
// Handle insertion
|
||
if (
|
||
contentBranchModel.getType() !== 'paragraph' &&
|
||
(
|
||
cursor === contentBranchModelRange.from ||
|
||
cursor === contentBranchModelRange.to
|
||
)
|
||
) {
|
||
// If we're at the start/end of something that's not a paragraph, insert a paragraph
|
||
// before/after
|
||
if ( cursor === contentBranchModelRange.from ) {
|
||
tx = ve.dm.Transaction.newFromInsertion(
|
||
documentModel, contentBranchModel.getOuterRange().from, emptyParagraph
|
||
);
|
||
} else if ( cursor === contentBranchModelRange.to ) {
|
||
tx = ve.dm.Transaction.newFromInsertion(
|
||
documentModel, contentBranchModel.getOuterRange().to, emptyParagraph
|
||
);
|
||
}
|
||
} else {
|
||
// Split
|
||
ve.Node.traverseUpstream( node, function ( node ) {
|
||
if ( !node.canBeSplit() ) {
|
||
return false;
|
||
}
|
||
stack.splice(
|
||
stack.length / 2,
|
||
0,
|
||
{ 'type': '/' + node.type },
|
||
node.model.getClonedElement()
|
||
);
|
||
outermostNode = node;
|
||
if ( e.shiftKey ) {
|
||
return false;
|
||
} else {
|
||
return true;
|
||
}
|
||
} );
|
||
|
||
outerParent = outermostNode.getModel().getParent();
|
||
outerChildrenCount = outerParent.getChildren().length;
|
||
|
||
if (
|
||
// This is a list item
|
||
outermostNode.type === 'listItem' &&
|
||
// This is the last list item
|
||
outerParent.getChildren()[outerChildrenCount - 1] === outermostNode.getModel() &&
|
||
// There is one child
|
||
outermostNode.children.length === 1 &&
|
||
// The child is empty
|
||
node.model.length === 0
|
||
) {
|
||
// Enter was pressed in an empty list item.
|
||
list = outermostNode.getModel().getParent();
|
||
// Remove the list item
|
||
tx = ve.dm.Transaction.newFromRemoval(
|
||
documentModel, outermostNode.getModel().getOuterRange()
|
||
);
|
||
this.model.change( tx );
|
||
// Insert a paragraph
|
||
tx = ve.dm.Transaction.newFromInsertion(
|
||
documentModel, list.getOuterRange().to, emptyParagraph
|
||
);
|
||
advanceCursor = false;
|
||
} else {
|
||
// We must process the transaction first because getRelativeContentOffset can't help us
|
||
// yet
|
||
tx = ve.dm.Transaction.newFromInsertion( documentModel, selection.from, stack );
|
||
}
|
||
}
|
||
|
||
// Commit the transaction
|
||
this.model.change( tx );
|
||
|
||
// Now we can move the cursor forward
|
||
if ( advanceCursor ) {
|
||
this.model.change(
|
||
null, new ve.Range( documentModel.getRelativeContentOffset( selection.from, 1 ) )
|
||
);
|
||
} else {
|
||
this.model.change(
|
||
null, new ve.Range( documentModel.getNearestContentOffset( selection.from ) )
|
||
);
|
||
}
|
||
// Reset and resume polling
|
||
this.clearPollData();
|
||
this.startPolling();
|
||
};
|
||
|
||
/**
|
||
* Responds to backspace and delete key events.
|
||
*
|
||
* @method
|
||
* @param {Boolean} Key was a backspace
|
||
*/
|
||
ve.ce.Surface.prototype.handleDelete = function ( e, backspace ) {
|
||
var selection = this.model.getSelection(),
|
||
sourceOffset,
|
||
targetOffset,
|
||
sourceSplitableNode,
|
||
targetSplitableNode,
|
||
tx,
|
||
cursorAt,
|
||
sourceNode,
|
||
targetNode,
|
||
sourceData,
|
||
nodeToDelete,
|
||
adjacentData,
|
||
adjacentText,
|
||
adjacentTextAfterMatch,
|
||
endOffset,
|
||
i;
|
||
|
||
if ( selection.from === selection.to ) {
|
||
// Set source and target linmod offsets
|
||
if ( backspace ) {
|
||
sourceOffset = selection.to;
|
||
targetOffset = this.getNearestCorrectOffset( sourceOffset - 1, -1 );
|
||
|
||
// At the beginning of the document - don't do anything and preventDefault
|
||
if ( sourceOffset === targetOffset ) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
|
||
} else {
|
||
sourceOffset = this.model.getDocument().getRelativeContentOffset( selection.to, 1 );
|
||
targetOffset = selection.to;
|
||
|
||
// At the end of the document - don't do anything and preventDefault
|
||
if ( sourceOffset <= targetOffset ) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Set source and target nodes
|
||
sourceNode = this.documentView.getNodeFromOffset( sourceOffset, false ),
|
||
targetNode = this.documentView.getNodeFromOffset( targetOffset, false );
|
||
|
||
if ( sourceNode.type === targetNode.type ) {
|
||
sourceSplitableNode = ve.ce.Node.getSplitableNode( sourceNode );
|
||
targetSplitableNode = ve.ce.Node.getSplitableNode( targetNode );
|
||
}
|
||
//ve.log(sourceSplitableNode, targetSplitableNode);
|
||
|
||
// Save target location of cursor
|
||
cursorAt = targetOffset;
|
||
|
||
// Get text from cursor location to end of node in the proper direction
|
||
adjacentData = null;
|
||
adjacentText = '';
|
||
|
||
if ( backspace ) {
|
||
adjacentData = sourceNode.model.doc.data.slice(
|
||
sourceNode.model.getOffset() + ( sourceNode.model.isWrapped() ? 1 : 0 ) ,
|
||
sourceOffset
|
||
);
|
||
} else {
|
||
endOffset = targetNode.model.getOffset() +
|
||
targetNode.model.getLength() +
|
||
( targetNode.model.isWrapped() ? 1 : 0 );
|
||
adjacentData = targetNode.model.doc.data.slice( targetOffset, endOffset );
|
||
}
|
||
|
||
for ( i = 0; i < adjacentData.length; i++ ) {
|
||
adjacentText += adjacentData[i][0];
|
||
}
|
||
|
||
adjacentTextAfterMatch = adjacentText.match(
|
||
/[a-zA-Z\-_’'‘ÆÐƎƏƐƔIJŊŒẞÞǷȜæðǝəɛɣijŋœĸſßþƿȝĄƁÇĐƊĘĦĮƘŁØƠŞȘŢȚŦŲƯY̨Ƴąɓçđɗęħįƙłøơşșţțŧųưy̨ƴÁÀÂÄǍĂĀÃÅǺĄÆǼǢƁĆĊĈČÇĎḌĐƊÐÉÈĖÊËĚĔĒĘẸƎƏƐĠĜǦĞĢƔáàâäǎăāãåǻąæǽǣɓćċĉčçďḍđɗðéèėêëěĕēęẹǝəɛġĝǧğģɣĤḤĦIÍÌİÎÏǏĬĪĨĮỊIJĴĶƘĹĻŁĽĿʼNŃN̈ŇÑŅŊÓÒÔÖǑŎŌÕŐỌØǾƠŒĥḥħıíìiîïǐĭīĩįịijĵķƙĸĺļłľŀʼnńn̈ňñņŋóòôöǒŏōõőọøǿơœŔŘŖŚŜŠŞȘṢẞŤŢṬŦÞÚÙÛÜǓŬŪŨŰŮŲỤƯẂẀŴẄǷÝỲŶŸȲỸƳŹŻŽẒŕřŗſśŝšşșṣßťţṭŧþúùûüǔŭūũűůųụưẃẁŵẅƿýỳŷÿȳỹƴźżžẓ]/g
|
||
);
|
||
|
||
// If there are "normal" characters in the adjacent text, let the browser handle natively.
|
||
if ( adjacentTextAfterMatch !== null && adjacentTextAfterMatch.length ) {
|
||
return;
|
||
}
|
||
|
||
ve.log('handleDelete programatically');
|
||
e.preventDefault();
|
||
this.stopPolling();
|
||
|
||
if (
|
||
// Source and target are the same node
|
||
sourceNode === targetNode ||
|
||
(
|
||
// Source and target have the same parent (list items)
|
||
sourceSplitableNode !== undefined &&
|
||
sourceSplitableNode.getParent() === targetSplitableNode.getParent()
|
||
)
|
||
) {
|
||
// Simple removal
|
||
tx = ve.dm.Transaction.newFromRemoval(
|
||
this.documentView.model, new ve.Range( targetOffset, sourceOffset )
|
||
);
|
||
this.model.change( tx, new ve.Range( cursorAt ) );
|
||
} else if ( sourceNode.getType() === 'document' ) {
|
||
// Source is a slug - move the cursor somewhere useful
|
||
this.model.change( null, new ve.Range( cursorAt ) );
|
||
} else {
|
||
// Source and target are different nodes or do not share a parent, perform tricky merge
|
||
// Get the data for the source node
|
||
sourceData = this.documentView.model.getData( sourceNode.model.getRange() );
|
||
|
||
// Find the node that should be completely removed
|
||
nodeToDelete = sourceNode;
|
||
ve.Node.traverseUpstream( nodeToDelete, function ( node ) {
|
||
if ( node.getParent().children.length === 1 ) {
|
||
nodeToDelete = node.getParent();
|
||
return true;
|
||
} else {
|
||
return false;
|
||
}
|
||
} );
|
||
|
||
this.model.change(
|
||
[
|
||
// Remove source node or source node ancestor
|
||
ve.dm.Transaction.newFromRemoval(
|
||
this.documentView.model, nodeToDelete.getModel().getOuterRange()
|
||
),
|
||
// Append source data to target
|
||
ve.dm.Transaction.newFromInsertion(
|
||
this.documentView.model, targetOffset, sourceData
|
||
)
|
||
],
|
||
new ve.Range( cursorAt )
|
||
);
|
||
}
|
||
} else {
|
||
// Selection removal
|
||
ve.log('selection removal - handle programatically');
|
||
e.preventDefault();
|
||
this.model.change(
|
||
ve.dm.Transaction.newFromRemoval( this.documentView.model, selection ),
|
||
new ve.Range( selection.start )
|
||
);
|
||
}
|
||
|
||
this.clearPollData();
|
||
this.startPolling();
|
||
};
|
||
|
||
/**
|
||
* Shows the cursor at a given offset.
|
||
*
|
||
* @method
|
||
* @param {Number} offset Offset to show cursor at
|
||
*/
|
||
ve.ce.Surface.prototype.showCursor = function ( offset ) {
|
||
this.showSelection( new ve.Range( offset ) );
|
||
};
|
||
|
||
/**
|
||
* Shows selection on a given range.
|
||
*
|
||
* @method
|
||
* @param {ve.Range} range Range to show selection on
|
||
*/
|
||
ve.ce.Surface.prototype.showSelection = function ( range ) {
|
||
var rangySel = rangy.getSelection(),
|
||
rangyRange = rangy.createRange(),
|
||
start,
|
||
end;
|
||
|
||
if ( range.start !== range.end ) {
|
||
start = this.getNodeAndOffset( range.start );
|
||
end = this.getNodeAndOffset( range.end );
|
||
|
||
if ( $.browser.msie ) {
|
||
if ( range.start === range.from ) {
|
||
if (
|
||
start.node === this.poll.rangySelection.anchorNode &&
|
||
start.offset === this.poll.rangySelection.anchorOffset &&
|
||
end.node === this.poll.rangySelection.focusNode &&
|
||
end.offset === this.poll.rangySelection.focusOffset
|
||
) {
|
||
return;
|
||
}
|
||
} else {
|
||
if (
|
||
end.node === this.poll.rangySelection.anchorNode &&
|
||
end.offset === this.poll.rangySelection.anchorOffset &&
|
||
start.node === this.poll.rangySelection.focusNode &&
|
||
start.offset === this.poll.rangySelection.focusOffset
|
||
) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
rangyRange.setStart( start.node, start.offset );
|
||
rangyRange.setEnd( end.node, end.offset );
|
||
rangySel.removeAllRanges();
|
||
rangySel.addRange( rangyRange, range.start !== range.from );
|
||
} else {
|
||
start = end = this.getNodeAndOffset( range.start );
|
||
|
||
if ( $.browser.msie ) {
|
||
if (
|
||
start.node === this.poll.rangySelection.anchorNode &&
|
||
start.offset === this.poll.rangySelection.anchorOffset
|
||
) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
rangyRange.setStart( start.node, start.offset );
|
||
rangySel.setSingleRange( rangyRange );
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Gets the nearest offset that a cursor can actually be placed at.
|
||
*
|
||
* TODO: Find a better name and a better place for this method
|
||
*
|
||
* @method
|
||
* @param {Number} offset Offset to start looking at
|
||
* @param {Number} [direction=-1] Direction to look in, +1 or -1
|
||
*/
|
||
ve.ce.Surface.prototype.getNearestCorrectOffset = function ( offset, direction ) {
|
||
var contentOffset, structuralOffset;
|
||
direction = direction > 0 ? 1 : -1;
|
||
|
||
if (
|
||
ve.dm.Document.isContentOffset( this.documentView.model.data, offset ) ||
|
||
this.hasSlugAtOffset( offset )
|
||
) {
|
||
return offset;
|
||
}
|
||
|
||
contentOffset = this.documentView.model.getNearestContentOffset( offset, direction );
|
||
structuralOffset = this.documentView.model.getNearestStructuralOffset( offset, direction, true );
|
||
|
||
if ( !this.hasSlugAtOffset( structuralOffset ) ) {
|
||
return contentOffset;
|
||
}
|
||
|
||
if ( direction === 1 ) {
|
||
if ( contentOffset < offset ) {
|
||
return structuralOffset;
|
||
} else {
|
||
return Math.min( contentOffset, structuralOffset );
|
||
}
|
||
} else {
|
||
if ( contentOffset > offset ) {
|
||
return structuralOffset;
|
||
} else {
|
||
return Math.max( contentOffset, structuralOffset );
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Checks if a given offset is inside a slug.
|
||
*
|
||
* TODO: Find a better name and a better place for this method - probably in a document view?
|
||
*
|
||
* @method
|
||
* @param {Number} offset Offset to check for a slug at
|
||
* @returns {Boolean} A slug exists at the given offset
|
||
*/
|
||
ve.ce.Surface.prototype.hasSlugAtOffset = function ( offset ) {
|
||
return this.documentView.getSlugAtOffset( offset ) || false;
|
||
};
|
||
|
||
/**
|
||
* Gets a DOM node and offset that can be used to place a cursor, based on a given offset.
|
||
*
|
||
* The results of this function are meant to be used with rangy.
|
||
*
|
||
* @method
|
||
* @param {Number} offset Linear model offset
|
||
* @returns {Object} Object containing a node and offset property where node is an HTML element and
|
||
* offset is the position within the element
|
||
*/
|
||
ve.ce.Surface.prototype.getNodeAndOffset = function ( offset ) {
|
||
var node = this.documentView.getNodeFromOffset( offset ),
|
||
startOffset = this.documentView.getDocumentNode().getOffsetFromNode( node ) +
|
||
( ( node.isWrapped() ) ? 1 : 0 ),
|
||
current = [node.$.contents(), 0],
|
||
stack = [current],
|
||
item,
|
||
$item,
|
||
length;
|
||
|
||
while ( stack.length > 0 ) {
|
||
if ( current[1] >= current[0].length ) {
|
||
stack.pop();
|
||
current = stack[ stack.length - 1 ];
|
||
continue;
|
||
}
|
||
item = current[0][current[1]];
|
||
if ( item.nodeType === Node.TEXT_NODE ) {
|
||
length = item.textContent.length;
|
||
if ( offset >= startOffset && offset <= startOffset + length ) {
|
||
return {
|
||
node: item,
|
||
offset: offset - startOffset
|
||
};
|
||
} else {
|
||
startOffset += length;
|
||
}
|
||
} else if ( item.nodeType === Node.ELEMENT_NODE ) {
|
||
$item = current[0].eq( current[1] );
|
||
if ( $item.hasClass('ve-ce-slug') ) {
|
||
if ( offset === startOffset ) {
|
||
return {
|
||
node: $item[0],
|
||
offset: 1
|
||
};
|
||
}
|
||
} else if ( $item.is( '.ve-ce-branchNode, .ve-ce-leafNode' ) ) {
|
||
length = $item.data( 'node' ).model.getOuterLength();
|
||
if ( offset >= startOffset && offset < startOffset + length ) {
|
||
stack.push( [$item.contents(), 0] );
|
||
current[1]++;
|
||
current = stack[stack.length-1];
|
||
continue;
|
||
} else {
|
||
startOffset += length;
|
||
}
|
||
} else {
|
||
stack.push( [$item.contents(), 0] );
|
||
current[1]++;
|
||
current = stack[stack.length-1];
|
||
continue;
|
||
}
|
||
|
||
}
|
||
current[1]++;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Updates the context icon.
|
||
*
|
||
* @method
|
||
*/
|
||
ve.ce.Surface.prototype.updateContextIcon = function () {
|
||
var selection = this.model.getSelection(),
|
||
doc = this.model.getDocument();
|
||
if ( this.contextView ) {
|
||
if ( doc.getText( selection ).length > 0 ) {
|
||
this.contextView.set();
|
||
} else {
|
||
this.contextView.clear();
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Gets the coordinates of the selection anchor.
|
||
*
|
||
* @method
|
||
*/
|
||
ve.ce.Surface.prototype.getSelectionRect = function () {
|
||
var rangySel = rangy.getSelection();
|
||
return {
|
||
start: rangySel.getStartDocumentPos(),
|
||
end: rangySel.getEndDocumentPos()
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Tests if the modifier key for keyboard shortcuts is pressed.
|
||
*
|
||
* @method
|
||
* @param {jQuery.Event} e
|
||
*/
|
||
ve.ce.Surface.isShortcutKey = function ( e ) {
|
||
if ( e.ctrlKey || e.metaKey ) {
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
/**
|
||
* Removes localStorage keys for copy and paste after a day.
|
||
*
|
||
* @method
|
||
*/
|
||
ve.ce.Surface.clearLocalStorage = function () {
|
||
var i, len, key,
|
||
time, now,
|
||
keysToRemove = [];
|
||
|
||
for ( i = 0, len = localStorage.length; i < len; i++ ) {
|
||
key = localStorage.key( i );
|
||
|
||
if ( key.indexOf( 've-' ) !== 0 ) {
|
||
return false;
|
||
}
|
||
|
||
time = JSON.parse( localStorage.getItem( key ) ).time;
|
||
now = new Date().getTime();
|
||
|
||
// Offset: 24 days (in miliseconds)
|
||
if ( now - time > ( 24 * 3600 * 1000 ) ) {
|
||
// Don't remove keys while iterating. Store them for later removal.
|
||
keysToRemove.push( key );
|
||
}
|
||
}
|
||
|
||
$.each( keysToRemove, function ( i, val ) {
|
||
localStorage.removeItem( val );
|
||
} );
|
||
};
|
||
|
||
/**
|
||
* Gets the surface model.
|
||
*
|
||
* @method
|
||
* @returns {ve.dm.Surface} Surface model
|
||
*/
|
||
ve.ce.Surface.prototype.getModel = function () {
|
||
return this.model;
|
||
};
|
||
|
||
/**
|
||
* Gets the document view.
|
||
*
|
||
* @method
|
||
* @returns {ve.ce.Document} Document view
|
||
*/
|
||
ve.ce.Surface.prototype.getDocument = function () {
|
||
return this.documentView;
|
||
};
|
||
|
||
/**
|
||
* @method
|
||
*/
|
||
ve.ce.Surface.prototype.lock = function() {
|
||
this.locked = true;
|
||
};
|
||
|
||
/**
|
||
* @method
|
||
*/
|
||
ve.ce.Surface.prototype.unlock = function() {
|
||
this.locked = false;
|
||
};
|
||
|
||
/**
|
||
* @method
|
||
*/
|
||
ve.ce.Surface.prototype.isLocked = function() {
|
||
return this.locked;
|
||
}; |