mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79
parent
e84f3fe8ed
commit
9a251fbef1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user