feat: add Ctrl+K for a link, Ctrl+S to save the draft and Ctrl+Shift+C or B for Cc and Bcc in the composer

This commit is contained in:
Linus Rath
2026-11-04 09:13:05 +01:00
parent b509ce067f
commit 2ca4946c6e
5 changed files with 62 additions and 1 deletions
@@ -411,6 +411,24 @@ describe('composer close guard with a follow-up', () => {
expect(onDiscardDraft).toHaveBeenCalledWith('draft-9', expect.anything());
});
// compose-19: the keys people bring from other mail apps.
it('saves the draft on Ctrl+S, and keeps the browser from saving the page', async () => {
const createDraft = savingClient();
renderWithCloseRef();
const subject = screen.getByDisplayValue('Half-written more');
expect(fireEvent.keyDown(subject, { key: 's', ctrlKey: true })).toBe(false);
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1));
});
it('shows Cc on Ctrl+Shift+C and Bcc on Ctrl+Shift+B, and puts the cursor there', async () => {
renderWithCloseRef();
const subject = screen.getByDisplayValue('Half-written more');
expect(fireEvent.keyDown(subject, { key: 'C', ctrlKey: true, shiftKey: true })).toBe(false);
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'cc_label' })));
expect(fireEvent.keyDown(subject, { key: 'B', ctrlKey: true, shiftKey: true })).toBe(false);
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'bcc_label' })));
});
it('closes straight through when there is nothing to lose', async () => {
draftToast.showDraftSavedToast.mockClear();
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
@@ -36,6 +36,17 @@ describe('Insert link', () => {
expect(editor.getHTML()).toContain('href="https://example.com/docs"');
});
it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => {
const editor = await setup('<p>docs here</p>');
act(() => { editor.commands.setTextSelection({ from: 1, to: 5 }); });
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true });
act(() => { editor.view.dom.dispatchEvent(event); });
expect(event.defaultPrevented).toBe(true);
expect(screen.getByRole('dialog', { name: 'insert_link' })).toBeInTheDocument();
});
it('removes a link from the dialog', async () => {
const editor = await setup('<p><a href="https://example.com">docs</a> here</p>');
act(() => { editor.commands.setTextSelection(2); });
+19
View File
@@ -4805,6 +4805,25 @@ export function EmailComposer({
const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey;
const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey;
if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) {
// Ctrl+S saves the draft (Outlook, Thunderbird, Apple Mail), not the
// page; Ctrl+Shift+C and Ctrl+Shift+B show Cc and Bcc (Gmail).
const key = e.key.toLowerCase();
if (hasPrimaryModifier && !e.altKey && !e.shiftKey && key === 's') {
e.preventDefault();
if (!isSavingAndClosing) void saveDraft({ announce: true });
return;
}
if (hasPrimaryModifier && !e.altKey && e.shiftKey && (key === 'c' || key === 'b')) {
e.preventDefault();
if (key === 'c') {
setShowCc(true);
setTimeout(() => ccInputRef.current?.focus(), 0);
} else {
setShowBcc(true);
setTimeout(() => bccInputRef.current?.focus(), 0);
}
return;
}
// A plugin's composer button with a shortcut (composer-toolbar-actions)
// works while the composer has focus, also while typing.
if (!e.altKey && !hasPrimaryModifier) return;
+9
View File
@@ -496,6 +496,8 @@ export function RichTextEditor({
// Images waiting for a description, one dialog at a time: a new image is
// found by its src (it has no alt yet), an existing one by its position.
const [altQueue, setAltQueue] = useState<AltRequest[]>([]);
// Insert link, for the key handler set up with the editor.
const addLinkRef = useRef<(() => void) | null>(null);
const editor = useEditor({
extensions: [
@@ -598,6 +600,12 @@ export function RichTextEditor({
event.preventDefault();
return true;
}
// Ctrl+K inserts a link, as in Gmail, Outlook and Word.
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "k" && addLinkRef.current) {
event.preventDefault();
addLinkRef.current();
return true;
}
return false;
},
handleDrop: (view, event) => {
@@ -727,6 +735,7 @@ export function RichTextEditor({
const { from, to } = editor.state.selection;
setLinkDialog({ href: previousUrl ?? "", from, to });
}, [editor]);
addLinkRef.current = addLink;
const applyLink = useCallback((url: string) => {
if (!editor || !linkDialog) return;
editor
+5 -1
View File
@@ -38,7 +38,11 @@ export const MAIL_SHORTCUTS = {
composer: [
{ key: "Mod + Enter", description: "shortcuts.composer.send" },
{ key: "Mod + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
// "t" opens templates only outside a text field, so it isn't listed: in the composer it types a t.
{ key: "Mod + S", description: "compose_ux.shortcut_save_draft" },
{ key: "Mod + K", description: "compose_ux.shortcut_insert_link" },
{ key: "Mod + Shift + C", description: "compose_ux.shortcut_show_cc" },
{ key: "Mod + Shift + B", description: "compose_ux.shortcut_show_bcc" },
// Proofreading (components/email/proofreading); shown even without a checker.
{ key: "Mod + '", description: "proofreading.shortcuts.next_issue" },
{ key: "Mod + ;", description: "proofreading.shortcuts.previous_issue" },