mirror of
https://github.com/fastmail/Squire.git
synced 2025-01-03 05:00:13 -05:00
b0ac7d32d0
Modern browsers tell you which character will be inserted with event.key, so we can make sure we handle content deletion ourselves in these cases too.
505 lines
18 KiB
JavaScript
505 lines
18 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 ( !range.collapsed && ( event.key || key ).length === 1 ) {
|
|
// Record undo checkpoint.
|
|
this.saveUndoState( range );
|
|
// Delete the selection
|
|
deleteContentsOfRange( range, this._root );
|
|
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 focused 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, self._root );
|
|
}
|
|
fixCursor( parent, self._root );
|
|
// 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
|
|
// root. Detach the <br>; the _ensureBottomLine call will insert a new
|
|
// block.
|
|
if ( node === self._root &&
|
|
( 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 root = self._root;
|
|
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, root, self );
|
|
self._removeZWS();
|
|
self._getRangeAndRemoveBookmark( range );
|
|
|
|
// Selected text is overwritten, therefore delete the contents
|
|
// to collapse selection.
|
|
if ( !range.collapsed ) {
|
|
deleteContentsOfRange( range, root );
|
|
}
|
|
|
|
block = getStartBlockOfRange( range, root );
|
|
|
|
// 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 ) ) {
|
|
// If inside an <a>, move focus out
|
|
parent = getNearest( range.endContainer, root, 'A' );
|
|
if ( parent ) {
|
|
parent = parent.parentNode;
|
|
moveRangeBoundariesUpTree( range, parent, parent, root );
|
|
range.collapse( false );
|
|
}
|
|
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, root, 'LI' ) ) {
|
|
block = parent;
|
|
}
|
|
|
|
if ( isEmptyBlock( block ) ) {
|
|
// Break list
|
|
if ( getNearest( block, root, 'UL' ) ||
|
|
getNearest( block, root, 'OL' ) ) {
|
|
return self.decreaseListLevel( range );
|
|
}
|
|
// Break blockquote
|
|
else if ( getNearest( block, root, '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, root );
|
|
|
|
// 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 );
|
|
},
|
|
backspace: function ( self, event, range ) {
|
|
var root = self._root;
|
|
self._removeZWS();
|
|
// Record undo checkpoint.
|
|
self.saveUndoState( range );
|
|
// If not collapsed, delete contents
|
|
if ( !range.collapsed ) {
|
|
event.preventDefault();
|
|
deleteContentsOfRange( range, root );
|
|
afterDelete( self, range );
|
|
}
|
|
// If at beginning of block, merge with previous
|
|
else if ( rangeDoesStartAtBlockBoundary( range, root ) ) {
|
|
event.preventDefault();
|
|
var current = getStartBlockOfRange( range, root );
|
|
var previous;
|
|
if ( !current ) {
|
|
return;
|
|
}
|
|
// In case inline data has somehow got between blocks.
|
|
fixContainer( current.parentNode, root );
|
|
// Now get previous block
|
|
previous = getPreviousBlock( current, root );
|
|
// 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, root );
|
|
// If deleted line between containers, merge newly adjacent
|
|
// containers.
|
|
current = previous.parentNode;
|
|
while ( current !== root && !current.nextSibling ) {
|
|
current = current.parentNode;
|
|
}
|
|
if ( current !== root && ( current = current.nextSibling ) ) {
|
|
mergeContainers( current, root );
|
|
}
|
|
self.setSelection( range );
|
|
}
|
|
// If at very beginning of text area, allow backspace
|
|
// to break lists/blockquote.
|
|
else if ( current ) {
|
|
// Break list
|
|
if ( getNearest( current, root, 'UL' ) ||
|
|
getNearest( current, root, 'OL' ) ) {
|
|
return self.decreaseListLevel( range );
|
|
}
|
|
// Break blockquote
|
|
else if ( getNearest( current, root, '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 ) {
|
|
var root = self._root;
|
|
var current, next, originalRange,
|
|
cursorContainer, cursorOffset, nodeAfterCursor;
|
|
self._removeZWS();
|
|
// Record undo checkpoint.
|
|
self.saveUndoState( range );
|
|
// If not collapsed, delete contents
|
|
if ( !range.collapsed ) {
|
|
event.preventDefault();
|
|
deleteContentsOfRange( range, root );
|
|
afterDelete( self, range );
|
|
}
|
|
// If at end of block, merge next into this block
|
|
else if ( rangeDoesEndAtBlockBoundary( range, root ) ) {
|
|
event.preventDefault();
|
|
current = getStartBlockOfRange( range, root );
|
|
if ( !current ) {
|
|
return;
|
|
}
|
|
// In case inline data has somehow got between blocks.
|
|
fixContainer( current.parentNode, root );
|
|
// Now get next block
|
|
next = getNextBlock( current, root );
|
|
// 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, root );
|
|
// If deleted line between containers, merge newly adjacent
|
|
// containers.
|
|
next = current.parentNode;
|
|
while ( next !== root && !next.nextSibling ) {
|
|
next = next.parentNode;
|
|
}
|
|
if ( next !== root && ( next = next.nextSibling ) ) {
|
|
mergeContainers( next, root );
|
|
}
|
|
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.
|
|
originalRange = range.cloneRange();
|
|
moveRangeBoundariesUpTree( range, root, root, root );
|
|
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 root = self._root;
|
|
var node, parent;
|
|
self._removeZWS();
|
|
// If no selection and at start of block
|
|
if ( range.collapsed && rangeDoesStartAtBlockBoundary( range, root ) ) {
|
|
node = getStartBlockOfRange( range, root );
|
|
// 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' ) {
|
|
// Then increase the list level
|
|
event.preventDefault();
|
|
self.increaseListLevel( range );
|
|
break;
|
|
}
|
|
node = parent;
|
|
}
|
|
}
|
|
},
|
|
'shift-tab': function ( self, event, range ) {
|
|
var root = self._root;
|
|
var node;
|
|
self._removeZWS();
|
|
// If no selection and at start of block
|
|
if ( range.collapsed && rangeDoesStartAtBlockBoundary( range, root ) ) {
|
|
// Break list
|
|
node = range.startContainer;
|
|
if ( getNearest( node, root, 'UL' ) ||
|
|
getNearest( node, root, 'OL' ) ) {
|
|
event.preventDefault();
|
|
self.decreaseListLevel( range );
|
|
}
|
|
}
|
|
},
|
|
space: function ( self, _, range ) {
|
|
var node, parent;
|
|
self._recordUndoState( range );
|
|
addLinks( range.startContainer, self._root, self );
|
|
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 && range.endOffset === getLength( node ) ) {
|
|
if ( node.nodeName === 'A' ) {
|
|
range.setStartAfter( node );
|
|
} else if ( parent.nodeName === 'A' && !node.nextSibling ) {
|
|
range.setStartAfter( parent );
|
|
}
|
|
}
|
|
// Delete the selection if not collapsed
|
|
if ( !range.collapsed ) {
|
|
deleteContentsOfRange( range, self._root );
|
|
self._ensureBottomLine();
|
|
self.setSelection( range );
|
|
self._updatePath( range, true );
|
|
}
|
|
|
|
self.setSelection( range );
|
|
},
|
|
left: function ( self ) {
|
|
self._removeZWS();
|
|
},
|
|
right: function ( self ) {
|
|
self._removeZWS();
|
|
}
|
|
};
|
|
|
|
// Firefox pre v29 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 ) {
|
|
keyHandlers[ 'meta-left' ] = function ( self, event ) {
|
|
event.preventDefault();
|
|
var sel = getWindowSelection( self );
|
|
if ( sel && sel.modify ) {
|
|
sel.modify( 'move', 'backward', 'lineboundary' );
|
|
}
|
|
};
|
|
keyHandlers[ 'meta-right' ] = function ( self, event ) {
|
|
event.preventDefault();
|
|
var sel = getWindowSelection( self );
|
|
if ( sel && sel.modify ) {
|
|
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' );
|