diff --git a/desktop/src/features/messages/lib/codeBlockExtensions.ts b/desktop/src/features/messages/lib/codeBlockExtensions.ts index c3490eda45c..cc9c3b1979f 100644 --- a/desktop/src/features/messages/lib/codeBlockExtensions.ts +++ b/desktop/src/features/messages/lib/codeBlockExtensions.ts @@ -67,6 +67,70 @@ export function insertNewlineInCodeBlock(ed: Editor): boolean { .run(); } +export function exitListIfEmptyLast(ed: Editor): boolean { + if (!ed.isActive("listItem")) return false; + const { $from } = ed.state.selection; + + let listItemDepth = -1; + for (let depth = $from.depth; depth >= 1; depth--) { + if ($from.node(depth).type.name === "listItem") { + listItemDepth = depth; + break; + } + } + if (listItemDepth < 1) return false; + + const listItem = $from.node(listItemDepth); + const isEmpty = + listItem.childCount === 1 && listItem.firstChild?.textContent === ""; + if (!isEmpty) return false; + + const listDepth = listItemDepth - 1; + const list = $from.node(listDepth); + if ($from.index(listDepth) !== list.childCount - 1) return false; + + const { tr, schema } = ed.state; + if (list.childCount === 1) { + const listStart = $from.before(listDepth); + tr.replaceWith( + listStart, + $from.after(listDepth), + schema.nodes.paragraph.create(), + ); + tr.setSelection(TextSelection.near(tr.doc.resolve(listStart + 1))); + } else { + tr.delete($from.before(listItemDepth), $from.after(listItemDepth)); + const listEnd = tr.mapping.map($from.after(listDepth)); + tr.insert(listEnd, schema.nodes.paragraph.create()); + tr.setSelection(TextSelection.near(tr.doc.resolve(listEnd + 1))); + } + ed.view.dispatch(tr); + return true; +} + +export function insertComposerSoftNewline( + ed: Editor, + { handleCodeFence }: { handleCodeFence: boolean }, +): boolean { + if (handleCodeFence) { + const fenceResult = handleCodeFenceEnter(ed); + if (fenceResult !== undefined) return fenceResult; + } + if (ed.isActive("codeBlock")) return insertNewlineInCodeBlock(ed); + if (exitListIfEmptyLast(ed)) return true; + if (ed.isActive("listItem")) { + return ed.commands.splitListItem("listItem"); + } + if (ed.isActive("blockquote")) { + const { $from } = ed.state.selection; + if ($from.parent.textContent === "") { + return ed.commands.lift("blockquote"); + } + return ed.chain().splitBlock().focus().run(); + } + return ed.commands.setHardBreak(); +} + export const CodeBlockAfterHardBreak = Extension.create({ name: "codeBlockAfterHardBreak", addInputRules() { diff --git a/desktop/src/features/messages/lib/composerSubmitShortcut.test.mjs b/desktop/src/features/messages/lib/composerSubmitShortcut.test.mjs new file mode 100644 index 00000000000..f90aa313f4b --- /dev/null +++ b/desktop/src/features/messages/lib/composerSubmitShortcut.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const storageKey = "buzz:composer-submit-shortcut:v1"; + +function createStorage(seed = {}) { + const values = new Map(Object.entries(seed)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }; +} + +let loadSequence = 0; + +async function loadStore(seed) { + globalThis.window = { localStorage: createStorage(seed) }; + loadSequence += 1; + return import( + `./composerSubmitShortcut.ts?test=${Date.now()}-${loadSequence}` + ); +} + +test("composer submit shortcut defaults to Enter", async () => { + const store = await loadStore(); + assert.equal(store.getComposerSubmitShortcut(), "enter"); +}); + +test("composer submit shortcut loads persisted Mod+Enter preference", async () => { + const store = await loadStore({ [storageKey]: "mod-enter" }); + assert.equal(store.getComposerSubmitShortcut(), "mod-enter"); +}); + +test("composer submit shortcut ignores invalid persisted values", async () => { + const store = await loadStore({ [storageKey]: "spacebar" }); + assert.equal(store.getComposerSubmitShortcut(), "enter"); +}); + +test("composer submit shortcut persists changes", async () => { + const store = await loadStore(); + + store.setComposerSubmitShortcut("mod-enter"); + + assert.equal(store.getComposerSubmitShortcut(), "mod-enter"); + assert.equal(window.localStorage.getItem(storageKey), "mod-enter"); +}); diff --git a/desktop/src/features/messages/lib/composerSubmitShortcut.ts b/desktop/src/features/messages/lib/composerSubmitShortcut.ts new file mode 100644 index 00000000000..43b2cec8189 --- /dev/null +++ b/desktop/src/features/messages/lib/composerSubmitShortcut.ts @@ -0,0 +1,59 @@ +import * as React from "react"; + +export type ComposerSubmitShortcut = "enter" | "mod-enter"; + +const STORAGE_KEY = "buzz:composer-submit-shortcut:v1"; +const DEFAULT_SHORTCUT: ComposerSubmitShortcut = "enter"; + +const listeners = new Set<() => void>(); +let currentShortcut = readShortcut(); + +function isComposerSubmitShortcut( + value: unknown, +): value is ComposerSubmitShortcut { + return value === "enter" || value === "mod-enter"; +} + +function readShortcut(): ComposerSubmitShortcut { + if (typeof window === "undefined") return DEFAULT_SHORTCUT; + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return isComposerSubmitShortcut(stored) ? stored : DEFAULT_SHORTCUT; + } catch { + return DEFAULT_SHORTCUT; + } +} + +function emit(): void { + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getComposerSubmitShortcut(): ComposerSubmitShortcut { + return currentShortcut; +} + +export function setComposerSubmitShortcut( + nextShortcut: ComposerSubmitShortcut, +): void { + if (currentShortcut === nextShortcut) return; + currentShortcut = nextShortcut; + try { + window.localStorage.setItem(STORAGE_KEY, nextShortcut); + } catch { + // Persistence is best-effort; the live setting still applies in memory. + } + emit(); +} + +export function useComposerSubmitShortcut(): ComposerSubmitShortcut { + return React.useSyncExternalStore( + subscribe, + getComposerSubmitShortcut, + () => DEFAULT_SHORTCUT, + ); +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index f812eb91156..516ee085b09 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -34,9 +34,11 @@ import { buildPreviewUpdate } from "./linkPreviewContent"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; import { CodeBlockAfterHardBreak, + exitListIfEmptyLast, handleCodeFenceEnter, - insertNewlineInCodeBlock, + insertComposerSoftNewline, } from "./codeBlockExtensions"; +import type { ComposerSubmitShortcut } from "./composerSubmitShortcut"; import { SpoilerMark } from "./spoilerMark"; import { createComposerLinkPasteHandler } from "./composerMessageLinkNode"; import type { ComposerMessageLinkChannel } from "./useComposerMessageLinks"; @@ -89,9 +91,11 @@ export type RichTextEditorOptions = { messageLinkChannels?: readonly ComposerMessageLinkChannel[]; /** Known custom-emoji set; used to render `:shortcode:` inline as images. */ customEmoji?: CustomEmoji[]; - /** Called on plain Enter (submit). Handled inside Tiptap's extension system - * so it fires *before* ProseMirror's default splitBlock behaviour. */ + /** Called by the active submit shortcut. Handled inside Tiptap's extension + * system so it fires *before* ProseMirror's default splitBlock behaviour. */ onSubmit?: () => void; + /** Which keyboard shortcut submits the composer. Defaults to Enter. */ + submitShortcut?: ComposerSubmitShortcut; /** * Called on ArrowUp in an empty composer (Slack parity: edit your last * message). Handled inside ProseMirror's `editorProps.handleKeyDown` — the @@ -213,6 +217,7 @@ export function useRichTextEditor({ onEditLink, onLinkSelectionChange, onLinkShortcut, + submitShortcut = "enter", }: RichTextEditorOptions) { const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; @@ -220,6 +225,9 @@ export function useRichTextEditor({ const onSubmitRef = React.useRef(onSubmit); onSubmitRef.current = onSubmit; + const submitShortcutRef = React.useRef(submitShortcut); + submitShortcutRef.current = submitShortcut; + const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); onEditLastOwnMessageRef.current = onEditLastOwnMessage; @@ -244,7 +252,7 @@ export function useRichTextEditor({ { extensions: [ StarterKit.configure({ - // Use hard breaks (Shift+Enter) — Enter submits the message. + // Use hard breaks for composer soft-newline shortcuts. hardBreak: { keepMarks: true, }, @@ -353,84 +361,11 @@ export function useRichTextEditor({ Extension.create({ name: "smartShiftEnter", addKeyboardShortcuts() { - // Exit a list by removing the empty last item and inserting a - // paragraph after the list. Works for both single-item and - // multi-item lists. - const exitListIfEmptyLast = (ed: typeof this.editor): boolean => { - if (!ed.isActive("listItem")) return false; - const { $from } = ed.state.selection; - - // Walk up to find the listItem node (handles nested structures). - let listItemDepth = -1; - for (let d = $from.depth; d >= 1; d--) { - if ($from.node(d).type.name === "listItem") { - listItemDepth = d; - break; - } - } - if (listItemDepth < 1) return false; - - const listItem = $from.node(listItemDepth); - const isEmpty = - listItem.childCount === 1 && - listItem.firstChild?.textContent === ""; - if (!isEmpty) return false; - - // Only trigger on the last item in the list. - const listDepth = listItemDepth - 1; - const list = $from.node(listDepth); - const itemIndex = $from.index(listDepth); - if (itemIndex !== list.childCount - 1) return false; - - const { tr, schema } = ed.state; - if (list.childCount === 1) { - // Only item → replace the entire list with an empty paragraph. - const listStart = $from.before(listDepth); - const listEnd = $from.after(listDepth); - const para = schema.nodes.paragraph.create(); - tr.replaceWith(listStart, listEnd, para); - tr.setSelection( - TextSelection.near(tr.doc.resolve(listStart + 1)), - ); - } else { - // Multiple items → delete the empty item, insert paragraph - // after the list, and move cursor there. - const itemStart = $from.before(listItemDepth); - const itemEnd = $from.after(listItemDepth); - tr.delete(itemStart, itemEnd); - const listEnd = tr.mapping.map($from.after(listDepth)); - const para = schema.nodes.paragraph.create(); - tr.insert(listEnd, para); - tr.setSelection( - TextSelection.near(tr.doc.resolve(listEnd + 1)), - ); - } - ed.view.dispatch(tr); - return true; - }; - return { "Shift-Enter": ({ editor: ed }) => { - if (ed.isActive("codeBlock")) { - return insertNewlineInCodeBlock(ed); - } - // Empty last list item → exit list to paragraph below. - if (exitListIfEmptyLast(ed)) return true; - // Non-empty or non-last list item → split. - if (ed.isActive("listItem")) { - return ed.commands.splitListItem("listItem"); - } - if (ed.isActive("blockquote")) { - // Empty blockquote paragraph → exit the blockquote. - const { $from } = ed.state.selection; - if ($from.parent.textContent === "") { - return ed.commands.lift("blockquote"); - } - // Non-empty → split the paragraph within the blockquote. - return ed.chain().splitBlock().focus().run(); - } - // Default: hard break (StarterKit handles it). - return false; + return insertComposerSoftNewline(ed, { + handleCodeFence: false, + }); }, ArrowDown: ({ editor: ed }) => { // Empty last list item + Down → exit list to paragraph below. @@ -439,15 +374,20 @@ export function useRichTextEditor({ }; }, }), - // Plain Enter → submit the message. This runs inside ProseMirror's - // keymap pipeline so it fires *before* the default splitBlock command, - // preventing the phantom paragraph-split that caused \n\n in messages. + // Composer submit/newline shortcut handling. This runs inside + // ProseMirror's keymap pipeline so handled Enter presses fire *before* + // the default splitBlock command, preventing phantom paragraph splits. Extension.create({ name: "submitOnEnter", addKeyboardShortcuts() { return { Enter: ({ editor: ed }) => { if (isAutocompleteOpen?.current) return false; + if (submitShortcutRef.current === "mod-enter") { + return insertComposerSoftNewline(ed, { + handleCodeFence: true, + }); + } if (!onSubmitRef.current) return false; const fenceResult = handleCodeFenceEnter(ed); @@ -456,6 +396,12 @@ export function useRichTextEditor({ onSubmitRef.current(); return true; }, + "Mod-Enter": () => { + if (submitShortcutRef.current !== "mod-enter") return false; + if (!onSubmitRef.current) return false; + onSubmitRef.current(); + return true; + }, }; }, }), diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 31a40c86b67..7d4bdc01fa4 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -3,6 +3,7 @@ import { EditorContent } from "@tiptap/react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; +import { useComposerSubmitShortcut } from "@/features/messages/lib/composerSubmitShortcut"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; @@ -223,9 +224,6 @@ function MessageComposerImpl({ emojiAutocomplete.isEmojiAutocompleteOpen; const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); - // Set after `useLinkEditor` exists below; the editor's link-click handler - // delegates through this ref to break the hook ordering cycle (the editor - // needs `onEditLink`, but the link editor needs the editor's `richText`). const onEditLinkRef = React.useRef< ((info: LinkSelectionInfo) => void) | null >(null); @@ -246,6 +244,7 @@ function MessageComposerImpl({ (replyTarget ? `Reply to ${replyTarget.author} in #${channelName}` : `Message #${channelName}`)); + const composerSubmitShortcut = useComposerSubmitShortcut(); const richText = useRichTextEditor({ placeholder: computedPlaceholder, editable: !composerDisabled, @@ -266,6 +265,7 @@ function MessageComposerImpl({ onEditLink: (info) => onEditLinkRef.current?.(info), onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, + submitShortcut: composerSubmitShortcut, onUpdate: ({ cursor, linkPreviewContent, text }) => { setComposerContentFromText(text); setPreviewContent(linkPreviewContent); diff --git a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx index c9c12b94216..2f2bbe251e6 100644 --- a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx +++ b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx @@ -3,6 +3,11 @@ import { getPlatformKeys, type KeyboardShortcut, } from "@/shared/lib/keyboard-shortcuts"; +import { + setComposerSubmitShortcut, + useComposerSubmitShortcut, + type ComposerSubmitShortcut, +} from "@/features/messages/lib/composerSubmitShortcut"; import { SettingsOptionGroup, SettingsOptionGroupList, @@ -10,6 +15,31 @@ import { } from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; +const MOD_ENTER_SHORTCUT: KeyboardShortcut = { + id: "composer-mod-enter", + label: "", + description: "", + keys: "⌘Enter", + keysWindows: "Ctrl+Enter", + category: "Messages", +}; + +function getShortcutForDisplay( + shortcut: KeyboardShortcut, + composerSubmitShortcut: ComposerSubmitShortcut, +): KeyboardShortcut { + if ( + shortcut.id === "send-message" && + composerSubmitShortcut === "mod-enter" + ) { + return { ...shortcut, keys: "⌘Enter", keysWindows: "Ctrl+Enter" }; + } + if (shortcut.id === "new-line" && composerSubmitShortcut === "mod-enter") { + return { ...shortcut, keys: "Enter", keysWindows: "Enter" }; + } + return shortcut; +} + function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { const keys = getPlatformKeys(shortcut); // Split on "+" but keep "+" as a standalone key (e.g. for zoom-in "⌘+") @@ -21,30 +51,104 @@ function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { return ( {parts.map((part) => ( - - {part} - + {part} + ))} + + ); +} + +function InlineKey({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function InlineSendShortcut() { + const keys = getPlatformKeys(MOD_ENTER_SHORTCUT); + const parts = keys === "⌘Enter" ? ["⌘", "Enter"] : keys.split("+"); + + return ( + + {parts.map((part) => ( + {part} ))} ); } +function ComposerSubmitShortcutRow({ + sendWithModEnter, +}: { + sendWithModEnter: boolean; +}) { + return ( + +
+ + When writing a message, press + Enter + to... + +
+ + +
+
+
+ ); +} + export function KeyboardShortcutsCard() { const categories = getShortcutsByCategory(); + const composerSubmitShortcut = useComposerSubmitShortcut(); + const sendWithModEnter = composerSubmitShortcut === "mod-enter"; return (
{[...categories.entries()].map(([category, shortcuts]) => ( + {category === "Messages" ? ( + + ) : null} {shortcuts.map((shortcut) => ( - + ))}