mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
fix(quick-command): restore Cmd+Enter shortcut and shortcut hint icons (#2442)
* fix(quick-command): restore Cmd+Enter shortcut and shortcut hint icons The Quick Ask/Command revamp (#2139) replaced an Obsidian Modal with a standalone DraggableModal. Two regressions slipped through: - The new modal didn't push a Scope, so any user-bound global Cmd+Enter hotkey would fire instead of Replace. Push a Scope in `CustomCommandChatModal.open()` that registers Mod+Enter and Mod+Shift+Enter as no-op consumers (the React onKeyDown does the real work) and pop it in close(), matching what `Modal` did automatically. - The Insert/Replace buttons lost the inline ⌘↩ / ⌘⇧↩ glyph hints that existed in the pre-revamp modal. Re-add them with Lucide icons (`Command`, `ArrowBigUp`, `CornerDownLeft`) in `action-buttons.tsx`, with a Ctrl-text fallback on non-mac. Also move the shortcut handler from a document-level keydown listener back to a React `onKeyDown` on the modal subtree — same pattern as the original implementation — so it can't be pre-empted by capture-phase listeners. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(quick-command): widen dialog so footer fits Note checkbox + shortcut glyphs Quick Command mode renders an extra "Note" context checkbox alongside the model selector and action buttons. With the restored ⌘⇧↩ / ⌘↩ glyphs on Insert/Replace, the default 500px footer overflows. Bump the initial width to `min(620px, 92vw)` only when `hideContentAreaOnIdle` (the Quick Command signal) is set; Custom Commands keep the original width. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(quick-command): widen modal unconditionally instead of gating on mode Apply the wider footer to Custom Commands too — same overflow potential once the Insert/Replace glyph hints are present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
020e24507c
commit
c5a990e48f
3 changed files with 146 additions and 123 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<span className="tw-ml-1 tw-inline-flex tw-items-center tw-gap-0.5 tw-opacity-80">
|
||||
<Command className={ICON_CLS} />
|
||||
<CornerDownLeft className={ICON_CLS} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="tw-ml-1 tw-inline-flex tw-items-center tw-gap-0.5 tw-text-xs tw-opacity-80">
|
||||
Ctrl
|
||||
<CornerDownLeft className={ICON_CLS} />
|
||||
</span>
|
||||
);
|
||||
|
||||
const InsertShortcutHint = () =>
|
||||
Platform.isMacOS ? (
|
||||
<span className="tw-ml-1 tw-inline-flex tw-items-center tw-gap-0.5 tw-opacity-80">
|
||||
<Command className={ICON_CLS} />
|
||||
<ArrowBigUp className={ICON_CLS} />
|
||||
<CornerDownLeft className={ICON_CLS} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="tw-ml-1 tw-inline-flex tw-items-center tw-gap-0.5 tw-text-xs tw-opacity-80">
|
||||
Ctrl
|
||||
<ArrowBigUp className={ICON_CLS} />
|
||||
<CornerDownLeft className={ICON_CLS} />
|
||||
</span>
|
||||
);
|
||||
|
||||
interface ActionButtonsProps {
|
||||
state: ActionState;
|
||||
onStop?: () => void;
|
||||
|
|
@ -58,6 +89,7 @@ export function ActionButtons({
|
|||
title={`Insert below selection (${Platform.isMacOS ? "⌘" : "Ctrl"}+Shift+Enter)`}
|
||||
>
|
||||
Insert
|
||||
<InsertShortcutHint />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -65,6 +97,7 @@ export function ActionButtons({
|
|||
title={`Replace selection (${Platform.isMacOS ? "⌘" : "Ctrl"}+Enter)`}
|
||||
>
|
||||
Replace
|
||||
<ReplaceShortcutHint />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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<HTMLElement>('[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 */}
|
||||
<CommandLabel
|
||||
icon={commandIcon}
|
||||
label={commandLabel}
|
||||
className="tw-border-b tw-border-border"
|
||||
/>
|
||||
|
||||
{/* Content Area - flex-1, editable when result is ready */}
|
||||
{showContentArea && (
|
||||
<ContentArea
|
||||
state={contentState}
|
||||
editable={isEditable}
|
||||
value={editableContent}
|
||||
onChange={onEditableContentChange}
|
||||
disableAutoGrow={resizable}
|
||||
minHeight={resizable ? "0px" : undefined}
|
||||
renderMarkdown={renderMarkdown}
|
||||
<div onKeyDown={handleKeyDown} className="tw-flex tw-min-h-0 tw-flex-1 tw-flex-col">
|
||||
{/* Command Label - flex-none */}
|
||||
<CommandLabel
|
||||
icon={commandIcon}
|
||||
label={commandLabel}
|
||||
className="tw-border-b tw-border-border"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Follow-up Input - flex-none, allow typing during streaming but disable submit */}
|
||||
<FollowUpInput
|
||||
value={followUpValue}
|
||||
onChange={onFollowUpChange}
|
||||
onSubmit={() => {
|
||||
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 */}
|
||||
<div
|
||||
ref={innerRef}
|
||||
className="tw-flex tw-flex-none tw-items-center tw-justify-between tw-border-t tw-border-border tw-px-4 tw-py-3"
|
||||
>
|
||||
<div className="tw-flex tw-items-center tw-gap-3">
|
||||
<ModelSelector
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
value={selectedModel}
|
||||
onChange={onSelectModel}
|
||||
disabled={isBusy}
|
||||
{/* Content Area - flex-1, editable when result is ready */}
|
||||
{showContentArea && (
|
||||
<ContentArea
|
||||
state={contentState}
|
||||
editable={isEditable}
|
||||
value={editableContent}
|
||||
onChange={onEditableContentChange}
|
||||
disableAutoGrow={resizable}
|
||||
minHeight={resizable ? "0px" : undefined}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
{onIncludeNoteContextChange && (
|
||||
<div className="tw-flex tw-items-center tw-gap-1.5">
|
||||
<Checkbox
|
||||
id="menuCommandIncludeContext"
|
||||
checked={includeNoteContext}
|
||||
onCheckedChange={(checked) => onIncludeNoteContextChange(!!checked)}
|
||||
className="tw-size-3.5"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<label
|
||||
htmlFor="menuCommandIncludeContext"
|
||||
className="tw-cursor-pointer tw-text-xs tw-text-muted"
|
||||
>
|
||||
Note
|
||||
</label>
|
||||
<HelpTooltip content="Include the active note's content as context" side="top" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
{/* Send button: shown when content area is hidden (idle Quick Command mode) */}
|
||||
{hideContentAreaOnIdle && actionState === "idle" && (
|
||||
<Button
|
||||
variant="default"
|
||||
)}
|
||||
|
||||
{/* Follow-up Input - flex-none, allow typing during streaming but disable submit */}
|
||||
<FollowUpInput
|
||||
value={followUpValue}
|
||||
onChange={onFollowUpChange}
|
||||
onSubmit={() => {
|
||||
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 */}
|
||||
<div className="tw-flex tw-flex-none tw-items-center tw-justify-between tw-border-t tw-border-border tw-px-4 tw-py-3">
|
||||
<div className="tw-flex tw-items-center tw-gap-3">
|
||||
<ModelSelector
|
||||
size="sm"
|
||||
onClick={onFollowUpSubmit}
|
||||
disabled={!followUpValue.trim() || isBusy}
|
||||
title="Send message"
|
||||
>
|
||||
<Send className="tw-mr-1 tw-size-4" />
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
<ActionButtons
|
||||
state={actionState}
|
||||
onStop={onStop}
|
||||
onCopy={onCopy}
|
||||
onInsert={onInsert}
|
||||
onReplace={onReplace}
|
||||
/>
|
||||
variant="ghost"
|
||||
value={selectedModel}
|
||||
onChange={onSelectModel}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{onIncludeNoteContextChange && (
|
||||
<div className="tw-flex tw-items-center tw-gap-1.5">
|
||||
<Checkbox
|
||||
id="menuCommandIncludeContext"
|
||||
checked={includeNoteContext}
|
||||
onCheckedChange={(checked) => onIncludeNoteContextChange(!!checked)}
|
||||
className="tw-size-3.5"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<label
|
||||
htmlFor="menuCommandIncludeContext"
|
||||
className="tw-cursor-pointer tw-text-xs tw-text-muted"
|
||||
>
|
||||
Note
|
||||
</label>
|
||||
<HelpTooltip content="Include the active note's content as context" side="top" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
{/* Send button: shown when content area is hidden (idle Quick Command mode) */}
|
||||
{hideContentAreaOnIdle && actionState === "idle" && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onFollowUpSubmit}
|
||||
disabled={!followUpValue.trim() || isBusy}
|
||||
title="Send message"
|
||||
>
|
||||
<Send className="tw-mr-1 tw-size-4" />
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
<ActionButtons
|
||||
state={actionState}
|
||||
onStop={onStop}
|
||||
onCopy={onCopy}
|
||||
onInsert={onInsert}
|
||||
onReplace={onReplace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableModal>
|
||||
|
|
|
|||
Loading…
Reference in a new issue