fix: improve keyboard shortcuts (#5601)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
65ee147681
commit
973572d060
8 changed files with 260 additions and 17 deletions
|
|
@ -1370,7 +1370,11 @@
|
|||
"command_menu": "Search & command menu",
|
||||
"help_menu": "Help menu",
|
||||
"show_all": "Keyboard shortcuts",
|
||||
"title": "General"
|
||||
"title": "General",
|
||||
"comment_uncomment": "Comment/Uncomment",
|
||||
"close_tab": "Close Tab",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo"
|
||||
},
|
||||
"miscellaneous": {
|
||||
"invite": "Invite people to Hoppscotch",
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ const ensureCompilerOptions = (() => {
|
|||
command: null,
|
||||
})
|
||||
|
||||
// Add Cmd+Y redo keybinding for Monaco
|
||||
monaco.editor.addKeybindingRule({
|
||||
keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyY,
|
||||
command: "redo",
|
||||
})
|
||||
|
||||
applied = true
|
||||
}
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<template>
|
||||
<HoppSmartSlideOver :show="show" :title="t('app.shortcuts')" @close="close()">
|
||||
<HoppSmartSlideOver
|
||||
:show="show"
|
||||
:title="t('app.shortcuts')"
|
||||
data-shortcuts-flyout
|
||||
@close="close()"
|
||||
>
|
||||
<template #content>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 flex-col overflow-x-auto bg-primary"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ import {
|
|||
StreamLanguage,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language"
|
||||
import { defaultKeymap, indentLess, insertTab } from "@codemirror/commands"
|
||||
import {
|
||||
defaultKeymap,
|
||||
indentLess,
|
||||
insertTab,
|
||||
redo,
|
||||
} from "@codemirror/commands"
|
||||
import { Completion, autocompletion } from "@codemirror/autocomplete"
|
||||
import { linter } from "@codemirror/lint"
|
||||
import { watch, ref, Ref, onMounted, onBeforeUnmount } from "vue"
|
||||
|
|
@ -53,7 +58,11 @@ import {
|
|||
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
|
||||
import xmlFormat from "xml-formatter"
|
||||
import { platform } from "~/platform"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import {
|
||||
invokeAction,
|
||||
registerCodeMirrorView,
|
||||
unregisterCodeMirrorView,
|
||||
} from "~/helpers/actions"
|
||||
import { useDebounceFn } from "@vueuse/core"
|
||||
// TODO: Migrate from legacy mode
|
||||
|
||||
|
|
@ -165,6 +174,13 @@ const hoppLang = (
|
|||
exts.push(hoppLinterExt(linter))
|
||||
if (completer) exts.push(hoppCompleterExt(completer))
|
||||
|
||||
// Add comment token configuration for JSONC to enable comment toggle
|
||||
if (language === jsoncLanguage) {
|
||||
exts.push(
|
||||
EditorState.languageData.of(() => [{ commentTokens: { line: "//" } }])
|
||||
)
|
||||
}
|
||||
|
||||
return language ? new LanguageSupport(language, exts) : exts
|
||||
}
|
||||
|
||||
|
|
@ -405,7 +421,10 @@ export function useCodemirror(
|
|||
.toJSON()
|
||||
.join(update.state.lineBreak)
|
||||
if (!options.extendedEditorConfig.readOnly) {
|
||||
value.value = cachedValue.value
|
||||
// Only update if the value is actually different to prevent circular updates
|
||||
if (value.value !== cachedValue.value) {
|
||||
value.value = cachedValue.value
|
||||
}
|
||||
if (options.onChange) {
|
||||
options.onChange(cachedValue.value)
|
||||
}
|
||||
|
|
@ -462,6 +481,16 @@ export function useCodemirror(
|
|||
run: indentLess,
|
||||
},
|
||||
]),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Ctrl-y",
|
||||
mac: "Cmd-y",
|
||||
preventDefault: true,
|
||||
run: redo,
|
||||
},
|
||||
])
|
||||
),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
|
|
@ -496,6 +525,9 @@ export function useCodemirror(
|
|||
scrollTo: EditorView.scrollIntoView(0),
|
||||
})
|
||||
|
||||
// Register the view for global access
|
||||
registerCodeMirrorView(view.value.dom, view.value)
|
||||
|
||||
options.onInit?.(view.value)
|
||||
}
|
||||
|
||||
|
|
@ -507,10 +539,16 @@ export function useCodemirror(
|
|||
|
||||
watch(el, () => {
|
||||
if (el.value) {
|
||||
if (view.value) view.value.destroy()
|
||||
if (view.value) {
|
||||
unregisterCodeMirrorView(view.value.dom)
|
||||
view.value.destroy()
|
||||
}
|
||||
initView(el.value)
|
||||
} else {
|
||||
view.value?.destroy()
|
||||
if (view.value) {
|
||||
unregisterCodeMirrorView(view.value.dom)
|
||||
view.value.destroy()
|
||||
}
|
||||
view.value = undefined
|
||||
}
|
||||
})
|
||||
|
|
@ -521,7 +559,10 @@ export function useCodemirror(
|
|||
|
||||
watch(value, (newVal) => {
|
||||
if (newVal === undefined) {
|
||||
view.value?.destroy()
|
||||
if (view.value) {
|
||||
unregisterCodeMirrorView(view.value.dom)
|
||||
view.value.destroy()
|
||||
}
|
||||
view.value = undefined
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,37 @@ import { HoppGQLSaveContext } from "./graphql/document"
|
|||
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
|
||||
import { getKernelMode } from "@hoppscotch/kernel"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { undo, redo, toggleComment } from "@codemirror/commands"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import { isCodeMirrorEditor } from "./utils/dom"
|
||||
|
||||
// Global registry for CodeMirror views
|
||||
const codeMirrorViews = new WeakMap<Element, EditorView>()
|
||||
|
||||
/**
|
||||
* Register a CodeMirror view with its DOM element
|
||||
*/
|
||||
export function registerCodeMirrorView(element: Element, view: EditorView) {
|
||||
codeMirrorViews.set(element, view)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a CodeMirror view
|
||||
*/
|
||||
export function unregisterCodeMirrorView(element: Element) {
|
||||
codeMirrorViews.delete(element)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CodeMirror EditorView instance from a DOM element
|
||||
*/
|
||||
function getCodeMirrorView(element: Element): EditorView | null {
|
||||
const editorElement = element.closest(".cm-editor")
|
||||
if (editorElement) {
|
||||
return codeMirrorViews.get(editorElement) || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export type HoppAction =
|
||||
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
|
||||
|
|
@ -79,6 +110,9 @@ export type HoppAction =
|
|||
| "user.login" // Login to Hoppscotch
|
||||
| "user.logout" // Log out of Hoppscotch
|
||||
| "editor.format" // Format editor content
|
||||
| "editor.undo" // Undo editor content
|
||||
| "editor.redo" // Redo editor content
|
||||
| "editor.comment-toggle" // Toggle comment in editor
|
||||
| "modals.team.delete" // Delete team
|
||||
| "workspace.switch" // Switch workspace
|
||||
| "rest.request.open" // Open REST request
|
||||
|
|
@ -355,4 +389,35 @@ function setupCoreActionHandlers() {
|
|||
})
|
||||
}
|
||||
|
||||
// Editor action handlers
|
||||
bindAction("editor.undo", () => {
|
||||
const activeElement = document.activeElement
|
||||
if (activeElement && isCodeMirrorEditor(activeElement)) {
|
||||
const editorView = getCodeMirrorView(activeElement)
|
||||
if (editorView) {
|
||||
undo(editorView)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
bindAction("editor.redo", () => {
|
||||
const activeElement = document.activeElement
|
||||
if (activeElement && isCodeMirrorEditor(activeElement)) {
|
||||
const editorView = getCodeMirrorView(activeElement)
|
||||
if (editorView) {
|
||||
redo(editorView)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
bindAction("editor.comment-toggle", () => {
|
||||
const activeElement = document.activeElement
|
||||
if (activeElement && isCodeMirrorEditor(activeElement)) {
|
||||
const editorView = getCodeMirrorView(activeElement)
|
||||
if (editorView) {
|
||||
toggleComment(editorView)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setupCoreActionHandlers()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { onBeforeUnmount, onMounted } from "vue"
|
||||
import { HoppActionWithOptionalArgs, invokeAction } from "./actions"
|
||||
import { isAppleDevice } from "./platformutils"
|
||||
import { isCodeMirrorEditor, isDOMElement, isTypableElement } from "./utils/dom"
|
||||
import {
|
||||
isCodeMirrorEditor,
|
||||
isDOMElement,
|
||||
isInShortcutsFlyout,
|
||||
isMonacoEditor,
|
||||
isTypableElement,
|
||||
} from "./utils/dom"
|
||||
import { getKernelMode } from "@hoppscotch/kernel"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
|
||||
|
|
@ -63,8 +69,9 @@ const baseBindings: {
|
|||
"alt-u": "request.method.put",
|
||||
"alt-x": "request.method.delete",
|
||||
"ctrl-k": "modals.search.toggle",
|
||||
"ctrl-/": "flyouts.keybinds.toggle",
|
||||
"ctrl-/": "editor.comment-toggle",
|
||||
"shift-/": "modals.support.toggle",
|
||||
"ctrl-shift-/": "flyouts.keybinds.toggle",
|
||||
"ctrl-m": "modals.share.toggle",
|
||||
"alt-r": "navigation.jump.rest",
|
||||
"alt-q": "navigation.jump.graphql",
|
||||
|
|
@ -77,10 +84,19 @@ const baseBindings: {
|
|||
"ctrl-.": "response.copy",
|
||||
"ctrl-e": "response.save-as-example",
|
||||
"ctrl-shift-l": "editor.format",
|
||||
"ctrl-z": "editor.undo",
|
||||
"ctrl-y": "editor.redo",
|
||||
"ctrl-delete": "response.erase",
|
||||
"ctrl-backspace": "response.erase",
|
||||
}
|
||||
|
||||
// Web-only bindings
|
||||
const webBindings: {
|
||||
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
|
||||
} = {
|
||||
"ctrl-d": "tab.close-current",
|
||||
}
|
||||
|
||||
// Desktop-only bindings
|
||||
const desktopBindings: {
|
||||
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
|
||||
|
|
@ -107,7 +123,10 @@ function getActiveBindings(): typeof baseBindings {
|
|||
}
|
||||
}
|
||||
|
||||
return baseBindings
|
||||
return {
|
||||
...baseBindings,
|
||||
...webBindings,
|
||||
}
|
||||
}
|
||||
|
||||
export const bindings = getActiveBindings()
|
||||
|
|
@ -119,7 +138,8 @@ export const bindings = getActiveBindings()
|
|||
*/
|
||||
export function hookKeybindingsListener() {
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
// Use capture phase to intercept events before browser handles them
|
||||
document.addEventListener("keydown", handleKeyDown, true)
|
||||
|
||||
// Listen for Tauri events (desktop only)
|
||||
if (getKernelMode() === "desktop") {
|
||||
|
|
@ -138,7 +158,7 @@ export function hookKeybindingsListener() {
|
|||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
document.removeEventListener("keydown", handleKeyDown, true)
|
||||
|
||||
if (unlistenTauriEvent) {
|
||||
unlistenTauriEvent()
|
||||
|
|
@ -156,6 +176,57 @@ function handleKeyDown(ev: KeyboardEvent) {
|
|||
|
||||
const activeBindings = getActiveBindings()
|
||||
const boundAction = activeBindings[binding]
|
||||
|
||||
// Special handling for Ctrl+D (tab close for web browsers)
|
||||
if (binding === "ctrl-d" && boundAction) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
ev.stopImmediatePropagation()
|
||||
|
||||
if (boundAction) {
|
||||
invokeAction(boundAction, undefined, "keypress")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for undo/redo - let CodeMirror and Monaco handle these in editors
|
||||
if (binding === "ctrl-z" || binding === "ctrl-y") {
|
||||
const target = ev.target
|
||||
if (
|
||||
isDOMElement(target) &&
|
||||
(isCodeMirrorEditor(target) ||
|
||||
isMonacoEditor(target) ||
|
||||
isTypableElement(target))
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for comment toggle - let CodeMirror and Monaco handle this in editors
|
||||
if (binding === "ctrl-/") {
|
||||
const target = ev.target
|
||||
|
||||
if (!isDOMElement(target)) return
|
||||
|
||||
// Let editors handle it normally
|
||||
if (isCodeMirrorEditor(target) || isMonacoEditor(target)) return
|
||||
|
||||
// If inside shortcuts flyout, always toggle it (even if focused on search input)
|
||||
// If not in editor or input, fall back to keybinds flyout
|
||||
const shouldToggle =
|
||||
isInShortcutsFlyout(target) || !isTypableElement(target)
|
||||
|
||||
if (shouldToggle) {
|
||||
invokeAction("flyouts.keybinds.toggle", undefined, "keypress")
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// If in a normal input field, let browser handle it
|
||||
return
|
||||
}
|
||||
|
||||
// If no action is bound, do nothing
|
||||
if (!boundAction) return
|
||||
|
||||
ev.preventDefault()
|
||||
|
|
@ -196,11 +267,11 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
|
|||
return null
|
||||
}
|
||||
|
||||
// Restrict alt+up and alt+down when the target is a codemirror editor
|
||||
// Restrict alt+up and alt+down when the target is a CodeMirror or Monaco editor
|
||||
if (
|
||||
modifierKey === "alt" &&
|
||||
(key === "up" || key === "down") &&
|
||||
isCodeMirrorEditor(target)
|
||||
(isCodeMirrorEditor(target) || isMonacoEditor(target))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,21 @@ export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
|
|||
keys: ["ESC"],
|
||||
section: t("shortcut.general.title"),
|
||||
},
|
||||
{
|
||||
label: t("shortcut.general.undo"),
|
||||
keys: [getPlatformSpecialKey(), "Z"],
|
||||
section: t("shortcut.general.title"),
|
||||
},
|
||||
{
|
||||
label: t("shortcut.general.redo"),
|
||||
keys: [getPlatformSpecialKey(), "Y"],
|
||||
section: t("shortcut.general.title"),
|
||||
},
|
||||
{
|
||||
label: t("shortcut.general.comment_uncomment"),
|
||||
keys: [getPlatformSpecialKey(), "/"],
|
||||
section: t("shortcut.general.title"),
|
||||
},
|
||||
|
||||
// Request
|
||||
{
|
||||
|
|
@ -148,6 +163,15 @@ export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
|
|||
},
|
||||
]
|
||||
|
||||
// Web-only shortcuts
|
||||
const webShortcuts: ShortcutDef[] = [
|
||||
{
|
||||
label: t("shortcut.general.close_tab"),
|
||||
keys: [getPlatformSpecialKey(), "D"],
|
||||
section: t("shortcut.general.title"),
|
||||
},
|
||||
]
|
||||
|
||||
// Desktop-only shortcuts
|
||||
const desktopShortcuts: ShortcutDef[] = [
|
||||
{
|
||||
|
|
@ -182,6 +206,9 @@ export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
|
|||
},
|
||||
]
|
||||
|
||||
// Return base shortcuts + desktop shortcuts only if in desktop mode
|
||||
return isDesktop ? [...baseShortcuts, ...desktopShortcuts] : baseShortcuts
|
||||
// Return base shortcuts + platform-specific shortcuts
|
||||
if (isDesktop) {
|
||||
return [...baseShortcuts, ...desktopShortcuts]
|
||||
}
|
||||
return [...baseShortcuts, ...webShortcuts]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,3 +27,27 @@ export function isCodeMirrorEditor(el: EventTarget | null): boolean {
|
|||
if (!(el instanceof HTMLElement)) return false
|
||||
return el.closest(".cm-editor") !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an element is a Monaco editor.
|
||||
* @param el The element to check. If this is not an HTMLElement, the function will return false.
|
||||
* @returns True if the element is a Monaco editor, false otherwise.
|
||||
*/
|
||||
export function isMonacoEditor(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
return el.closest(".monaco-editor") !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an element is inside the shortcuts flyout panel.
|
||||
* @param el The element to check. If this is not an HTMLElement, the function will return false.
|
||||
* @returns True if the element is inside the shortcuts flyout, false otherwise.
|
||||
*/
|
||||
export function isInShortcutsFlyout(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
|
||||
const slideOver = el.closest("[data-shortcuts-flyout]")
|
||||
if (slideOver) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue