Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,12 @@ function SidebarControl() {
return;
}
if (
isRichTextBoldShortcut(event) &&
isRichTextBoldShortcut(event, window.getSelection()) &&
event.target instanceof HTMLElement &&
event.target.closest('[data-composer-rich-text="true"]')
) {
// The rich-text composer claims Mod+B for bold; the toggle stays
// available everywhere else, including the plain-text composer.
// The rich-text composer claims Mod+B only to bold selected text;
// the toggle stays available everywhere else.
return;
}
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { userEvent } from "vite-plus/test/browser";
import { act, render } from "../../test/render";

import { serializeEditorDoc } from "~/composer-rich-text-doc";
import { isMacPlatform } from "~/lib/utils";
import {
ComposerPromptEditor,
type ComposerPromptEditorHandle,
Expand Down Expand Up @@ -148,6 +149,25 @@ it.each([
expect(textWithMark(editor, "code")).toEqual(code);
});

it("bolds with Mod+B only when text is selected", async () => {
await using screen = await mountEditor("ship it");
const { editor } = screen;
const modB = isMacPlatform(navigator.platform) ? "{Meta>}b{/Meta}" : "{Control>}b{/Control}";
await act(async () => {
editor.commands.focus("end");
});
await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
await userEvent.keyboard(modB);
// Without the guard, Tiptap stores bold for the next typed text.
expect(editor.state.storedMarks).toBeNull();

await act(async () => {
editor.commands.setTextSelection({ from: 1, to: 5 });
});
await userEvent.keyboard(modB);
await vi.waitFor(() => expect(textWithMark(editor, "bold")).toEqual(["ship"]));
});

it.each([false, true])(
"composes an umlaut without wrapping the preedit text (rich text: %s)",
async (richTextEnabled) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
ComposerListExtensions,
buildTiptapContent,
caretTakesMarksBefore,
ComposerBoldSelectionOnlyExtension,
convertBulletItemToTask,
ComposerCodeExtension,
ComposerItalicExtension,
Expand Down Expand Up @@ -874,6 +875,7 @@ function createComposerExtensions(richText: boolean) {
ComposerMarkersExtension,
...(richText
? [
ComposerBoldSelectionOnlyExtension,
ComposerItalicExtension,
ComposerCodeExtension,
ComposerCodeBlockExtension.extend({
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/composer-rich-text-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Italic, starInputRegex, starPasteRegex } from "@tiptap/extension-italic
import {
type Command,
type Editor,
Extension,
markInputRule,
markPasteRule,
mergeAttributes,
Expand Down Expand Up @@ -153,6 +154,20 @@ export const ComposerItalicExtension = Italic.extend({
},
});

/**
* Mod+B bolds selected text only. Without a selection Tiptap would toggle bold
* for the next typed text, which users hit by accident when they mean the
* sidebar toggle. Runs ahead of the Bold extension and swallows the chord.
*/
export const ComposerBoldSelectionOnlyExtension = Extension.create({
name: "composerBoldSelectionOnly",
priority: 1000,
addKeyboardShortcuts() {
const ignoreWithoutSelection = () => this.editor.state.selection.empty;
return { "Mod-b": ignoreWithoutSelection, "Mod-B": ignoreWithoutSelection };
},
});

/**
* Tiptap's block extensions each bind a chord that turns the current block into
* their node (Mod-Shift-8 for a list, Mod-Alt-c for a fence, and so on). The
Expand Down
18 changes: 15 additions & 3 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,18 +713,30 @@ describe("resolveShortcutCommand", () => {
});

describe("isRichTextBoldShortcut", () => {
const selected = { isCollapsed: false };

it("matches the B key on non-Latin layouts, like the sidebar toggle does", () => {
const cyrillicB = event({ key: "и", code: "KeyB", ctrlKey: true });
assert.isTrue(isRichTextBoldShortcut(cyrillicB));
assert.isTrue(isRichTextBoldShortcut(cyrillicB, selected));
assert.strictEqual(
resolveShortcutCommand(cyrillicB, DEFAULT_BINDINGS, { platform: "Win32" }),
"sidebar.toggle",
);
});

it("follows the letter a Latin layout types, not the physical key", () => {
assert.isFalse(isRichTextBoldShortcut(event({ key: "x", code: "KeyB", ctrlKey: true })));
assert.isTrue(isRichTextBoldShortcut(event({ key: "b", code: "KeyN", ctrlKey: true })));
assert.isFalse(
isRichTextBoldShortcut(event({ key: "x", code: "KeyB", ctrlKey: true }), selected),
);
assert.isTrue(
isRichTextBoldShortcut(event({ key: "b", code: "KeyN", ctrlKey: true }), selected),
);
});

it("leaves the chord to the sidebar toggle when no text is selected", () => {
const modB = event({ key: "b", metaKey: true });
assert.isFalse(isRichTextBoldShortcut(modB, { isCollapsed: true }));
assert.isFalse(isRichTextBoldShortcut(modB, null));
});
});

Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,15 +371,21 @@ export function isOpenFavoriteEditorShortcut(
}

/**
* Whether the keypress is the rich-text bold chord (Mod+B without extra
* modifiers). Tiptap binds the same chord, so app shortcuts captured ahead
* of the editor must yield when the rich-text composer is focused.
* Whether the keypress should bold text in the rich-text composer: Mod+B
* without extra modifiers, with text selected. Tiptap binds the same chord, so
* app shortcuts captured ahead of the editor yield only in that case. With no
* selection the chord stays with the app (the sidebar toggle by default).
*/
export function isRichTextBoldShortcut(event: ShortcutEventLike): boolean {
export function isRichTextBoldShortcut(
event: ShortcutEventLike,
selection: Pick<Selection, "isCollapsed"> | null,
): boolean {
if (event.type !== undefined && event.type !== "keydown") {
return false;
}
return (
selection !== null &&
!selection.isCollapsed &&
resolveEventKeys(event).has("b") &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
Expand Down
Loading