fix(desktop): scope Emacs keys to lines (#2127)

Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Bradley Axen
2026-07-19 15:37:23 -07:00
committed by GitHub
co-authored by npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79
parent e84f3fe8ed
commit 9a251fbef1
2 changed files with 114 additions and 2 deletions
@@ -7,6 +7,7 @@ import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import { Extension, type KeyboardShortcutCommand } from "@tiptap/core";
import { Plugin, Selection, TextSelection } from "@tiptap/pm/state";
import type { ResolvedPos } from "@tiptap/pm/model";
import {
hasPrimaryShortcutModifier,
@@ -35,6 +36,24 @@ import {
} from "./codeBlockExtensions";
import { SpoilerMark } from "./spoilerMark";
function hardBreakLineBounds($from: ResolvedPos) {
const parentStart = $from.start();
let start = parentStart;
let end = parentStart + $from.parent.content.size;
$from.parent.forEach((node, offset) => {
if (node.type.name !== "hardBreak") return;
const breakPosition = parentStart + offset;
if (breakPosition < $from.pos) {
start = breakPosition + node.nodeSize;
} else if (breakPosition >= $from.pos && end > breakPosition) {
end = breakPosition;
}
});
return { end, start };
}
/**
* Plain-text edit descriptor returned by autocomplete hooks
* (mentions / channel links / emoji). Offsets are in plain-text space —
@@ -243,8 +262,8 @@ export function useRichTextEditor({
link: false,
}),
// macOS text fields traditionally support a small set of Emacs-style
// Control shortcuts. ProseMirror already handles Ctrl-A/E/H/D on macOS;
// these fill in the common movement and kill-line gaps for the composer.
// Control shortcuts. Keep movement and kill-line scoped to the current
// hard-break-delimited line rather than the whole ProseMirror block.
Extension.create({
name: "macEmacsTextShortcuts",
addKeyboardShortcuts() {
@@ -254,6 +273,20 @@ export function useRichTextEditor({
}
return {
"Ctrl-a": ({ editor: ed }) => {
const { $from } = ed.state.selection;
if (!$from.parent.inlineContent) return false;
return ed.commands.setTextSelection(
hardBreakLineBounds($from).start,
);
},
"Ctrl-e": ({ editor: ed }) => {
const { $from } = ed.state.selection;
if (!$from.parent.inlineContent) return false;
return ed.commands.setTextSelection(
hardBreakLineBounds($from).end,
);
},
"Ctrl-b": ({ editor: ed }) => {
const { empty, from } = ed.state.selection;
if (!empty || from <= 0) return false;
@@ -272,6 +305,21 @@ export function useRichTextEditor({
return ed.commands.deleteSelection();
}
if ($from.parent.inlineContent) {
const lineEnd = hardBreakLineBounds($from).end;
if (from < lineEnd) {
return ed.commands.deleteRange({ from, to: lineEnd });
}
const nodeAfter = $from.nodeAfter;
if (nodeAfter?.type.name === "hardBreak") {
return ed.commands.deleteRange({
from,
to: from + nodeAfter.nodeSize,
});
}
}
const blockEnd = $from.end();
if (from < blockEnd) {
return ed.commands.deleteRange({ from, to: blockEnd });
@@ -460,6 +508,30 @@ export function useRichTextEditor({
// command/caret logic, fires regardless of selection state, and works
// the same across browser engines. Returning `true` consumes the key.
handleKeyDown: (view, event) => {
// Chromium handles Ctrl-A/E as whole-content movement before the
// keymap on macOS. Claim them at the raw DOM layer so hard breaks
// behave like actual line boundaries.
if (
isMacPlatform() &&
event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
(event.key.toLowerCase() === "a" || event.key.toLowerCase() === "e")
) {
const { $from } = view.state.selection;
if (!$from.parent.inlineContent) return false;
const bounds = hardBreakLineBounds($from);
const position =
event.key.toLowerCase() === "a" ? bounds.start : bounds.end;
view.dispatch(
view.state.tr.setSelection(
TextSelection.create(view.state.doc, position),
),
);
return true;
}
// ⌘K / Ctrl+K → link editor. The formatting toolbar has always
// advertised this shortcut on its link button; bind it here so it
// actually works. Kept alongside the ArrowUp handling below rather
@@ -126,6 +126,46 @@ test("macOS plain Ctrl+K still kill-lines in the composer", async ({
await expect(page.getByTestId("search-dialog-input")).toHaveCount(0);
});
test("macOS Ctrl+A, Ctrl+E, and Ctrl+K stay within hard-break lines", async ({
page,
}) => {
test.skip(process.platform !== "darwin", "mac-only Emacs bindings");
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { value: "MacIntel" });
});
await installMockBridge(page);
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("first");
await input.press("Shift+Enter");
await input.pressSequentially("middle");
await input.press("Shift+Enter");
await input.pressSequentially("last");
await expect(input.locator("br")).toHaveCount(2);
// Start/end movement applies to the third visual line, not the whole editor.
await page.keyboard.press("Control+a");
await input.pressSequentially("[");
await page.keyboard.press("Control+e");
await input.pressSequentially("]");
await expect(input).toHaveText("firstmiddle[last]");
// Kill only to this line's end, preserving both preceding lines.
await page.keyboard.press("Control+a");
await page.keyboard.press("Control+k");
await expect(input).toHaveText("firstmiddle");
await expect(input.locator("br")).toHaveCount(3);
// At end-of-line, kill the newline and join with the following line.
await page.keyboard.press("Control+b");
await page.keyboard.press("Control+k");
await expect(input).toHaveText("firstmiddle");
await expect(input.locator("br")).toHaveCount(1);
});
test("⌘K outside the composer opens quick search", async ({ page }) => {
await installMockBridge(page);
await openGeneral(page);