mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-01 05:50:35 +03:00
e21bde39e1
refs https://github.com/TryGhost/Team/issues/1239 - updates keyboard shortcut behavior to match the quote icon behavior where it cycles through blockquote->aside->p
503 lines
16 KiB
JavaScript
503 lines
16 KiB
JavaScript
import Browser from 'mobiledoc-kit/utils/browser';
|
|
import {
|
|
CURSOR_AFTER,
|
|
CURSOR_BEFORE
|
|
} from '../components/koenig-editor';
|
|
|
|
// Key commands will run any time a particular key or key combination is pressed
|
|
// https://github.com/bustlelabs/mobiledoc-kit#configuring-hot-keys
|
|
|
|
const setHeader = function setHeader(headerTag, editor, koenig) {
|
|
if (!editor.activeSection.isMarkerable) {
|
|
return;
|
|
}
|
|
|
|
let range = editor.range;
|
|
let canKeepRange = !editor.activeSection.isListItem;
|
|
|
|
editor.run((postEditor) => {
|
|
koenig.send('toggleHeaderSection', headerTag, postEditor, {force: true});
|
|
if (canKeepRange) {
|
|
postEditor.setRange(range);
|
|
}
|
|
});
|
|
};
|
|
|
|
// cycle through H2-H6. Starts with H2 so that heirarchy with the story title is
|
|
// kept. H1 should be explicitly selected with CTRL+ALT+1 or using `#` text expansion
|
|
const cycleHeaderLevel = function cycleHeaderLevel(editor, koenig) {
|
|
if (!editor.activeSection.isMarkerable) {
|
|
return;
|
|
}
|
|
|
|
let headerMatch = editor.activeSection.tagName.match(/^h(\d)$/i);
|
|
let headerTag = 'h2';
|
|
|
|
if (headerMatch) {
|
|
let newLevel = parseInt(headerMatch[1]) + 1;
|
|
|
|
if (newLevel > 6) {
|
|
// remove header tag before starting the cycle again
|
|
headerTag = headerMatch[0];
|
|
} else {
|
|
headerTag = `h${newLevel}`;
|
|
}
|
|
}
|
|
|
|
setHeader(headerTag, editor, koenig);
|
|
};
|
|
|
|
const cycleQuoteStyle = function cycleQuoteStyle(editor, koenig) {
|
|
if (!editor.activeSection.isMarkerable) {
|
|
return;
|
|
}
|
|
|
|
const range = editor.range;
|
|
const canKeepRange = !editor.activeSection.isListItem;
|
|
|
|
let sectionName = 'blockquote';
|
|
|
|
if (editor.activeSection.tagName === 'blockquote') {
|
|
sectionName = 'aside';
|
|
}
|
|
if (editor.activeSection.tagName === 'aside') {
|
|
sectionName = 'p';
|
|
}
|
|
|
|
editor.run((postEditor) => {
|
|
koenig.send('toggleSection', sectionName, postEditor);
|
|
|
|
if (canKeepRange) {
|
|
postEditor.setRange(range);
|
|
}
|
|
});
|
|
};
|
|
|
|
export const DEFAULT_KEY_COMMANDS = [{
|
|
str: 'ENTER',
|
|
run(editor, koenig) {
|
|
let {isCollapsed, head: {offset, section}} = editor.range;
|
|
|
|
// if cursor is at beginning of a heading, insert a blank paragraph above
|
|
if (isCollapsed && offset === 0 && section.tagName && section.tagName.match(/^h\d$/)) {
|
|
editor.run((postEditor) => {
|
|
let newPara = postEditor.builder.createMarkupSection('p');
|
|
let collection = section.parent.sections;
|
|
postEditor.insertSectionBefore(collection, newPara, section);
|
|
});
|
|
koenig._scrollCursorIntoView();
|
|
return;
|
|
}
|
|
|
|
// if cursor is on a blank list item mid-list then split the list
|
|
if (isCollapsed && section.isBlank && section.isListItem && section.next && section.next.isListItem) {
|
|
editor.run((postEditor) => {
|
|
postEditor.toggleSection('p', section.headPosition());
|
|
});
|
|
koenig._scrollCursorIntoView();
|
|
return;
|
|
}
|
|
|
|
// if a card is selected, move the cursor to the end of the card so that
|
|
// Enter consistently adds a new paragraph after the card
|
|
if (isCollapsed && section.isCardSection) {
|
|
koenig.moveCaretToTailOfSection(section, true);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
// CMD+ENTER is our keyboard shortcut for putting a selected card into edit mode
|
|
str: 'META+ENTER',
|
|
run(editor, koenig) {
|
|
if (koenig.selectedCard) {
|
|
koenig.editCard(koenig.selectedCard);
|
|
return;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
// CTRL+ENTER is our keyboard shortcut for putting a selected card into edit mode
|
|
str: 'CTRL+ENTER',
|
|
run(editor, koenig) {
|
|
if (Browser.isWin() && koenig.selectedCard) {
|
|
koenig.editCard(koenig.selectedCard);
|
|
return;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
str: 'SHIFT+ENTER',
|
|
run(editor) {
|
|
if (!editor.range.headSection.isMarkerable) {
|
|
return;
|
|
}
|
|
|
|
editor.run((postEditor) => {
|
|
let softReturn = postEditor.builder.createAtom('soft-return');
|
|
postEditor.insertMarkers(editor.range.head, [softReturn]);
|
|
});
|
|
}
|
|
}, {
|
|
str: 'BACKSPACE',
|
|
run(editor, koenig) {
|
|
let {head, isCollapsed, head: {marker, offset, section}} = editor.range;
|
|
let {next, prev} = section;
|
|
|
|
// if a card is selected we should delete the card then place the cursor
|
|
// at the end of the previous section
|
|
if (koenig.selectedCard) {
|
|
let cursorPosition = section.prev ? CURSOR_BEFORE : CURSOR_AFTER;
|
|
koenig.deleteCard(koenig.selectedCard, cursorPosition);
|
|
return;
|
|
}
|
|
|
|
// if the cursor is at the beginning of the doc and on a blank paragraph,
|
|
// then delete or re-create the paragraph to remove formatting
|
|
let sections = section.isListItem ? section.parent.parent.sections : section.parent.sections;
|
|
let isFirstSection = section === sections.head;
|
|
if (isFirstSection && isCollapsed && offset === 0 && (section.isBlank || section.text === '')) {
|
|
editor.run((postEditor) => {
|
|
// remove the current section
|
|
postEditor.removeSection(section);
|
|
|
|
// select the next section
|
|
if (next) {
|
|
return;
|
|
}
|
|
|
|
// we don't have another section so create a blank paragraph
|
|
// which will have the effect of removing any formatting
|
|
let {builder} = postEditor;
|
|
let p = builder.createMarkupSection('p');
|
|
postEditor.insertSectionAtEnd(p);
|
|
postEditor.setRange(p.tailPosition());
|
|
});
|
|
|
|
// allow default behaviour which will trigger `cursorDidChange` and
|
|
// fire our `cursorDidExitAtTop` action
|
|
return;
|
|
}
|
|
|
|
// if the section about to be deleted by a backspace is a card then
|
|
// actually delete the card rather than selecting it.
|
|
// However, if the current paragraph is blank then delete the paragraph
|
|
// instead - allows blank paragraphs between cards to be deleted and
|
|
// feels more natural
|
|
if (isCollapsed && offset === 0 && prev && prev.type === 'card-section' && !section.isBlank) {
|
|
let card = koenig.getCardFromSection(prev);
|
|
koenig.deleteCard(card);
|
|
return;
|
|
}
|
|
|
|
// if cursor is at the beginning of a heading and previous section is a
|
|
// blank paragraph, delete the blank paragraph
|
|
if (isCollapsed && offset === 0 && section.tagName.match(/^h\d$/) && prev && prev.tagName === 'p' && prev.isBlank) {
|
|
editor.run((postEditor) => {
|
|
postEditor.removeSection(prev);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// if the markup about to be deleted is a special format (code, strike)
|
|
// then undo the text expansion to allow it to be extended
|
|
if (isCollapsed && marker && offset !== 0) {
|
|
let specialMarkupTagNames = Object.keys(koenig.SPECIAL_MARKUPS);
|
|
let hasReversed = false;
|
|
specialMarkupTagNames.forEach((tagName) => {
|
|
// only continue if we're about to delete a special markup
|
|
let markup = marker.markups.find(markerMarkup => markerMarkup.tagName.toUpperCase() === tagName);
|
|
if (markup) {
|
|
let nextMarker = head.markerIn(1);
|
|
// ensure we're at the end of the markup not inside it
|
|
if (!nextMarker || !nextMarker.hasMarkup(tagName)) {
|
|
// wrap with the text expansion, remove formatting, then delete the last char
|
|
editor.run((postEditor) => {
|
|
let markdown = koenig.SPECIAL_MARKUPS[tagName];
|
|
let replace = true;
|
|
|
|
if (typeof markdown === 'object') {
|
|
replace = markdown.replace;
|
|
markdown = markdown.char;
|
|
}
|
|
|
|
let range = editor.range.expandByMarker(markerToExpand => !!markerToExpand.markups.includes(markup));
|
|
|
|
// replaced markdown (default) will have chars removed when formatted
|
|
// and added back when the format is removed by backspace
|
|
if (replace) {
|
|
postEditor.insertText(range.head, markdown);
|
|
range = range.extend(markdown.length);
|
|
let endPos = postEditor.insertText(range.tail, markdown);
|
|
range = range.extend(markdown.length);
|
|
postEditor.toggleMarkup(tagName, range);
|
|
endPos = postEditor.deleteAtPosition(endPos, -1);
|
|
postEditor.setRange(endPos);
|
|
} else {
|
|
postEditor.toggleMarkup(tagName, range);
|
|
let endPos = postEditor.deleteAtPosition(range.tail);
|
|
postEditor.setRange(endPos);
|
|
}
|
|
});
|
|
hasReversed = true;
|
|
}
|
|
}
|
|
});
|
|
if (hasReversed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
str: 'DEL',
|
|
run(editor, koenig) {
|
|
let {isCollapsed, head: {offset, section}} = editor.range;
|
|
|
|
// if a card is selected we should delete the card then place the cursor
|
|
// at the beginning of the next section or select the following card
|
|
if (koenig.selectedCard) {
|
|
let selectNextCard = section.next?.type === 'card-section';
|
|
let nextCard = koenig.getCardFromSection(section.next);
|
|
|
|
koenig.deleteCard(koenig.selectedCard);
|
|
|
|
if (selectNextCard) {
|
|
koenig.selectCard(nextCard);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// if the section about to be deleted by a DEL is a card then actually
|
|
// delete the card rather than selecting it
|
|
// However, if the current paragraph is blank then delete the paragraph
|
|
// instead - allows blank paragraphs between cards to be deleted and
|
|
// feels more natural
|
|
if (isCollapsed && offset === section.length && section.next && section.next.type === 'card-section' && !section.isBlank) {
|
|
let card = koenig.getCardFromSection(section.next);
|
|
koenig.deleteCard(card, CURSOR_BEFORE);
|
|
return;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
// trigger a closure action to indicate that the caret "left" the top of
|
|
// the editor canvas when pressing UP with the caret at the beginning of
|
|
// the doc
|
|
str: 'UP',
|
|
run(editor, koenig) {
|
|
let {isCollapsed, head: {offset, section}} = editor.range;
|
|
let prevSection = section.isListItem && !section.prev ? section.parent.prev : section.prev;
|
|
|
|
if (isCollapsed && (offset === 0 || section.isCardSection) && !prevSection) {
|
|
koenig.send('exitCursorAtTop');
|
|
return false;
|
|
}
|
|
|
|
// Firefox has a bug with cursor movement where it will stop the cursor
|
|
// from moving upwards when a card is selected. Default behaviour in
|
|
// all other browsers is to place the cursor at the end of the previous
|
|
// section so we replicate that here
|
|
if (section.isCardSection && prevSection) {
|
|
koenig.moveCaretToTailOfSection(prevSection, false);
|
|
return;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
str: 'LEFT',
|
|
run(editor, koenig) {
|
|
let {isCollapsed, head: {offset, section}} = editor.range;
|
|
|
|
// trigger a closure action to indicate that the caret "left" the top of
|
|
// the editor canvas if the caret is at the very beginning of the doc
|
|
let prevSection = section.isListItem && !section.prev ? section.parent.prev : section.prev;
|
|
if (isCollapsed && (offset === 0 || section.isCardSection) && !prevSection) {
|
|
koenig.send('exitCursorAtTop');
|
|
return;
|
|
}
|
|
|
|
// if we have a selected card move the caret to end of the previous
|
|
// section because the cursor will likely be at the end of the card
|
|
// section meaning the default behaviour would move the cursor to the
|
|
// beginning and require two key presses instead of one
|
|
if (koenig.selectedCard && koenig.selectedCard.postModel === section) {
|
|
koenig.moveCaretToTailOfSection(section.prev, false);
|
|
return;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
str: 'CTRL+ALT+1',
|
|
run(editor, koenig) {
|
|
return setHeader('h1', editor, koenig);
|
|
}
|
|
}, {
|
|
str: 'CTRL+ALT+2',
|
|
run(editor, koenig) {
|
|
return setHeader('h2', editor, koenig);
|
|
}
|
|
}, {
|
|
str: 'CTRL+ALT+3',
|
|
run(editor, koenig) {
|
|
return setHeader('h3', editor, koenig);
|
|
}
|
|
}, {
|
|
str: 'CTRL+ALT+4',
|
|
run(editor, koenig) {
|
|
return setHeader('h4', editor, koenig);
|
|
}
|
|
}, {
|
|
str: 'CTRL+ALT+5',
|
|
run(editor, koenig) {
|
|
return setHeader('h5', editor, koenig);
|
|
}
|
|
}, {
|
|
str: 'CTRL+ALT+6',
|
|
run(editor, koenig) {
|
|
return setHeader('h6', editor, koenig);
|
|
}
|
|
}, {
|
|
str: 'CTRL+H',
|
|
run(editor, koenig) {
|
|
if (!Browser.isMac()) {
|
|
return cycleHeaderLevel(editor, koenig);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
str: 'META+H',
|
|
run(editor, koenig) {
|
|
if (Browser.isMac()) {
|
|
return cycleHeaderLevel(editor, koenig);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
str: 'CTRL+Q',
|
|
run(editor, koenig) {
|
|
return cycleQuoteStyle(editor, koenig);
|
|
}
|
|
}, {
|
|
str: 'CTRL+L',
|
|
run(editor, koenig) {
|
|
if (!editor.activeSection.isMarkerable) {
|
|
return;
|
|
}
|
|
|
|
koenig.send('toggleSection', 'ul');
|
|
}
|
|
}, {
|
|
str: 'CTRL+K',
|
|
run(editor, koenig) {
|
|
if (Browser.isWin()) {
|
|
return koenig.send('editLink', editor.range);
|
|
}
|
|
|
|
// default behaviour for Mac is delete to end of section
|
|
return false;
|
|
}
|
|
}, {
|
|
str: 'META+K',
|
|
run(editor, koenig) {
|
|
return koenig.send('editLink', editor.range);
|
|
}
|
|
}, {
|
|
str: 'CTRL+ALT+U',
|
|
run(editor, koenig) {
|
|
return koenig.send('toggleMarkup', 's');
|
|
}
|
|
}, {
|
|
str: 'CTRL+SHIFT+K',
|
|
run(editor, koenig) {
|
|
if (!Browser.isMac()) {
|
|
return koenig.send('toggleMarkup', 'code');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
str: 'META+SHIFT+K',
|
|
run(editor, koenig) {
|
|
if (Browser.isMac()) {
|
|
return koenig.send('toggleMarkup', 'code');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}];
|
|
|
|
// key commands that are used in koenig-basic-html-input
|
|
export const BASIC_KEY_COMMANDS = DEFAULT_KEY_COMMANDS.filter((command) => {
|
|
let basicCommands = [
|
|
'BACKSPACE',
|
|
'CTRL+K',
|
|
'META+K',
|
|
'CTRL+ALT+U',
|
|
'CTRL+SHIFT+K',
|
|
'META+SHIFT+K'
|
|
];
|
|
return basicCommands.includes(command.str);
|
|
});
|
|
|
|
export const BASIC_KEY_COMMANDS_WITH_BR = DEFAULT_KEY_COMMANDS.filter((command) => {
|
|
let basicCommands = [
|
|
'BACKSPACE',
|
|
'CTRL+K',
|
|
'META+K',
|
|
'CTRL+ALT+U',
|
|
'CTRL+SHIFT+K',
|
|
'META+SHIFT+K',
|
|
'SHIFT+ENTER'
|
|
];
|
|
return basicCommands.includes(command.str);
|
|
});
|
|
|
|
// key commands that are used in koenig-basic-html-textarea
|
|
export const BASIC_TEXTAREA_KEY_COMMANDS = DEFAULT_KEY_COMMANDS.filter((command) => {
|
|
let basicCommands = [
|
|
'BACKSPACE',
|
|
'CTRL+K',
|
|
'META+K',
|
|
'CTRL+ALT+U',
|
|
'CTRL+SHIFT+K',
|
|
'META+SHIFT+K',
|
|
'ENTER',
|
|
'SHIFT+ENTER'
|
|
];
|
|
return basicCommands.includes(command.str);
|
|
});
|
|
|
|
// key commands that are used in koenig-text-replacement-html-input
|
|
// (code formatting is not allowed here because it's used to highlight replacement strings)
|
|
export const TEXT_REPLACEMENT_KEY_COMMANDS = DEFAULT_KEY_COMMANDS.filter((command) => {
|
|
let commands = [
|
|
'BACKSPACE',
|
|
'CTRL+K',
|
|
'META+K',
|
|
'CTRL+ALT+U',
|
|
'ENTER',
|
|
'SHIFT+ENTER'
|
|
];
|
|
return commands.includes(command.str);
|
|
});
|
|
|
|
export default function registerKeyCommands(editor, koenig, commands = DEFAULT_KEY_COMMANDS) {
|
|
commands.forEach((keyCommand) => {
|
|
editor.registerKeyCommand({
|
|
str: keyCommand.str,
|
|
run() {
|
|
return keyCommand.run(editor, koenig);
|
|
}
|
|
});
|
|
});
|
|
}
|