diff --git a/src/commands/CustomCommandChatModal.tsx b/src/commands/CustomCommandChatModal.tsx index b5556779..554cd67c 100644 --- a/src/commands/CustomCommandChatModal.tsx +++ b/src/commands/CustomCommandChatModal.tsx @@ -13,7 +13,7 @@ import { computeVerticalPlacement } from "@/utils/panelPlacement"; import { computeSelectionAnchors } from "@/utils/selectionAnchors"; import type { EditorView } from "@codemirror/view"; import { PenLine } from "lucide-react"; -import { App, Component, MarkdownRenderer, Notice, MarkdownView } from "obsidian"; +import { App, Component, MarkdownRenderer, Notice, MarkdownView, Scope } from "obsidian"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { preprocessAIResponse } from "@/utils/markdownPreprocess"; import { createRoot, Root } from "react-dom/client"; @@ -486,6 +486,7 @@ export class CustomCommandChatModal { private container: HTMLElement | null = null; private highlightView: EditorView | null = null; private replaceGuard: ReplaceGuard | null = null; + private scope: Scope | null = null; constructor( private app: App, @@ -646,6 +647,14 @@ export class CustomCommandChatModal { } open() { + // Reason: Push a Scope so user-bound global Cmd/Ctrl+Enter hotkeys don't fire + // while this modal is open. The Scope handlers return true to consume the event; + // the React onKeyDown inside MenuCommandModal does the actual Replace/Insert. + this.scope = new Scope(); + this.scope.register(["Mod"], "Enter", () => true); + this.scope.register(["Mod", "Shift"], "Enter", () => true); + this.app.keymap.pushScope(this.scope); + // Reason: Capture activeView once at open time to ensure document/window/editor // all reference the same view. Avoids subtle inconsistencies if the user switches // tabs between resolveDocument() and the selection snapshot below. @@ -732,6 +741,10 @@ export class CustomCommandChatModal { } close() { + if (this.scope) { + this.app.keymap.popScope(this.scope); + this.scope = null; + } // Hide selection highlight if (this.highlightView) { SelectionHighlight.hide(this.highlightView); diff --git a/src/components/command-ui/action-buttons.tsx b/src/components/command-ui/action-buttons.tsx index e4be2a8f..4be9c8c3 100644 --- a/src/components/command-ui/action-buttons.tsx +++ b/src/components/command-ui/action-buttons.tsx @@ -1,10 +1,41 @@ import * as React from "react"; import { Platform } from "obsidian"; +import { ArrowBigUp, Command, CornerDownLeft } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; type ActionState = "idle" | "loading" | "result"; +const ICON_CLS = "tw-size-3"; + +const ReplaceShortcutHint = () => + Platform.isMacOS ? ( + + + + + ) : ( + + Ctrl + + + ); + +const InsertShortcutHint = () => + Platform.isMacOS ? ( + + + + + + ) : ( + + Ctrl + + + + ); + interface ActionButtonsProps { state: ActionState; onStop?: () => void; @@ -58,6 +89,7 @@ export function ActionButtons({ title={`Insert below selection (${Platform.isMacOS ? "⌘" : "Ctrl"}+Shift+Enter)`} > Insert + Replace + > )} diff --git a/src/components/command-ui/menu-command-modal.tsx b/src/components/command-ui/menu-command-modal.tsx index 98c93df8..fa887b9b 100644 --- a/src/components/command-ui/menu-command-modal.tsx +++ b/src/components/command-ui/menu-command-modal.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { useEffect, useRef } from "react"; import { Send } from "lucide-react"; import { Platform } from "obsidian"; import { DraggableModal } from "./draggable-modal"; @@ -107,46 +106,24 @@ export function MenuCommandModal({ const isEditable = contentState.type === "result" && !contentState.isStreaming && !!onEditableContentChange; - // Ref to an element inside this modal, used to resolve ownerDocument and scope shortcuts - const innerRef = useRef(null); + // Keyboard shortcuts: Ctrl/Cmd+Enter → Replace, Ctrl/Cmd+Shift+Enter → Insert. + // Attached as a React onKeyDown on the modal subtree so it runs before any global + // capture-phase keymap (Obsidian hotkeys, other plugins) can consume the event. + const handleKeyDown = (e: React.KeyboardEvent) => { + if (actionState !== "result") return; + if (e.nativeEvent.isComposing) return; - // Keyboard shortcuts: Ctrl+Enter → Replace, Ctrl+Shift+Enter → Insert - // Mirrors DraggableModal's Escape handling: uses ownerDocument for popout windows, - // and scopes to the focused modal to avoid firing across multiple open modals. - useEffect(() => { - if (!open) return; + const modKey = Platform.isMacOS ? e.metaKey : e.ctrlKey; + if (e.key !== "Enter" || !modKey) return; - const modalEl = innerRef.current?.closest('[data-copilot-draggable-modal="true"]'); - const ownerDocument = modalEl?.doc ?? activeDocument; - - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle when result is ready and not busy - if (actionState !== "result") return; - - const modKey = Platform.isMacOS ? e.metaKey : e.ctrlKey; - if (e.key !== "Enter" || !modKey) return; - - // Scope to focused modal: only handle if active element is inside this modal. - // Avoid `instanceof Element` — it fails cross-realm (popout windows have their own Element). - const activeEl = ownerDocument.activeElement; - if (modalEl && (!activeEl || !modalEl.contains(activeEl))) return; - - if (e.shiftKey) { - // Ctrl/Cmd + Shift + Enter → Insert - e.preventDefault(); - e.stopPropagation(); - onInsert?.(); - } else { - // Ctrl/Cmd + Enter → Replace - e.preventDefault(); - e.stopPropagation(); - onReplace?.(); - } - }; - - ownerDocument.addEventListener("keydown", handleKeyDown); - return () => ownerDocument.removeEventListener("keydown", handleKeyDown); - }, [open, actionState, onInsert, onReplace]); + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) { + onInsert?.(); + } else { + onReplace?.(); + } + }; // Conditionally show ContentArea based on hideContentAreaOnIdle prop const showContentArea = hideContentAreaOnIdle ? contentState.type !== "idle" : true; @@ -162,95 +139,95 @@ export function MenuCommandModal({ anchorBottom={anchorBottom} resizable={resizable} minHeight={resizable ? dynamicMinHeight : undefined} + width="min(620px, 92vw)" closeOnEscapeFromOutside > - {/* Command Label - flex-none */} - - - {/* Content Area - flex-1, editable when result is ready */} - {showContentArea && ( - + {/* Command Label - flex-none */} + - )} - {/* Follow-up Input - flex-none, allow typing during streaming but disable submit */} - { - if (!isBusy) onFollowUpSubmit(); - }} - onClear={() => onFollowUpChange("")} - placeholder="Enter follow-up instructions..." - className={!showContentArea ? "tw-mt-auto" : undefined} - hint={isBusy ? "Generating..." : undefined} - autoFocus - /> - - {/* Bottom Toolbar - flex-none */} - - - - {onIncludeNoteContextChange && ( - - onIncludeNoteContextChange(!!checked)} - className="tw-size-3.5" - disabled={isBusy} - /> - - Note - - - - )} - - - {/* Send button: shown when content area is hidden (idle Quick Command mode) */} - {hideContentAreaOnIdle && actionState === "idle" && ( - { + if (!isBusy) onFollowUpSubmit(); + }} + onClear={() => onFollowUpChange("")} + placeholder="Enter follow-up instructions..." + className={!showContentArea ? "tw-mt-auto" : undefined} + hint={isBusy ? "Generating..." : undefined} + autoFocus + /> + + {/* Bottom Toolbar - flex-none */} + + + - - Send - - )} - + variant="ghost" + value={selectedModel} + onChange={onSelectModel} + disabled={isBusy} + /> + {onIncludeNoteContextChange && ( + + onIncludeNoteContextChange(!!checked)} + className="tw-size-3.5" + disabled={isBusy} + /> + + Note + + + + )} + + + {/* Send button: shown when content area is hidden (idle Quick Command mode) */} + {hideContentAreaOnIdle && actionState === "idle" && ( + + + Send + + )} + +