mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-09-26 19:56:49 +00:00
c40174b60c
This license change is aimed at maximizing the reusability of this code in other projects. VisualEditor is more than just an awesome editor for MediaWiki, it's the new editor for the entire internet. Added license and author files, plus mentions of the license to all VisualEditor PHP, JavaScript and CSS files. Parser files have not been modified but are effectively re-licensed since there's no overriding license information. 3rd party libraries are not changed, but are all already MIT licensed. Change-Id: I895b256325db7c8689756edab34523de4418b0f2
1146 lines
31 KiB
JavaScript
1146 lines
31 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
|
|
* @param model {ve.dm.Surface} Model to observe
|
|
*/
|
|
ve.ce.Surface = function( $container, model ) {
|
|
// Inheritance
|
|
ve.EventEmitter.call( this );
|
|
|
|
// Properties
|
|
this.model = model;
|
|
this.documentView = null; // See initialization below
|
|
this.contextView = null; // See initialization below
|
|
this.selectionTimeout = null;
|
|
this.$ = $container;
|
|
this.$document = $( document );
|
|
this.clipboard = {};
|
|
this.render = true; // Used in ve.ce.TextNode
|
|
this.sluggable = true;
|
|
|
|
this.poll = {
|
|
text: null,
|
|
hash: null,
|
|
node: null,
|
|
range: null,
|
|
rangySelection: {
|
|
anchorNode: null,
|
|
anchorOffset: null,
|
|
focusNode: null,
|
|
focusOffset: null
|
|
},
|
|
polling: false,
|
|
timeout: null,
|
|
frequency: 100
|
|
};
|
|
|
|
// Events
|
|
this.model.on( 'change', ve.proxy( this.onChange, this ) );
|
|
this.on( 'contentChange', ve.proxy( this.onContentChange, this ) );
|
|
this.$.on( {
|
|
'cut copy': ve.proxy( this.onCutCopy, this ),
|
|
'paste': ve.proxy( this.onPaste, this ),
|
|
'dragover drop': function( e ) {
|
|
// Prevent content drag & drop
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
} );
|
|
if ( $.browser.msie ) {
|
|
this.$.on( 'beforepaste', ve.proxy( this.onPaste, this ) );
|
|
}
|
|
|
|
// Initialization
|
|
try {
|
|
document.execCommand( 'enableObjectResizing', false, false );
|
|
document.execCommand( 'enableInlineTableEditing', false, false );
|
|
} catch ( e ) {
|
|
// Silently ignore
|
|
}
|
|
rangy.init();
|
|
// 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.$.append( this.documentView.getDocumentNode().$ );
|
|
|
|
// DocumentNode Events
|
|
this.documentView.getDocumentNode().$.on( {
|
|
'focus': ve.proxy( this.documentOnFocus, this ),
|
|
'blur': ve.proxy( this.documentOnBlur, this )
|
|
} );
|
|
};
|
|
|
|
/* Methods */
|
|
|
|
ve.ce.Surface.prototype.documentOnFocus = function() {
|
|
ve.log( 'documentOnFocus' );
|
|
|
|
this.$document.off( '.ve-ce-Surface' );
|
|
|
|
this.$document.on( {
|
|
// key down
|
|
'keydown.ve-ce-Surface': ve.proxy( this.onKeyDown, this ),
|
|
'keypress.ve-ce-Surface': ve.proxy( this.onKeyPress, this ),
|
|
// mouse down
|
|
'mousedown.ve-ce-Surface': ve.proxy( this.onMouseDown, this )
|
|
} );
|
|
this.startPolling( true );
|
|
};
|
|
|
|
ve.ce.Surface.prototype.documentOnBlur = function() {
|
|
ve.log( 'documentOnBlur' );
|
|
|
|
this.$document.off( '.ve-ce-Surface' );
|
|
this.stopPolling();
|
|
};
|
|
|
|
ve.ce.Surface.prototype.onMouseDown = function( e ) {
|
|
ve.log( 'onMouseDown' );
|
|
|
|
if ( this.poll.polling === true ) {
|
|
this.pollChanges();
|
|
if ( $( e.target ).closest( '.ve-ce-documentNode' ).length === 0 ) {
|
|
this.stopPolling();
|
|
} else {
|
|
this.pollChanges( true );
|
|
}
|
|
}
|
|
|
|
// Block / prevent triple click
|
|
if ( e.originalEvent.detail > 2 ) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
ve.ce.Surface.prototype.onKeyDown = function( e ) {
|
|
var offset,
|
|
relativeContentOffset,
|
|
relativeStructuralOffset,
|
|
relativeStructuralOffsetNode,
|
|
hasSlug,
|
|
newOffset,
|
|
annotations,
|
|
annotation;
|
|
switch ( e.keyCode ) {
|
|
// Indenting list items doesn't work yet, so disable tab handling for now
|
|
/*
|
|
// Tab Key
|
|
case 9:
|
|
e.preventDefault();
|
|
// FIXME check if indentation is even possible here, insert literal tab otherwise
|
|
ve.ui.IndentationButtonTool.changeListLevel( this.model , 'in' );
|
|
break;
|
|
// Shift + Tab Key
|
|
case 16:
|
|
e.preventDefault();
|
|
// FIXME check if indentation is even possible here
|
|
ve.ui.IndentationButtonTool.changeListLevel( this.model , 'out' );
|
|
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 = relativeStructuralOffsetNode.hasSlugAtOffset( relativeStructuralOffset );
|
|
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 = relativeStructuralOffsetNode.hasSlugAtOffset( relativeStructuralOffset );
|
|
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( true );
|
|
e.preventDefault();
|
|
break;
|
|
// Delete
|
|
case 46:
|
|
this.handleDelete( false );
|
|
e.preventDefault();
|
|
break;
|
|
// B
|
|
case 66:
|
|
if ( ve.ce.Surface.isShortcutKey( e ) ) {
|
|
// Ctrl+B / Cmd+B, annotate with bold
|
|
e.preventDefault();
|
|
annotations = this.documentView.model.getAnnotationsFromRange( this.model.getSelection() );
|
|
annotation = { 'type': 'textStyle/bold' };
|
|
|
|
this.model.annotate( annotations[ve.getHash(annotation)] ? 'clear' : 'set', annotation );
|
|
}
|
|
break;
|
|
// I
|
|
case 73:
|
|
if ( ve.ce.Surface.isShortcutKey( e ) ) {
|
|
// Ctrl+I / Cmd+I, annotate with italic
|
|
e.preventDefault();
|
|
annotations = this.documentView.model.getAnnotationsFromRange( this.model.getSelection() );
|
|
annotation = { 'type': 'textStyle/italic' };
|
|
|
|
this.model.annotate( annotations[ve.getHash(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:
|
|
if ( this.poll.polling === false ) {
|
|
this.poll.polling = true;
|
|
this.pollChanges();
|
|
}
|
|
}
|
|
};
|
|
|
|
ve.ce.Surface.prototype.onCutCopy = function( e ) {
|
|
var view = this,
|
|
sel = rangy.getSelection(),
|
|
$frag = null,
|
|
key = '';
|
|
|
|
this.stopPolling();
|
|
|
|
// Create key from text and element names
|
|
$frag = $( sel.getRangeAt(0).cloneContents() );
|
|
$frag.contents().each( function () {
|
|
key += this.textContent || this.nodeName;
|
|
} );
|
|
key = key.replace( /\s/gm, '' );
|
|
|
|
// Set surface clipboard
|
|
this.clipboard[key] = ve.copyArray(
|
|
this.documentView.model.getData( this.model.getSelection() )
|
|
);
|
|
|
|
if ( e.type === 'cut' ) {
|
|
this.stopPolling();
|
|
|
|
setTimeout( function() {
|
|
var selection = null,
|
|
tx = null;
|
|
|
|
// We don't like how browsers cut, so let's undo it and do it ourselves.
|
|
document.execCommand( 'undo', false, false );
|
|
|
|
selection = view.model.getSelection();
|
|
|
|
// Transact
|
|
view.autoRender = true;
|
|
tx = ve.dm.Transaction.newFromRemoval( view.documentView.model, selection );
|
|
view.model.change( tx, new ve.Range( selection.start ) );
|
|
view.autoRender = false;
|
|
|
|
view.clearPollData();
|
|
view.startPolling();
|
|
}, 1 );
|
|
}
|
|
};
|
|
|
|
ve.ce.Surface.prototype.onPaste = function( e ) {
|
|
var view = this,
|
|
selection = this.model.getSelection(),
|
|
tx = null;
|
|
|
|
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 = null,
|
|
tx = null;
|
|
|
|
// Create key from text and element names
|
|
$('#paste').hide().contents().each(function() {
|
|
key += this.textContent || this.nodeName;
|
|
});
|
|
key = key.replace( /\s/gm, '' );
|
|
|
|
// Get linear model from surface clipboard or create array from unknown pasted content
|
|
pasteData = ( view.clipboard[key] ) ? view.clipboard[key] : $('#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 );
|
|
};
|
|
|
|
ve.ce.Surface.prototype.onKeyPress = function( e ) {
|
|
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();
|
|
node.$.append( document.createTextNode('') );
|
|
this.clearPollData();
|
|
this.startPolling();
|
|
}
|
|
|
|
if ( selection.getLength() > 0 && e.which !== 0 ) {
|
|
this.stopPolling();
|
|
this.model.change(
|
|
ve.dm.Transaction.newFromRemoval(
|
|
this.documentView.model,
|
|
selection
|
|
),
|
|
new ve.Range( selection.start )
|
|
);
|
|
this.clearPollData();
|
|
this.startPolling();
|
|
}
|
|
|
|
};
|
|
|
|
ve.ce.Surface.prototype.startPolling = function( async ) {
|
|
ve.log( 'startPolling' );
|
|
|
|
this.poll.polling = true;
|
|
this.pollChanges( async );
|
|
};
|
|
|
|
ve.ce.Surface.prototype.stopPolling = function() {
|
|
ve.log( 'stopPolling' );
|
|
|
|
this.poll.polling = false;
|
|
clearTimeout( this.poll.timeout );
|
|
};
|
|
|
|
ve.ce.Surface.prototype.clearPollData = function() {
|
|
ve.log( 'clearPollData' );
|
|
|
|
this.poll.text = null;
|
|
this.poll.hash = null;
|
|
this.poll.node = null;
|
|
this.poll.rangySelection.anchorNode = null;
|
|
this.poll.rangySelection.anchorOffset = null;
|
|
this.poll.rangySelection.focusNode = null;
|
|
this.poll.rangySelection.focusOffset = null;
|
|
};
|
|
|
|
ve.ce.Surface.prototype.pollChanges = function( async ) {
|
|
var delay, node, range, rangySelection,
|
|
$anchorNode, $focusNode,
|
|
text, hash;
|
|
|
|
delay = ve.proxy( function( async ) {
|
|
if ( this.poll.polling ) {
|
|
if ( this.poll.timeout !== null ) {
|
|
clearTimeout( this.poll.timeout );
|
|
}
|
|
this.poll.timeout = setTimeout(
|
|
ve.proxy( this.pollChanges, this ), async ? 0 : this.poll.frequency
|
|
);
|
|
}
|
|
}, this );
|
|
|
|
if ( async ) {
|
|
delay( true );
|
|
return;
|
|
}
|
|
|
|
node = this.poll.node;
|
|
range = this.poll.range;
|
|
rangySelection = rangy.getSelection();
|
|
|
|
if (
|
|
rangySelection.anchorNode !== this.poll.rangySelection.anchorNode ||
|
|
rangySelection.anchorOffset !== this.poll.rangySelection.anchorOffset ||
|
|
rangySelection.focusNode !== this.poll.rangySelection.focusNode ||
|
|
rangySelection.focusOffset !== this.poll.rangySelection.focusOffset
|
|
) {
|
|
this.poll.rangySelection.anchorNode = rangySelection.anchorNode;
|
|
this.poll.rangySelection.anchorOffset = rangySelection.anchorOffset;
|
|
this.poll.rangySelection.focusNode = rangySelection.focusNode;
|
|
this.poll.rangySelection.focusOffset = rangySelection.focusOffset;
|
|
|
|
// TODO: Optimize for the case of collapsed (rangySelection.isCollapsed) range
|
|
|
|
$anchorNode = $( rangySelection.anchorNode ).closest( '.ve-ce-branchNode' );
|
|
$focusNode = $( rangySelection.focusNode ).closest( '.ve-ce-branchNode' );
|
|
|
|
if ( $anchorNode[0] === $focusNode[0] ) {
|
|
node = $anchorNode[0];
|
|
} else {
|
|
node = null;
|
|
}
|
|
|
|
// TODO: Do we really need to figure out range even if node is null? (YES)
|
|
|
|
range = new ve.Range(
|
|
this.getOffset( rangySelection.anchorNode, rangySelection.anchorOffset ),
|
|
this.getOffset( rangySelection.focusNode, rangySelection.focusOffset )
|
|
);
|
|
}
|
|
|
|
// TODO: Invastigate more when and why node is null and what to do in those cases
|
|
|
|
if ( this.poll.node !== node ) {
|
|
if ( node === null ) {
|
|
this.poll.text = this.poll.hash = this.poll.node = null;
|
|
} else {
|
|
this.poll.text = ve.ce.getDomText( node );
|
|
this.poll.hash = ve.ce.getDomHash( node );
|
|
this.poll.node = node;
|
|
}
|
|
} else {
|
|
if ( node !== null ) {
|
|
text = ve.ce.getDomText( node );
|
|
hash = ve.ce.getDomHash( node );
|
|
if ( this.poll.text !== text || this.poll.hash !== hash ) {
|
|
this.emit( 'contentChange', {
|
|
'node': node,
|
|
'old': {
|
|
'text': this.poll.text,
|
|
'hash': this.poll.hash,
|
|
'range': this.poll.range
|
|
},
|
|
'new': {
|
|
'text': text,
|
|
'hash': hash,
|
|
'range': range
|
|
}
|
|
} );
|
|
this.poll.text = text;
|
|
this.poll.hash = hash;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( this.poll.range !== range ) {
|
|
|
|
// TODO: Fix range
|
|
if ( range.getLength() === 0 ) {
|
|
range = new ve.Range( this.getNearestCorrectOffset( range.start, 1 ) );
|
|
}
|
|
|
|
this.model.change( null, range );
|
|
this.poll.range = range;
|
|
}
|
|
|
|
delay();
|
|
};
|
|
|
|
ve.ce.Surface.prototype.onContentChange = function( e ) {
|
|
var nodeOffset = $( e.node ).data( 'node' ).model.getOffset(),
|
|
offsetDiff = (
|
|
e.old.range !== null &&
|
|
e['new'].range !== null &&
|
|
e.old.range.getLength() === 0 &&
|
|
e['new'].range.getLength() === 0
|
|
) ? e['new'].range.start - e.old.range.start : null,
|
|
lengthDiff = e['new'].text.length - e.old.text.length,
|
|
data,
|
|
annotations;
|
|
|
|
if (
|
|
offsetDiff === lengthDiff &&
|
|
e.old.text.substring( 0, e.old.range.start - nodeOffset - 1 ) ===
|
|
e['new'].text.substring( 0, e.old.range.start - nodeOffset - 1 )
|
|
) {
|
|
data = e['new'].text.substring(
|
|
e.old.range.start - nodeOffset - 1,
|
|
e['new'].range.start - nodeOffset - 1
|
|
).split( '' );
|
|
annotations = this.model.getDocument().getAnnotationsFromOffset( e.old.range.start - 1 );
|
|
|
|
if ( !ve.isEmptyObject( annotations ) ) {
|
|
ve.dm.Document.addAnnotationsToData( data, annotations );
|
|
}
|
|
|
|
this.render = false;
|
|
this.model.change(
|
|
ve.dm.Transaction.newFromInsertion( this.documentView.model, e.old.range.start, data ),
|
|
e['new'].range
|
|
);
|
|
this.render = true;
|
|
|
|
} else {
|
|
var fromLeft = 0,
|
|
fromRight = 0,
|
|
len = Math.min( e.old.text.length, e['new'].text.length );
|
|
|
|
while ( fromLeft < len && e.old.text[fromLeft] === e['new'].text[fromLeft] ) {
|
|
++fromLeft;
|
|
}
|
|
while (
|
|
fromRight < len - fromLeft &&
|
|
e.old.text[e.old.text.length - 1 - fromRight] ===
|
|
e['new'].text[e['new'].text.length - 1 - fromRight]
|
|
) {
|
|
++fromRight;
|
|
}
|
|
|
|
annotations = this.model.getDocument().getAnnotationsFromOffset( nodeOffset + 1 + fromLeft );
|
|
data = e['new'].text.substring( fromLeft, e['new'].text.length - fromRight ).split( '' );
|
|
|
|
if ( !ve.isEmptyObject( annotations ) ) {
|
|
ve.dm.Document.addAnnotationsToData( data, annotations );
|
|
}
|
|
|
|
this.clearPollData();
|
|
|
|
// TODO: combine newFromRemoval and newFromInsertion into one newFromReplacement
|
|
if ( fromLeft + fromRight < e.old.text.length ) {
|
|
this.model.change(
|
|
ve.dm.Transaction.newFromRemoval(
|
|
this.documentView.model,
|
|
new ve.Range( nodeOffset + 1 + fromLeft, nodeOffset + 1 + e.old.text.length - fromRight )
|
|
),
|
|
e['new'].range
|
|
);
|
|
}
|
|
this.model.change(
|
|
ve.dm.Transaction.newFromInsertion( this.documentView.model, nodeOffset + 1 + fromLeft, data ),
|
|
e['new'].range
|
|
);
|
|
}
|
|
this.sluggable = true;
|
|
};
|
|
|
|
ve.ce.Surface.prototype.onChange = function( transaction, selection ) {
|
|
ve.log( 'onChange' );
|
|
|
|
if ( selection ) {
|
|
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.proxy( function() {
|
|
if ( this.contextView ) {
|
|
if ( selection.getLength() > 0 ) {
|
|
this.contextView.set();
|
|
} else {
|
|
this.contextView.clear();
|
|
}
|
|
}
|
|
}, this ),
|
|
250
|
|
);
|
|
}
|
|
};
|
|
|
|
ve.ce.Surface.prototype.handleEnter = function( e ) {
|
|
var selection = this.model.getSelection(),
|
|
documentModel = this.model.getDocument(),
|
|
emptyParagraph = [{ 'type': 'paragraph' }, { 'type': '/paragraph' }],
|
|
tx,
|
|
advanceCursor = true,
|
|
outerParent,
|
|
outerChildrenCount;
|
|
|
|
// 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
|
|
var node = this.documentView.getNodeFromOffset( selection.from ),
|
|
nodeModel = node.getModel(),
|
|
cursor = selection.from,
|
|
nodeOffset = nodeModel.getOffset(),
|
|
contentBranchModel = nodeModel.isContent() ? nodeModel.getParent() : nodeModel,
|
|
contentBranchModelRange = contentBranchModel.getRange();
|
|
|
|
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
|
|
var stack = [],
|
|
outermostNode = null;
|
|
|
|
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 (
|
|
outermostNode.type === 'listItem' && // this is a list item
|
|
outerParent.getChildren()[outerChildrenCount - 1] === outermostNode.getModel() && // this is the last list item
|
|
outermostNode.children.length === 1 && // there is one child
|
|
node.model.length === 0 // the child is empty
|
|
) {
|
|
// Enter was pressed in an empty list item.
|
|
var 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 );
|
|
}
|
|
}
|
|
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();
|
|
};
|
|
|
|
ve.ce.Surface.prototype.handleDelete = function( backspace ) {
|
|
this.stopPolling();
|
|
|
|
var selection = this.model.getSelection(),
|
|
sourceOffset,
|
|
targetOffset,
|
|
sourceSplitableNode,
|
|
targetSplitableNode,
|
|
tx,
|
|
cursorAt,
|
|
sourceNode,
|
|
targetNode,
|
|
sourceData,
|
|
nodeToDelete;
|
|
|
|
if ( selection.from === selection.to ) {
|
|
if ( backspace || selection.to === this.model.getDocument().data.length - 1) {
|
|
sourceOffset = selection.to;
|
|
targetOffset = this.getNearestCorrectOffset( sourceOffset - 1, -1 );
|
|
} else {
|
|
sourceOffset = this.model.getDocument().getRelativeContentOffset( selection.to, 1 );
|
|
targetOffset = selection.to;
|
|
}
|
|
|
|
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 );
|
|
}
|
|
|
|
cursorAt = targetOffset;
|
|
|
|
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 and 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;
|
|
}
|
|
} );
|
|
// Remove source node or source node ancestor
|
|
this.model.change( ve.dm.Transaction.newFromRemoval(
|
|
this.documentView.model, nodeToDelete.getModel().getOuterRange()
|
|
) );
|
|
// Append source data to target
|
|
this.model.change(
|
|
ve.dm.Transaction.newFromInsertion(
|
|
this.documentView.model, targetOffset, sourceData
|
|
),
|
|
new ve.Range( cursorAt )
|
|
);
|
|
}
|
|
} else {
|
|
// Selection removal
|
|
this.model.change(
|
|
ve.dm.Transaction.newFromRemoval( this.documentView.model, selection ),
|
|
new ve.Range( selection.start )
|
|
);
|
|
}
|
|
|
|
this.clearPollData();
|
|
this.startPolling();
|
|
};
|
|
|
|
ve.ce.Surface.prototype.showCursor = function( offset ) {
|
|
this.showSelection( new ve.Range( offset ) );
|
|
};
|
|
|
|
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 );
|
|
}
|
|
};
|
|
|
|
// TODO: Find a better name and a better place for this method
|
|
ve.ce.Surface.prototype.getNearestCorrectOffset = function( offset, direction ) {
|
|
direction = direction > 0 ? 1 : -1;
|
|
|
|
if (
|
|
ve.dm.Document.isContentOffset( this.documentView.model.data, offset ) ||
|
|
this.hasSlugAtOffset( offset )
|
|
) {
|
|
return offset;
|
|
}
|
|
|
|
var 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 );
|
|
}
|
|
}
|
|
};
|
|
|
|
// TODO: Find a better name and a better place for this method - probably in a document view?
|
|
ve.ce.Surface.prototype.hasSlugAtOffset = function( offset ) {
|
|
var node = this.documentView.documentNode.getNodeFromOffset( offset );
|
|
if ( node && node.canHaveChildren() ) {
|
|
return node.hasSlugAtOffset( offset );
|
|
} else {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Based on a given offset returns DOM node and offset that can be used to place a cursor.
|
|
*
|
|
* The results of this function are meant to be used with rangy.
|
|
*
|
|
* @method
|
|
* @param offset {Integer} 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]++;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Gets the linear offset based on a given DOM node and DOM offset
|
|
*
|
|
* @method
|
|
* @param domNode {DOM Node} DOM node
|
|
* @param domOffset {Integer} DOM offset within the DOM Element
|
|
* @returns {Integer} Linear model offset
|
|
*/
|
|
ve.ce.Surface.prototype.getOffset = function( domNode, domOffset ) {
|
|
if ( domNode.nodeType === Node.TEXT_NODE ) {
|
|
return this.getOffsetFromTextNode( domNode, domOffset );
|
|
} else {
|
|
return this.getOffsetFromElementNode( domNode, domOffset );
|
|
}
|
|
};
|
|
|
|
ve.ce.Surface.prototype.getOffsetFromTextNode = function( domNode, domOffset ) {
|
|
var $node, nodeModel, current, stack, item, offset, $item;
|
|
|
|
$node = $( domNode ).closest(
|
|
'.ve-ce-branchNode, .ve-ce-alienBlockNode, .ve-ce-alienInlineNode'
|
|
);
|
|
nodeModel = $node.data( 'node' ).getModel();
|
|
|
|
if ( ! $node.hasClass( 've-ce-branchNode' ) ) {
|
|
return nodeModel.getOffset();
|
|
}
|
|
|
|
current = [$node.contents(), 0];
|
|
stack = [current];
|
|
offset = 0;
|
|
|
|
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 ) {
|
|
if ( item === domNode ) {
|
|
offset += domOffset;
|
|
break;
|
|
} else {
|
|
offset += item.textContent.length;
|
|
}
|
|
} else if ( item.nodeType === Node.ELEMENT_NODE ) {
|
|
$item = current[0].eq( current[1] );
|
|
if ( $item.hasClass( 've-ce-slug' ) ) {
|
|
if ( $item.contents()[0] === domNode ) {
|
|
break;
|
|
}
|
|
} else if ( $item.hasClass( 've-ce-leafNode' ) ) {
|
|
offset += 2;
|
|
} else if ( $item.hasClass( 've-ce-branchNode' ) ) {
|
|
offset += $item.data( 'node' ).getOuterLength();
|
|
} else {
|
|
stack.push( [$item.contents(), 0 ] );
|
|
current[1]++;
|
|
current = stack[stack.length-1];
|
|
continue;
|
|
}
|
|
}
|
|
current[1]++;
|
|
}
|
|
return offset + nodeModel.getOffset() + ( nodeModel.isWrapped() ? 1 : 0 );
|
|
};
|
|
|
|
ve.ce.Surface.prototype.getOffsetFromElementNode = function( domNode, domOffset, addOuterLength ) {
|
|
var $domNode = $( domNode ),
|
|
nodeModel,
|
|
node;
|
|
|
|
if ( $domNode.hasClass( 've-ce-slug' ) ) {
|
|
if ( $domNode.prev().length > 0 ) {
|
|
nodeModel = $domNode.prev().data( 'node' ).getModel();
|
|
return nodeModel.getOffset() + nodeModel.getOuterLength();
|
|
}
|
|
if ( $domNode.next().length > 0 ) {
|
|
nodeModel = $domNode.next().data( 'node' ).getModel();
|
|
return nodeModel.getOffset();
|
|
}
|
|
}
|
|
|
|
if ( domOffset === 0 ) {
|
|
node = $domNode.data( 'node' );
|
|
if ( node ) {
|
|
nodeModel = $domNode.data( 'node' ).getModel();
|
|
if ( addOuterLength === true ) {
|
|
return nodeModel.getOffset() + nodeModel.getOuterLength();
|
|
} else {
|
|
return nodeModel.getOffset();
|
|
}
|
|
} else {
|
|
node = $domNode.contents().last()[0];
|
|
}
|
|
} else {
|
|
node = $domNode.contents()[ domOffset - 1 ];
|
|
}
|
|
|
|
if ( node.nodeType === Node.TEXT_NODE ) {
|
|
return this.getOffsetFromTextNode( node, node.length );
|
|
} else {
|
|
return this.getOffsetFromElementNode( node, 0, true );
|
|
}
|
|
};
|
|
|
|
/* Supplies the selection anchor coordinates to contextView */
|
|
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. */
|
|
ve.ce.Surface.isShortcutKey = function( e ) {
|
|
if ( e.ctrlKey || e.metaKey ) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
ve.ce.Surface.prototype.getModel = function() {
|
|
return this.model;
|
|
};
|
|
|
|
ve.ce.Surface.prototype.getDocument = function() {
|
|
return this.documentView;
|
|
};
|
|
|
|
/* Inheritance */
|
|
|
|
ve.extendClass( ve.ce.Surface, ve.EventEmitter );
|