diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 3eab0edba..b1524987e 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -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>; diff --git a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx index e815efa53..c15a51ce4 100644 --- a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx +++ b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx @@ -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('

docs here

'); + 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('

docs here

'); act(() => { editor.commands.setTextSelection(2); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0f0da05c1..05c4fb78c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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; diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 532291294..a18d8252e 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -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([]); + // 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 diff --git a/lib/keyboard-shortcuts/mail.ts b/lib/keyboard-shortcuts/mail.ts index bfc88af5e..9d39a0008 100644 --- a/lib/keyboard-shortcuts/mail.ts +++ b/lib/keyboard-shortcuts/mail.ts @@ -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" },