diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6b491f..450b1bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Added +- Customize keyboard shortcuts from Settings. Thanks [@Mamdouh66](https://github.com/Mamdouh66)! [#204](https://github.com/bholmesdev/hubble.md/pull/204) + ### Changed ### Fixed diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0dc980ee..e1625352 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -3,7 +3,13 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { type AppCommandId, getCommand } from "@hubble.md/editor/commands"; +import { + type AppCommandId, + type CommandBindings, + getCommand, + getCommandBinding, + setCommandBindings, +} from "@hubble.md/editor/commands"; import hubbleRuntime from "@hubble.md/runtime/global.js?raw"; import htmlAppTheme from "@hubble.md/runtime/html-app-theme.css?raw"; import tailwindRuntime from "@tailwindcss/browser?raw"; @@ -785,7 +791,6 @@ type TextContextMenuItem = | { id: "copy-as-markdown"; label: string; - accelerator?: string; flag: keyof Electron.EditFlags; click: (webContents: Electron.WebContents) => void; }; @@ -796,7 +801,6 @@ const textContextMenuItems: TextContextMenuItem[] = [ { id: "copy-as-markdown", label: getCommand("app.copy-as-markdown").label, - accelerator: getCommand("app.copy-as-markdown").defaultBinding, flag: "canCopy", click: (webContents) => { webContents.send("desktop:menu-copy-as-markdown"); @@ -849,7 +853,7 @@ function buildTextContextMenu( : { id: item.id, label: item.label, - accelerator: item.accelerator, + accelerator: getCommandBinding("app.copy-as-markdown") ?? undefined, enabled: params.editFlags[item.flag], click: () => item.click(webContents), }, @@ -877,7 +881,7 @@ function commandMenuItem( return { id, label: command.label, - accelerator: command.defaultBinding, + accelerator: getCommandBinding(id) ?? undefined, enabled: command.isEnabled(menuState), click, }; @@ -1951,6 +1955,14 @@ function registerIpc() { }; buildMenu(); }); + + ipcMain.handle( + "desktop:set-shortcut-bindings", + (_event, bindings: CommandBindings) => { + setCommandBindings(bindings); + buildMenu(); + }, + ); } protocol.registerSchemesAsPrivileged([ diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index f2dbe046..1eabfff3 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -115,6 +115,8 @@ const desktopApi = { setSpellcheckLanguages: (languages) => ipcRenderer.invoke("desktop:set-spellcheck-languages", { languages }), setMenuState: (state) => ipcRenderer.invoke("desktop:set-menu-state", state), + setShortcutBindings: (bindings) => + ipcRenderer.invoke("desktop:set-shortcut-bindings", bindings), getUpdateState: () => ipcRenderer.invoke("desktop:get-update-state"), getTelemetryConsent: () => ipcRenderer.invoke("desktop:get-telemetry-consent"), diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 61a7a8ff..e920fea8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,6 +1,7 @@ import { type AppCommandId, getCommand, + getCommandBinding, wikiDisplayNameForTarget, } from "@hubble.md/editor"; import { @@ -9,7 +10,6 @@ import { EditorView, GlobalSearchPalette, getActiveEditor, - Input, MarkdownSourceEditor, OPEN_COMMAND_PALETTE_EVENT, type PaletteFile, @@ -21,31 +21,21 @@ import { useStoreValue } from "@simplestack/store/react"; import { keymatch } from "keymatch"; import { useEffect, useState, useSyncExternalStore } from "react"; import { toast } from "sonner"; -import MingcutePencilLine from "~icons/mingcute/pencil-line"; import { recentCommandIdsStore, recordRecentCommand, } from "./commands/recentCommands"; import { buildAppCommands } from "./commands/useAppCommands"; import { HtmlAppEmptyState } from "./components/HtmlAppEmptyState"; -import { SettingsDialog, SettingsSection } from "./components/SettingsDialog"; +import { Settings } from "./components/Settings"; import { type DesktopSidebarFocus, Sidebar } from "./components/Sidebar"; -import { SpellcheckSettingsSection } from "./components/SpellcheckSection"; -import { - TelemetryConsentCallout, - TelemetrySettingsSection, -} from "./components/TelemetrySection"; +import { TelemetryConsentCallout } from "./components/TelemetrySection"; import { TerminalPanel } from "./components/TerminalPanel"; import { Toolbar } from "./components/Toolbar"; -import { SidebarCallout, UpdatesSection } from "./components/UpdatesSection"; +import { SidebarCallout } from "./components/UpdatesSection"; import { WelcomeScreen } from "./components/WelcomeScreen"; import { desktopApi } from "./desktopApi"; -import type { - DesktopUpdateState, - SpellcheckState, - TelemetryChoice, - TelemetryConsent, -} from "./desktopApi/types"; +import type { DesktopUpdateState } from "./desktopApi/types"; import { createEmbedExtension } from "./editor/EmbedExtension"; import { handleImageDrop, handleImagePaste } from "./editor/handleImagePaste"; import { IframeView, toAssetUrl } from "./editor/IframeView"; @@ -80,6 +70,7 @@ import { goForward, handleExternalFileChange, loadPath, + loadSettingsState, openChangelog, openWorkspace, openWorkspaceWithSidebar, @@ -90,12 +81,10 @@ import { reloadFromDiskConflict, requestChatAboutNote, savePathContent, - setChatCommand, - setCodeFileOpenMode, setLastSeenVersion, setReviewThreads, setSidebarOpen, - setThemePreference, + setTelemetryConsent, setViewerMode, setWorkspaceSwitcherOpen, toggleTerminal, @@ -105,12 +94,12 @@ import { import { canGoBack, canGoForward } from "./store/history"; import { useHistoryNav } from "./store/hooks"; import { - chatCommandStore, - codeFileOpenModeStore, lastSeenVersionStore, + shortcutBindingsStore, sidebarOpenStore, + spellcheckStore, + telemetryConsentStore, terminalPositionStore, - themePreferenceStore, uiStore, type ViewMode, viewerStore, @@ -209,6 +198,7 @@ function App() { const workspacePath = useStoreValue(workspacePathStore); const sidebarOpen = useStoreValue(sidebarOpenStore); const terminalPosition = useStoreValue(terminalPositionStore); + const shortcutBindings = useStoreValue(shortcutBindingsStore); const hasWorkspace = workspacePath !== null; const { canGoBack: menuCanGoBack, canGoForward: menuCanGoForward } = useHistoryNav(); @@ -219,9 +209,8 @@ function App() { const [updateState, setUpdateState] = useState( null, ); - const [telemetryConsent, setTelemetryConsent] = - useState(null); - const [spellcheck, setSpellcheck] = useState(null); + const telemetryConsent = useStoreValue(telemetryConsentStore); + const spellcheck = useStoreValue(spellcheckStore); const [focusedSidebarItem, setFocusedSidebarItem] = useState(null); const updateFocusedSidebarItem = (next: DesktopSidebarFocus) => { @@ -311,30 +300,7 @@ function App() { if (currentVersion) setLastSeenVersion(currentVersion); }; - useEffect(() => { - void desktopApi.getTelemetryConsent().then(setTelemetryConsent); - }, []); - - useEffect(() => { - void desktopApi.getSpellcheckState().then(setSpellcheck); - }, []); - - const updateSpellcheck = async (request: Promise) => { - try { - await request; - setSpellcheck(await desktopApi.getSpellcheckState()); - } catch { - toast.error("Failed to update spellcheck"); - } - }; - - const changeSpellcheckEnabled = (enabled: boolean) => { - void updateSpellcheck(desktopApi.setSpellcheckEnabled(enabled)); - }; - - const changeSpellcheckLanguages = (languages: string[]) => { - void updateSpellcheck(desktopApi.setSpellcheckLanguages(languages)); - }; + useEffect(loadSettingsState, []); const spellcheckStatus: SpellcheckStatus | null = spellcheck?.enabled && @@ -347,20 +313,6 @@ function App() { } : null; - const chooseTelemetry = async (choice: TelemetryChoice) => { - setTelemetryConsent(await desktopApi.setTelemetryConsent(choice)); - if (choice !== "enabled") return; - // Declining wiped today's record, so re-enabling must record the current - // session again; an open HTML file counts as HTML App use. - const viewer = viewerStore.get(); - void desktopApi.recordTelemetryActivity({ - usedHtmlApp: - viewer.status === "ready" && - !!viewer.currentPath && - hasHtmlExtension(viewer.currentPath), - }); - }; - useEffect(() => { // First install has no update to announce; just record the version. if (currentVersion && lastSeenVersion === null) { @@ -456,6 +408,10 @@ function App() { state.viewMode, ]); + useEffect(() => { + void desktopApi.setShortcutBindings(shortcutBindings); + }, [shortcutBindings]); + useEffect(() => { if (!sidebarOpen) setFocusedSidebarItem(null); }, [sidebarOpen]); @@ -463,7 +419,8 @@ function App() { useEffect(() => { const onKeyDown = async (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (keymatch(event, getCommand("app.format-menu").defaultBinding)) { + const formatBinding = getCommandBinding("app.format-menu"); + if (formatBinding && keymatch(event, formatBinding)) { const editor = getActiveEditor(); if (editor?.isFocused && !editor.state.selection.empty) return; event.preventDefault(); @@ -510,10 +467,8 @@ function App() { () => void | Promise, ][]) { const command = getCommand(id); - if ( - keymatch(event, command.defaultBinding) && - command.isEnabled(context) - ) { + const binding = getCommandBinding(id); + if (binding && keymatch(event, binding) && command.isEnabled(context)) { event.preventDefault(); await handler(); return; @@ -778,7 +733,7 @@ function App() { /> ) : telemetryConsent === "unset" ? ( void chooseTelemetry(choice)} + onChoose={(choice) => void setTelemetryConsent(choice)} /> ) : undefined } @@ -846,111 +801,17 @@ function App() { recentCommandIds={recentCommandIds} onRunCommand={recordRecentCommand} /> - - {updateState ? ( - void triggerPrimaryUpdateAction()} - onViewChangelog={openWhatsNew} - /> - ) : null} - - {spellcheck ? ( - - ) : null} - - - {telemetryConsent ? ( - void chooseTelemetry(choice)} - /> - ) : null} - + void triggerPrimaryUpdateAction()} + onViewChangelog={openWhatsNew} + /> ); } -function GeneralSettingsSection() { - const theme = useStoreValue(themePreferenceStore); - return ( - -
- {(["light", "dark", "system"] as const).map((preference) => ( - - ))} -
-
- ); -} - -function CodeFilesSettingsSection() { - const mode = useStoreValue(codeFileOpenModeStore); - return ( - -
- - -
-
- ); -} - -function ChatAboutNoteSettingsSection() { - const [draft, setDraft] = useState(() => chatCommandStore.get()); - - return ( - -
- { - setDraft(event.currentTarget.value); - setChatCommand(event.currentTarget.value); - }} - /> - -
-
- ); -} - function DocumentViewer({ path, content, diff --git a/apps/desktop/src/commands/useAppCommands.ts b/apps/desktop/src/commands/useAppCommands.ts index 21f578c5..6867f4a6 100644 --- a/apps/desktop/src/commands/useAppCommands.ts +++ b/apps/desktop/src/commands/useAppCommands.ts @@ -1,6 +1,7 @@ import { type CommandId, getCommand, + getCommandBinding, type CommandContext as RegistryContext, } from "@hubble.md/editor"; import { @@ -111,7 +112,7 @@ function defineCommands( label: options?.label ?? command.label, group, keywords, - binding: command.defaultBinding, + binding: getCommandBinding(id) ?? undefined, globalShortcut: options?.globalShortcut ?? true, isEnabled: options?.isEnabled ?? (() => command.isEnabled(registry)), run, diff --git a/apps/desktop/src/components/Settings.tsx b/apps/desktop/src/components/Settings.tsx new file mode 100644 index 00000000..2087dfa9 --- /dev/null +++ b/apps/desktop/src/components/Settings.tsx @@ -0,0 +1,556 @@ +import { + type CommandId, + findCommandBindingConflicts, + getCommand, + resolveCommandBinding, +} from "@hubble.md/editor"; +import { Button, formatShortcut, Input } from "@hubble.md/ui"; +import { useStoreValue } from "@simplestack/store/react"; +import { type ReactNode, type Ref, useEffect, useRef, useState } from "react"; +import MingcuteCloseLine from "~icons/mingcute/close-line"; +import MingcutePencilLine from "~icons/mingcute/pencil-line"; +import MingcuteRefresh2Line from "~icons/mingcute/refresh-2-line"; +import type { DesktopUpdateState } from "../desktopApi/types"; +import { + resetShortcutBindings, + setChatCommand, + setCodeFileOpenMode, + setShortcutBinding, + setSpellcheckEnabled, + setSpellcheckLanguages, + setTelemetryConsent, + setThemePreference, +} from "../store/actions"; +import { + chatCommandStore, + codeFileOpenModeStore, + shortcutBindingsStore, + spellcheckStore, + telemetryConsentStore, + themePreferenceStore, +} from "../store/state"; +import { SettingsDialog, SettingsSection } from "./SettingsDialog"; +import { SpellcheckSettingsSection } from "./SpellcheckSection"; +import { + filterShortcutGroups, + isShortcutCustomized, + type ShortcutCommand, + shortcutBindingFromEvent, + validateShortcutBinding, +} from "./shortcutSettingsModel"; +import { TelemetrySettingsSection } from "./TelemetrySection"; +import { UpdatesSection } from "./UpdatesSection"; + +type SettingsPage = "general" | "chat" | "shortcuts"; +type Errors = Partial>; + +export function Settings({ + open, + onOpenChange, + updateState, + onUpdateAction, + onViewChangelog, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + updateState: DesktopUpdateState | null; + onUpdateAction: () => void; + onViewChangelog: () => void; +}) { + const [page, setPage] = useState("general"); + const activeTabRef = useRef(null); + useEffect(() => { + if (!open) return; + const timeout = window.setTimeout(() => activeTabRef.current?.focus()); + return () => window.clearTimeout(timeout); + }, [open]); + + return ( + +
+ +
+ {page === "general" ? ( + + ) : page === "chat" ? ( + + ) : ( + + )} +
+
+
+ ); +} + +const pageTitles: Record = { + general: "General", + chat: "Chat", + shortcuts: "Keyboard shortcuts", +}; + +function GeneralSettings({ + updateState, + onUpdateAction, + onViewChangelog, +}: { + updateState: DesktopUpdateState | null; + onUpdateAction: () => void; + onViewChangelog: () => void; +}) { + const spellcheck = useStoreValue(spellcheckStore); + const telemetryConsent = useStoreValue(telemetryConsentStore); + + return ( +
+ {updateState ? ( + + ) : null} + + {spellcheck ? ( + void setSpellcheckEnabled(enabled)} + onLanguagesChange={(languages) => + void setSpellcheckLanguages(languages) + } + /> + ) : null} + + {telemetryConsent ? ( + void setTelemetryConsent(choice)} + /> + ) : null} +
+ ); +} + +function AppearanceSettings() { + const theme = useStoreValue(themePreferenceStore); + return ( + +
+ {(["light", "dark", "system"] as const).map((preference) => ( + + ))} +
+
+ ); +} + +function CodeFilesSettings() { + const mode = useStoreValue(codeFileOpenModeStore); + return ( + +
+ + +
+
+ ); +} + +function ChatSettings() { + const [draft, setDraft] = useState(() => chatCommandStore.get()); + + return ( +
+ +
+ { + setDraft(event.currentTarget.value); + setChatCommand(event.currentTarget.value); + }} + /> + +
+
+
+ ); +} + +function ShortcutSettings() { + const state = useShortcutState(); + const groups = filterShortcutGroups(state.query, state.bindings); + + return ( +
+
+ state.setQuery(event.currentTarget.value)} + /> + +
+
+ {groups.map(({ area, commands: groupCommands }) => ( +
+

{area}

+
+ {groupCommands.map((command) => ( + + ))} +
+
+ ))} + {groups.length === 0 ? ( +

+ No shortcuts match this search. +

+ ) : null} +
+
+ ); +} + +function CommandRow({ + command, + state, +}: { + command: ShortcutCommand; + state: ReturnType; +}) { + const binding = resolveCommandBinding(command.id, state.bindings); + const conflicts = findCommandBindingConflicts(command.id, state.bindings); + const customized = isShortcutCustomized(command.id, state.bindings); + const recording = state.recordingId === command.id; + + return ( +
+
+

{command.label}

+

+ {command.description} +

+ {state.errors[command.id] ? ( +

+ {state.errors[command.id]} +

+ ) : null} + {conflicts.length > 0 ? ( +

+ Also assigned to{" "} + {conflicts.map((id, index) => ( + + {conflictSeparator(index, conflicts.length)} + + + ))} +

+ ) : null} +
+
+
+ {customized ? ( + + ) : null} +
+
+ + {binding && !recording ? ( + + ) : null} +
+
+
+ ); +} + +function useShortcutState() { + const bindings = useStoreValue(shortcutBindingsStore); + const [errors, setErrors] = useState({}); + const [recordingId, setRecordingId] = useState(null); + const [query, setQuery] = useState(""); + // Conflict links change this ID to scroll to and pulse the matching row. + const [revealId, setRevealId] = useState(null); + + useEffect(() => { + if (!revealId) return; + const frame = requestAnimationFrame(() => { + const row = document.getElementById(shortcutRowId(revealId)); + if (!row) return; + row.scrollIntoView({ + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth", + block: "center", + }); + row.focus({ preventScroll: true }); + pulseShortcutRow(row); + setRevealId(null); + }); + return () => cancelAnimationFrame(frame); + }, [revealId]); + + useEffect(() => { + if (!recordingId) return; + + const record = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape") { + setRecordingId(null); + setErrors((current) => ({ ...current, [recordingId]: undefined })); + return; + } + if ( + (event.key === "Backspace" || event.key === "Delete") && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ) { + setShortcutBinding(recordingId, null); + setRecordingId(null); + setErrors((current) => ({ ...current, [recordingId]: undefined })); + return; + } + + const binding = shortcutBindingFromEvent(event); + if (!binding) return; + const error = validateShortcutBinding(binding); + if (error) { + setErrors((current) => ({ ...current, [recordingId]: error })); + return; + } + + setShortcutBinding(recordingId, binding); + setErrors((current) => ({ ...current, [recordingId]: undefined })); + setRecordingId(null); + }; + + const onBlur = () => { + setErrors((current) => ({ + ...current, + [recordingId]: + "The operating system may have intercepted that shortcut. Press another combination or Escape.", + })); + }; + window.addEventListener("keydown", record, true); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("keydown", record, true); + window.removeEventListener("blur", onBlur); + }; + }, [recordingId]); + + const clearError = (id: CommandId) => + setErrors((current) => ({ ...current, [id]: undefined })); + + return { + bindings, + clear: (id: CommandId) => { + setShortcutBinding(id, null); + clearError(id); + if (recordingId === id) setRecordingId(null); + }, + errors, + query, + recordingId, + reveal: (id: CommandId) => { + setQuery(""); + setRevealId(id); + }, + reset: (id: CommandId) => { + setShortcutBinding(id, getCommand(id).defaultBinding); + clearError(id); + if (recordingId === id) setRecordingId(null); + }, + resetAll: () => { + resetShortcutBindings(); + setErrors({}); + setRecordingId(null); + }, + setQuery, + startRecording: (id: CommandId) => { + clearError(id); + setRecordingId(id); + }, + }; +} + +function shortcutRowId(id: CommandId) { + return `shortcut-${id}`; +} + +function conflictSeparator(index: number, count: number) { + if (index === 0) return ""; + if (index === count - 1) return count === 2 ? " and " : ", and "; + return ", "; +} + +function pulseShortcutRow(row: HTMLElement) { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + row.animate( + [ + { backgroundColor: "transparent" }, + { + backgroundColor: + "color-mix(in oklab, var(--brand-accent) 24%, transparent)", + }, + { backgroundColor: "transparent" }, + ], + { duration: 700, easing: "ease-out" }, + ); +} + +function NavButton({ + active, + buttonRef, + children, + onClick, +}: { + active: boolean; + buttonRef?: Ref; + children: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/components/SettingsDialog.tsx b/apps/desktop/src/components/SettingsDialog.tsx index ad26ce56..991119bb 100644 --- a/apps/desktop/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/components/SettingsDialog.tsx @@ -1,23 +1,51 @@ import { Modal } from "@hubble.md/ui"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; export function SettingsDialog({ open, onOpenChange, + title, + className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; + title: string; + className?: string; children: ReactNode; }) { + const [scrollState, setScrollState] = useState({ title, scrolled: false }); + const scrolled = scrollState.title === title && scrollState.scrolled; + return ( + Settings + {title} + + } + className={`overflow-hidden p-0 ${className ?? "max-w-xl"}`} + headerClassName={`relative z-10 mb-0 h-12 items-center pr-2.5 [&>div:first-child]:flex-1 ${ + scrolled + ? "after:absolute after:right-0 after:bottom-0 after:left-40 after:border-b after:border-dashed after:border-border after:content-['']" + : "" + }`} + contentClassName="mr-0 overflow-visible pr-0" > -
{children}
+
+ setScrollState({ + title, + scrolled: (event.target as HTMLElement).scrollTop > 0, + }) + } + > + {children} +
); } diff --git a/apps/desktop/src/components/Toolbar.tsx b/apps/desktop/src/components/Toolbar.tsx index eb8ee467..9a06eab3 100644 --- a/apps/desktop/src/components/Toolbar.tsx +++ b/apps/desktop/src/components/Toolbar.tsx @@ -3,10 +3,11 @@ import type { AppCommandId } from "@hubble.md/editor"; import { Button, commandReviewThread, - formatCommandShortcut, ReviewCommentSummary, type ReviewCommentSummaryProps, Toolbar as SharedToolbar, + useCommandShortcut, + useCommandShortcutLabel, } from "@hubble.md/ui"; import { useStoreValue } from "@simplestack/store/react"; import { type CSSProperties, useEffect, useState } from "react"; @@ -161,8 +162,8 @@ export function Toolbar({ function NavigationControls() { const { canGoBack, canGoForward } = useHistoryNav(); - const backLabel = `Go Back (${formatCommandShortcut("app.go-back")})`; - const forwardLabel = `Go Forward (${formatCommandShortcut("app.go-forward")})`; + const backLabel = useCommandShortcutLabel("Go Back", "app.go-back"); + const forwardLabel = useCommandShortcutLabel("Go Forward", "app.go-forward"); return (
diff --git a/packages/ui/src/editor/EditorCommandShortcuts.test.ts b/packages/ui/src/editor/EditorCommandShortcuts.test.ts index 07f2bdfd..b3aadb4a 100644 --- a/packages/ui/src/editor/EditorCommandShortcuts.test.ts +++ b/packages/ui/src/editor/EditorCommandShortcuts.test.ts @@ -1,6 +1,10 @@ // @vitest-environment happy-dom -import { InlineCodeExtension, listExtensions } from "@hubble.md/editor"; +import { + InlineCodeExtension, + listExtensions, + setCommandBindings, +} from "@hubble.md/editor"; import { Editor } from "@tiptap/core"; import { TaskItem } from "@tiptap/extension-list"; import { afterEach, describe, expect, it } from "vitest"; @@ -14,6 +18,7 @@ const editors: Editor[] = []; afterEach(() => { for (const editor of editors) editor.destroy(); editors.length = 0; + setCommandBindings({}); }); describe("EditorCommandShortcuts", () => { @@ -64,6 +69,41 @@ describe("EditorCommandShortcuts", () => { expect(editor.commands.keyboardShortcut("Mod-Shift-9")).toBe(true); expect(editor.isActive("bulletList")).toBe(true); }); + + it("applies a remap to an editor that is already open", () => { + const editor = createEditor(); + editor.commands.selectAll(); + setCommandBindings({ "editor.bold": "CmdOrCtrl+Alt+B" }); + + editor.commands.keyboardShortcut("Mod-b"); + expect(editor.isActive("bold")).toBe(false); + + editor.commands.keyboardShortcut("Mod-Alt-b"); + expect(editor.isActive("bold")).toBe(true); + }); + + it("does not run a disabled shortcut", () => { + const editor = createEditor(); + editor.commands.selectAll(); + setCommandBindings({ "editor.italic": null }); + + editor.commands.keyboardShortcut("Mod-i"); + expect(editor.isActive("italic")).toBe(false); + }); + + it("runs only the first command when shortcuts conflict", () => { + const editor = createEditor(); + editor.commands.selectAll(); + setCommandBindings({ + "editor.bold": "CmdOrCtrl+Alt+M", + "editor.italic": "CmdOrCtrl+Alt+M", + }); + + editor.commands.keyboardShortcut("Mod-Alt-m"); + + expect(editor.isActive("bold")).toBe(true); + expect(editor.isActive("italic")).toBe(false); + }); }); // Mirrors the EditorView setup: registry-owned StarterKit shortcuts plus diff --git a/packages/ui/src/editor/EditorCommandShortcuts.ts b/packages/ui/src/editor/EditorCommandShortcuts.ts index 9d9dadff..ca22ee9e 100644 --- a/packages/ui/src/editor/EditorCommandShortcuts.ts +++ b/packages/ui/src/editor/EditorCommandShortcuts.ts @@ -1,10 +1,12 @@ -import { tiptapBinding } from "@hubble.md/editor"; +import { type EditorCommandId, getCommandBinding } from "@hubble.md/editor"; import { Extension } from "@tiptap/core"; import { Blockquote } from "@tiptap/extension-blockquote"; import { Bold } from "@tiptap/extension-bold"; import { Heading } from "@tiptap/extension-heading"; import { Italic } from "@tiptap/extension-italic"; +import { Plugin } from "@tiptap/pm/state"; import StarterKit from "@tiptap/starter-kit"; +import { keymatch } from "keymatch"; const withoutShortcuts = { addKeyboardShortcuts: () => ({}) }; @@ -43,36 +45,72 @@ export const EditorCommandShortcuts = Extension.create({ name: "editorCommandShortcuts", priority: 2000, - addKeyboardShortcuts() { - return { - [tiptapBinding("editor.link")]: () => - this.editor.commands.toggleLinkAtSelection(), - [tiptapBinding("editor.strike")]: () => - this.editor.commands.toggleMark("strike"), - [tiptapBinding("editor.ordered-list")]: () => - this.editor.commands.toggleParentOrderedList(), - [tiptapBinding("editor.bullet-list")]: () => - this.editor.commands.toggleParentBulletList(), - [tiptapBinding("editor.task-list")]: () => - this.editor.commands.toggleParentTaskList(), - [tiptapBinding("editor.bold")]: () => this.editor.commands.toggleBold(), - [tiptapBinding("editor.italic")]: () => - this.editor.commands.toggleItalic(), - [tiptapBinding("editor.code")]: () => this.editor.commands.toggleCode(), - [tiptapBinding("editor.heading-1")]: () => - this.editor.commands.toggleHeading({ level: 1 }), - [tiptapBinding("editor.heading-2")]: () => - this.editor.commands.toggleHeading({ level: 2 }), - [tiptapBinding("editor.heading-3")]: () => - this.editor.commands.toggleHeading({ level: 3 }), - [tiptapBinding("editor.heading-4")]: () => - this.editor.commands.toggleHeading({ level: 4 }), - [tiptapBinding("editor.heading-5")]: () => - this.editor.commands.toggleHeading({ level: 5 }), - [tiptapBinding("editor.heading-6")]: () => - this.editor.commands.toggleHeading({ level: 6 }), - [tiptapBinding("editor.blockquote")]: () => - this.editor.commands.toggleBlockquote(), + addProseMirrorPlugins() { + const run = (id: EditorCommandId) => { + switch (id) { + case "editor.link": + return this.editor.commands.toggleLinkAtSelection(); + case "editor.strike": + return this.editor.commands.toggleMark("strike"); + case "editor.ordered-list": + return this.editor.commands.toggleParentOrderedList(); + case "editor.bullet-list": + return this.editor.commands.toggleParentBulletList(); + case "editor.task-list": + return this.editor.commands.toggleParentTaskList(); + case "editor.bold": + return this.editor.commands.toggleBold(); + case "editor.italic": + return this.editor.commands.toggleItalic(); + case "editor.code": + return this.editor.commands.toggleCode(); + case "editor.heading-1": + return this.editor.commands.toggleHeading({ level: 1 }); + case "editor.heading-2": + return this.editor.commands.toggleHeading({ level: 2 }); + case "editor.heading-3": + return this.editor.commands.toggleHeading({ level: 3 }); + case "editor.heading-4": + return this.editor.commands.toggleHeading({ level: 4 }); + case "editor.heading-5": + return this.editor.commands.toggleHeading({ level: 5 }); + case "editor.heading-6": + return this.editor.commands.toggleHeading({ level: 6 }); + case "editor.blockquote": + return this.editor.commands.toggleBlockquote(); + } }; + + return [ + new Plugin({ + props: { + handleKeyDown: (_view, event) => { + for (const id of editorCommandIds) { + const binding = getCommandBinding(id); + if (binding && keymatch(event, binding)) return run(id); + } + return false; + }, + }, + }), + ]; }, }); + +const editorCommandIds: EditorCommandId[] = [ + "editor.link", + "editor.strike", + "editor.ordered-list", + "editor.bullet-list", + "editor.task-list", + "editor.bold", + "editor.italic", + "editor.code", + "editor.heading-1", + "editor.heading-2", + "editor.heading-3", + "editor.heading-4", + "editor.heading-5", + "editor.heading-6", + "editor.blockquote", +]; diff --git a/packages/ui/src/editor/FindBar.tsx b/packages/ui/src/editor/FindBar.tsx index e6829b24..e089b6e3 100644 --- a/packages/ui/src/editor/FindBar.tsx +++ b/packages/ui/src/editor/FindBar.tsx @@ -1,6 +1,6 @@ import { type FindState, - getCommand, + getCommandBinding, getFindState, selectFindMatch, } from "@hubble.md/editor"; @@ -108,7 +108,8 @@ export function FindBar({ editor }: { editor: Editor | null }) { } return; } - if (!keymatch(event, getCommand("app.find").defaultBinding)) return; + const binding = getCommandBinding("app.find"); + if (!binding || !keymatch(event, binding)) return; if (!editor?.isFocused && !open) return; event.preventDefault(); openFind(); diff --git a/packages/ui/src/editor/FormatCommandMenu.tsx b/packages/ui/src/editor/FormatCommandMenu.tsx index 7863ce9f..7910c2af 100644 --- a/packages/ui/src/editor/FormatCommandMenu.tsx +++ b/packages/ui/src/editor/FormatCommandMenu.tsx @@ -1,4 +1,8 @@ -import { type CommandId, getCommand } from "@hubble.md/editor"; +import { + type CommandBindings, + type CommandId, + getCommandBinding, +} from "@hubble.md/editor"; import type { Editor } from "@tiptap/core"; import { Command } from "cmdk"; import { keymatch } from "keymatch"; @@ -23,7 +27,7 @@ import MingcuteListOrderedLine from "~icons/mingcute/list-ordered-line"; import MingcuteQuoteLeftLine from "~icons/mingcute/quote-left-line"; import MingcuteStrikethroughLine from "~icons/mingcute/strikethrough-line"; import MingcuteTextLine from "~icons/mingcute/text-line"; -import { formatCommandShortcut } from "../lib/shortcut"; +import { formatCommandShortcut, useCommandBindings } from "../lib/shortcut"; import { cn } from "../lib/utils"; import { useCommandMenuPosition } from "./commandMenuPosition"; import { @@ -172,6 +176,7 @@ export function FormatCommandMenu({ editor: Editor | null; viewportRef: RefObject; }) { + const commandBindings = useCommandBindings(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [position, setPosition] = useState(null); @@ -224,8 +229,8 @@ export function FormatCommandMenu({ editor.commands.focus(undefined, { scrollIntoView: false }); return; } - if (!keymatch(event, getCommand("app.format-menu").defaultBinding)) - return; + const binding = getCommandBinding("app.format-menu"); + if (!binding || !keymatch(event, binding)) return; if (!editor.isFocused && !open) return; if (!open && editor.state.selection.empty) return; event.preventDefault(); @@ -321,8 +326,20 @@ export function FormatCommandMenu({
) : ( <> - {renderGroup("Block", visibleCommands, runCommand, editor)} - {renderGroup("Inline", visibleCommands, runCommand, editor)} + {renderGroup( + "Block", + visibleCommands, + runCommand, + editor, + commandBindings, + )} + {renderGroup( + "Inline", + visibleCommands, + runCommand, + editor, + commandBindings, + )} )} @@ -336,6 +353,7 @@ function renderGroup( commands: FormatCommand[], runCommand: (kind: FormatCommandKind) => void, editor: Editor, + commandBindings: CommandBindings, ) { const groupCommands = commands.filter((command) => command.group === group); if (groupCommands.length === 0) return null; @@ -349,6 +367,9 @@ function renderGroup( {groupCommands.map((command) => { const Icon = command.icon; const isApplied = isFormatActive(editor, command.kind); + const shortcut = command.shortcut + ? formatCommandShortcut(command.shortcut, commandBindings) + : null; return ( {isApplied ? ( - ) : command.shortcut ? ( + ) : shortcut ? ( ) : null} diff --git a/packages/ui/src/editor/SlashCommandMenu.tsx b/packages/ui/src/editor/SlashCommandMenu.tsx index a67ee7ab..ddb633f6 100644 --- a/packages/ui/src/editor/SlashCommandMenu.tsx +++ b/packages/ui/src/editor/SlashCommandMenu.tsx @@ -19,7 +19,7 @@ import MingcuteQuoteLeftLine from "~icons/mingcute/quote-left-line"; import MingcuteStrikethroughLine from "~icons/mingcute/strikethrough-line"; import MingcuteTable2Line from "~icons/mingcute/table-2-line"; import MingcuteTextLine from "~icons/mingcute/text-line"; -import { formatCommandShortcut } from "../lib/shortcut"; +import { formatCommandShortcut, useCommandBindings } from "../lib/shortcut"; import { cn } from "../lib/utils"; import { useCommandMenuPosition } from "./commandMenuPosition"; import { @@ -138,6 +138,7 @@ export function SlashCommandMenu({ editor: Editor | null; viewportRef: RefObject; }) { + const commandBindings = useCommandBindings(); const [token, setToken] = useState(null); const [position, setPosition] = useState(null); const [selectedKind, setSelectedKind] = @@ -297,6 +298,9 @@ export function SlashCommandMenu({ {visibleCommands.map((command) => { const Icon = command.icon; + const shortcut = command.shortcut + ? formatCommandShortcut(command.shortcut, commandBindings) + : null; return ( {command.title} - {command.shortcut && ( + {shortcut ? ( - )} + ) : null} ); })} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f1077666..6e8c6c5f 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -73,7 +73,13 @@ export { scoreCommand, stripCommandPrefix, } from "./lib/paletteCommand"; -export { formatCommandShortcut, formatShortcut } from "./lib/shortcut"; +export { + formatCommandShortcut, + formatShortcut, + useCommandBindings, + useCommandShortcut, + useCommandShortcutLabel, +} from "./lib/shortcut"; export { Button, buttonVariants } from "./primitives/button"; export { Input } from "./primitives/input"; export { Modal } from "./primitives/modal"; diff --git a/packages/ui/src/lib/shortcut.ts b/packages/ui/src/lib/shortcut.ts index 43f1b556..ae2cfc9c 100644 --- a/packages/ui/src/lib/shortcut.ts +++ b/packages/ui/src/lib/shortcut.ts @@ -1,5 +1,12 @@ -import { type CommandId, getCommand } from "@hubble.md/editor"; +import { + type CommandId, + getCommandBinding, + getCommandBindings, + resolveCommandBinding, + subscribeCommandBindings, +} from "@hubble.md/editor"; import { isMac } from "keymatch"; +import { useSyncExternalStore } from "react"; // macOS renders modifiers as adjacent glyphs (⌘⌥R); Windows/Linux use // "+"-joined words (Ctrl+Alt+R). Keys are matched against the same @@ -57,6 +64,30 @@ export function formatShortcut(spec: string): string { return mac ? rendered.join("") : rendered.join("+"); } -export function formatCommandShortcut(id: CommandId): string { - return formatShortcut(getCommand(id).defaultBinding); +export function formatCommandShortcut( + id: CommandId, + bindings?: ReturnType, +): string | null { + const binding = bindings + ? resolveCommandBinding(id, bindings) + : getCommandBinding(id); + return binding ? formatShortcut(binding) : null; +} + +export function useCommandBindings() { + return useSyncExternalStore( + subscribeCommandBindings, + getCommandBindings, + getCommandBindings, + ); +} + +export function useCommandShortcut(id: CommandId) { + const bindings = useCommandBindings(); + return formatCommandShortcut(id, bindings); +} + +export function useCommandShortcutLabel(label: string, id: CommandId) { + const shortcut = useCommandShortcut(id); + return shortcut ? `${label} (${shortcut})` : label; } diff --git a/packages/ui/src/primitives/modal.tsx b/packages/ui/src/primitives/modal.tsx index 66ef7935..005d64c3 100644 --- a/packages/ui/src/primitives/modal.tsx +++ b/packages/ui/src/primitives/modal.tsx @@ -7,9 +7,11 @@ import { Button } from "./button"; type Props = { open?: boolean; onOpenChange?: (open: boolean) => void; - title: string; + title: ReactNode; description?: string; className?: string; + headerClassName?: string; + contentClassName?: string; children: ReactNode; }; @@ -19,6 +21,8 @@ function Modal({ title, description, className, + headerClassName, + contentClassName, children, }: Props) { return ( @@ -31,7 +35,12 @@ function Modal({ className, )} > -
+
{title} @@ -55,7 +64,12 @@ function Modal({ } />
-
+
{children}