0
Fork 0
mirror of https://github.com/fastmail/Squire.git synced 2024-12-22 15:23:29 -05:00
Squire/source/KeyHandlers.js
2015-09-04 18:33:11 +01:00

468 lines
17 KiB
JavaScript

/*jshint strict:false, undef:false, unused:false */
var keys = {
8: 'backspace',
9: 'tab',
13: 'enter',
32: 'space',
33: 'pageup',
34: 'pagedown',
37: 'left',
39: 'right',
46: 'delete',
219: '[',
221: ']'
};
// Ref: http://unixpapa.com/js/key.html
var onKey = function ( event ) {
var code = event.keyCode,
key = keys[ code ],
modifiers = '',
range = this.getSelection();
if ( event.defaultPrevented ) {
return;
}
if ( !key ) {
key = String.fromCharCode( code ).toLowerCase();
// Only reliable for letters and numbers
if ( !/^[A-Za-z0-9]$/.test( key ) ) {
key = '';
}
}
// On keypress, delete and '.' both have event.keyCode 46
// Must check event.which to differentiate.
if ( isPresto && event.which === 46 ) {
key = '.';
}
// Function keys
if ( 111 < code && code < 124 ) {
key = 'f' + ( code - 111 );
}
// We need to apply the backspace/delete handlers regardless of
// control key modifiers.
if ( key !== 'backspace' && key !== 'delete' ) {
if ( event.altKey ) { modifiers += 'alt-'; }
if ( event.ctrlKey ) { modifiers += 'ctrl-'; }
if ( event.metaKey ) { modifiers += 'meta-'; }
}
// However, on Windows, shift-delete is apparently "cut" (WTF right?), so
// we want to let the browser handle shift-delete.
if ( event.shiftKey ) { modifiers += 'shift-'; }
key = modifiers + key;
if ( this._keyHandlers[ key ] ) {
this._keyHandlers[ key ]( this, event, range );
} else if ( key.length === 1 && !range.collapsed ) {
// Record undo checkpoint.
this._recordUndoState( range );
this._getRangeAndRemoveBookmark( range );
// Delete the selection
deleteContentsOfRange( range );
this._ensureBottomLine();
this.setSelection( range );
this._updatePath( range, true );
}
};
var mapKeyTo = function ( method ) {
return function ( self, event ) {
event.preventDefault();
self[ method ]();
};
};
var mapKeyToFormat = function ( tag, remove ) {
remove = remove || null;
return function ( self, event ) {
event.preventDefault();
var range = self.getSelection();
if ( self.hasFormat( tag, null, range ) ) {
self.changeFormat( null, { tag: tag }, range );
} else {
self.changeFormat( { tag: tag }, remove, range );
}
};
};
// If you delete the content inside a span with a font styling, Webkit will
// replace it with a <font> tag (!). If you delete all the text inside a
// link in Opera, it won't delete the link. Let's make things consistent. If
// you delete all text inside an inline tag, remove the inline tag.
var afterDelete = function ( self, range ) {
try {
if ( !range ) { range = self.getSelection(); }
var node = range.startContainer,
parent;
// Climb the tree from the focus point while we are inside an empty
// inline element
if ( node.nodeType === TEXT_NODE ) {
node = node.parentNode;
}
parent = node;
while ( isInline( parent ) &&
( !parent.textContent || parent.textContent === ZWS ) ) {
node = parent;
parent = node.parentNode;
}
// If focussed in empty inline element
if ( node !== parent ) {
// Move focus to just before empty inline(s)
range.setStart( parent,
indexOf.call( parent.childNodes, node ) );
range.collapse( true );
// Remove empty inline(s)
parent.removeChild( node );
// Fix cursor in block
if ( !isBlock( parent ) ) {
parent = getPreviousBlock( parent );
}
fixCursor( parent );
// Move cursor into text node
moveRangeBoundariesDownTree( range );
}
// If you delete the last character in the sole <div> in Chrome,
// it removes the div and replaces it with just a <br> inside the
// body. Detach the <br>; the _ensureBottomLine call will insert a new
// block.
if ( node.nodeName === 'BODY' &&
( node = node.firstChild ) && node.nodeName === 'BR' ) {
detach( node );
}
self._ensureBottomLine();
self.setSelection( range );
self._updatePath( range, true );
} catch ( error ) {
self.didError( error );
}
};
var keyHandlers = {
enter: function ( self, event, range ) {
var block, parent, nodeAfterSplit;
// We handle this ourselves
event.preventDefault();
// Save undo checkpoint and add any links in the preceding section.
// Remove any zws so we don't think there's content in an empty
// block.
self._recordUndoState( range );
addLinks( range.startContainer );
self._removeZWS();
self._getRangeAndRemoveBookmark( range );
// Selected text is overwritten, therefore delete the contents
// to collapse selection.
if ( !range.collapsed ) {
deleteContentsOfRange( range );
}
block = getStartBlockOfRange( range );
// If this is a malformed bit of document or in a table;
// just play it safe and insert a <br>.
if ( !block || /^T[HD]$/.test( block.nodeName ) ) {
insertNodeInRange( range, self.createElement( 'BR' ) );
range.collapse( false );
self.setSelection( range );
self._updatePath( range, true );
return;
}
// If in a list, we'll split the LI instead.
if ( parent = getNearest( block, 'LI' ) ) {
block = parent;
}
if ( !block.textContent ) {
// Break list
if ( getNearest( block, 'UL' ) || getNearest( block, 'OL' ) ) {
return self.modifyBlocks( decreaseListLevel, range );
}
// Break blockquote
else if ( getNearest( block, 'BLOCKQUOTE' ) ) {
return self.modifyBlocks( removeBlockQuote, range );
}
}
// Otherwise, split at cursor point.
nodeAfterSplit = splitBlock( self, block,
range.startContainer, range.startOffset );
// Clean up any empty inlines if we hit enter at the beginning of the
// block
removeZWS( block );
removeEmptyInlines( block );
fixCursor( block );
// Focus cursor
// If there's a <b>/<i> etc. at the beginning of the split
// make sure we focus inside it.
while ( nodeAfterSplit.nodeType === ELEMENT_NODE ) {
var child = nodeAfterSplit.firstChild,
next;
// Don't continue links over a block break; unlikely to be the
// desired outcome.
if ( nodeAfterSplit.nodeName === 'A' &&
( !nodeAfterSplit.textContent ||
nodeAfterSplit.textContent === ZWS ) ) {
child = self._doc.createTextNode( '' );
replaceWith( nodeAfterSplit, child );
nodeAfterSplit = child;
break;
}
while ( child && child.nodeType === TEXT_NODE && !child.data ) {
next = child.nextSibling;
if ( !next || next.nodeName === 'BR' ) {
break;
}
detach( child );
child = next;
}
// 'BR's essentially don't count; they're a browser hack.
// If you try to select the contents of a 'BR', FF will not let
// you type anything!
if ( !child || child.nodeName === 'BR' ||
( child.nodeType === TEXT_NODE && !isPresto ) ) {
break;
}
nodeAfterSplit = child;
}
range = self._createRange( nodeAfterSplit, 0 );
self.setSelection( range );
self._updatePath( range, true );
// Scroll into view
if ( nodeAfterSplit.nodeType === TEXT_NODE ) {
nodeAfterSplit = nodeAfterSplit.parentNode;
}
var doc = self._doc,
body = self._body;
if ( nodeAfterSplit.offsetTop + nodeAfterSplit.offsetHeight >
( doc.documentElement.scrollTop || body.scrollTop ) +
body.offsetHeight ) {
nodeAfterSplit.scrollIntoView( false );
}
},
backspace: function ( self, event, range ) {
self._removeZWS();
// Record undo checkpoint.
self._recordUndoState( range );
self._getRangeAndRemoveBookmark( range );
// If not collapsed, delete contents
if ( !range.collapsed ) {
event.preventDefault();
deleteContentsOfRange( range );
afterDelete( self, range );
}
// If at beginning of block, merge with previous
else if ( rangeDoesStartAtBlockBoundary( range ) ) {
event.preventDefault();
var current = getStartBlockOfRange( range ),
previous = current && getPreviousBlock( current );
// Must not be at the very beginning of the text area.
if ( previous ) {
// If not editable, just delete whole block.
if ( !previous.isContentEditable ) {
detach( previous );
return;
}
// Otherwise merge.
mergeWithBlock( previous, current, range );
// If deleted line between containers, merge newly adjacent
// containers.
current = previous.parentNode;
while ( current && !current.nextSibling ) {
current = current.parentNode;
}
if ( current && ( current = current.nextSibling ) ) {
mergeContainers( current );
}
self.setSelection( range );
}
// If at very beginning of text area, allow backspace
// to break lists/blockquote.
else if ( current ) {
// Break list
if ( getNearest( current, 'UL' ) ||
getNearest( current, 'OL' ) ) {
return self.modifyBlocks( decreaseListLevel, range );
}
// Break blockquote
else if ( getNearest( current, 'BLOCKQUOTE' ) ) {
return self.modifyBlocks( decreaseBlockQuoteLevel, range );
}
self.setSelection( range );
self._updatePath( range, true );
}
}
// Otherwise, leave to browser but check afterwards whether it has
// left behind an empty inline tag.
else {
self.setSelection( range );
setTimeout( function () { afterDelete( self ); }, 0 );
}
},
'delete': function ( self, event, range ) {
self._removeZWS();
// Record undo checkpoint.
self._recordUndoState( range );
self._getRangeAndRemoveBookmark( range );
// If not collapsed, delete contents
if ( !range.collapsed ) {
event.preventDefault();
deleteContentsOfRange( range );
afterDelete( self, range );
}
// If at end of block, merge next into this block
else if ( rangeDoesEndAtBlockBoundary( range ) ) {
event.preventDefault();
var current = getStartBlockOfRange( range ),
next = current && getNextBlock( current );
// Must not be at the very end of the text area.
if ( next ) {
// If not editable, just delete whole block.
if ( !next.isContentEditable ) {
detach( next );
return;
}
// Otherwise merge.
mergeWithBlock( current, next, range );
// If deleted line between containers, merge newly adjacent
// containers.
next = current.parentNode;
while ( next && !next.nextSibling ) {
next = next.parentNode;
}
if ( next && ( next = next.nextSibling ) ) {
mergeContainers( next );
}
self.setSelection( range );
self._updatePath( range, true );
}
}
// Otherwise, leave to browser but check afterwards whether it has
// left behind an empty inline tag.
else {
// But first check if the cursor is just before an IMG tag. If so,
// delete it ourselves, because the browser won't if it is not
// inline.
var originalRange = range.cloneRange(),
cursorContainer, cursorOffset, nodeAfterCursor;
moveRangeBoundariesUpTree( range, self._body );
cursorContainer = range.endContainer;
cursorOffset = range.endOffset;
if ( cursorContainer.nodeType === ELEMENT_NODE ) {
nodeAfterCursor = cursorContainer.childNodes[ cursorOffset ];
if ( nodeAfterCursor && nodeAfterCursor.nodeName === 'IMG' ) {
event.preventDefault();
detach( nodeAfterCursor );
moveRangeBoundariesDownTree( range );
afterDelete( self, range );
return;
}
}
self.setSelection( originalRange );
setTimeout( function () { afterDelete( self ); }, 0 );
}
},
tab: function ( self, event, range ) {
var node, parent;
self._removeZWS();
// If no selection and in an empty block
if ( range.collapsed &&
rangeDoesStartAtBlockBoundary( range ) &&
rangeDoesEndAtBlockBoundary( range ) ) {
node = getStartBlockOfRange( range );
// Iterate through the block's parents
while ( parent = node.parentNode ) {
// If we find a UL or OL (so are in a list, node must be an LI)
if ( parent.nodeName === 'UL' || parent.nodeName === 'OL' ) {
// AND the LI is not the first in the list
if ( node.previousSibling ) {
// Then increase the list level
event.preventDefault();
self.modifyBlocks( increaseListLevel, range );
}
break;
}
node = parent;
}
}
},
space: function ( self, _, range ) {
var node, parent;
self._recordUndoState( range );
addLinks( range.startContainer );
self._getRangeAndRemoveBookmark( range );
// If the cursor is at the end of a link (<a>foo|</a>) then move it
// outside of the link (<a>foo</a>|) so that the space is not part of
// the link text.
node = range.endContainer;
parent = node.parentNode;
if ( range.collapsed && parent.nodeName === 'A' &&
!node.nextSibling && range.endOffset === getLength( node ) ) {
range.setStartAfter( parent );
}
self.setSelection( range );
},
left: function ( self ) {
self._removeZWS();
},
right: function ( self ) {
self._removeZWS();
}
};
// Firefox incorrectly handles Cmd-left/Cmd-right on Mac:
// it goes back/forward in history! Override to do the right
// thing.
// https://bugzilla.mozilla.org/show_bug.cgi?id=289384
if ( isMac && isGecko && win.getSelection().modify ) {
keyHandlers[ 'meta-left' ] = function ( self, event ) {
event.preventDefault();
self._sel.modify( 'move', 'backward', 'lineboundary' );
};
keyHandlers[ 'meta-right' ] = function ( self, event ) {
event.preventDefault();
self._sel.modify( 'move', 'forward', 'lineboundary' );
};
}
// System standard for page up/down on Mac is to just scroll, not move the
// cursor. On Linux/Windows, it should move the cursor, but some browsers don't
// implement this natively. Override to support it.
if ( !isMac ) {
keyHandlers.pageup = function ( self ) {
self.moveCursorToStart();
};
keyHandlers.pagedown = function ( self ) {
self.moveCursorToEnd();
};
}
keyHandlers[ ctrlKey + 'b' ] = mapKeyToFormat( 'B' );
keyHandlers[ ctrlKey + 'i' ] = mapKeyToFormat( 'I' );
keyHandlers[ ctrlKey + 'u' ] = mapKeyToFormat( 'U' );
keyHandlers[ ctrlKey + 'shift-7' ] = mapKeyToFormat( 'S' );
keyHandlers[ ctrlKey + 'shift-5' ] = mapKeyToFormat( 'SUB', { tag: 'SUP' } );
keyHandlers[ ctrlKey + 'shift-6' ] = mapKeyToFormat( 'SUP', { tag: 'SUB' } );
keyHandlers[ ctrlKey + 'shift-8' ] = mapKeyTo( 'makeUnorderedList' );
keyHandlers[ ctrlKey + 'shift-9' ] = mapKeyTo( 'makeOrderedList' );
keyHandlers[ ctrlKey + '[' ] = mapKeyTo( 'decreaseQuoteLevel' );
keyHandlers[ ctrlKey + ']' ] = mapKeyTo( 'increaseQuoteLevel' );
keyHandlers[ ctrlKey + 'y' ] = mapKeyTo( 'redo' );
keyHandlers[ ctrlKey + 'z' ] = mapKeyTo( 'undo' );
keyHandlers[ ctrlKey + 'shift-z' ] = mapKeyTo( 'redo' );