mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
746 lines
27 KiB
TypeScript
746 lines
27 KiB
TypeScript
import { CustomModel, useModelKey } from "@/aiParams";
|
|
import { processCommandPrompt } from "@/commands/customCommandUtils";
|
|
import { MenuCommandModal, type ContentState } from "@/components/command-ui";
|
|
import {
|
|
MODAL_MIN_HEIGHT_COMPACT,
|
|
MODAL_MIN_HEIGHT_EXPANDED,
|
|
} from "@/components/command-ui/constants";
|
|
import { SelectionHighlight } from "@/editor/selectionHighlight";
|
|
import { createHighlightReplaceGuard, type ReplaceGuard } from "@/editor/replaceGuard";
|
|
import { logError } from "@/logger";
|
|
import { cleanMessageForCopy, findCustomModel, insertIntoEditor } from "@/utils";
|
|
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, Scope } from "obsidian";
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { preprocessAIResponse } from "@/utils/markdownPreprocess";
|
|
import { Root } from "react-dom/client";
|
|
import { createPluginRoot } from "@/utils/react/createPluginRoot";
|
|
import { CustomCommand } from "@/commands/type";
|
|
import { useSettingsValue, updateSetting } from "@/settings/model";
|
|
import {
|
|
useStreamingChatSession,
|
|
type StreamingChatTurnContext,
|
|
} from "@/hooks/use-streaming-chat-session";
|
|
import { ABORT_REASON } from "@/constants";
|
|
|
|
// ============================================================================
|
|
// Behavior Config - Replaces mode-based branching
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Model selection scope determines how model changes are persisted.
|
|
* - 'quick-command': Changes persist to quickCommandModelKey (shared with Quick Ask)
|
|
* - 'custom-command': Changes only affect current session (respects command-level config)
|
|
*/
|
|
export type ModelSelectionScope = "quick-command" | "custom-command";
|
|
|
|
/**
|
|
* Configuration for modal behavior.
|
|
* This replaces the mode-based branching with explicit configuration.
|
|
*/
|
|
export interface ModalBehaviorConfig {
|
|
/** Whether to auto-execute the command on open (menu mode: true, quick mode: false) */
|
|
autoExecuteOnOpen: boolean;
|
|
/** Whether to hide ContentArea when state is idle */
|
|
hideContentAreaOnIdle: boolean;
|
|
/** Transform function for first submit (e.g., append note context placeholders) */
|
|
firstSubmitTransform?: (input: string, includeNoteContext: boolean) => string;
|
|
/** Label to display in the modal header */
|
|
commandLabel: string;
|
|
/** Icon to display in the modal header (null for no icon, undefined for default) */
|
|
commandIcon?: React.ReactNode | null;
|
|
/** Whether to show the "Include note context" checkbox */
|
|
showIncludeNoteContext?: boolean;
|
|
/**
|
|
* Model selection scope - determines persistence behavior.
|
|
* - 'quick-command': Persists to quickCommandModelKey (default for Quick Command)
|
|
* - 'custom-command': Only affects current session (default for Custom Commands)
|
|
*/
|
|
modelSelectionScope?: ModelSelectionScope;
|
|
}
|
|
|
|
/**
|
|
* Resolves modal behavior defaults and caller overrides.
|
|
*/
|
|
function resolveBehaviorConfig(
|
|
command: CustomCommand,
|
|
overrides?: Partial<ModalBehaviorConfig>
|
|
): ModalBehaviorConfig {
|
|
const defaults: ModalBehaviorConfig = {
|
|
autoExecuteOnOpen: true,
|
|
hideContentAreaOnIdle: false,
|
|
commandLabel: command.title,
|
|
commandIcon: <PenLine className="tw-size-4 tw-text-muted" />,
|
|
};
|
|
|
|
return { ...defaults, ...overrides };
|
|
}
|
|
|
|
// ============================================================================
|
|
// Content Component
|
|
// ============================================================================
|
|
|
|
interface CustomCommandChatModalContentProps {
|
|
originalText: string;
|
|
command: CustomCommand;
|
|
onInsert: (message: string) => void;
|
|
onReplace: (message: string) => void;
|
|
onClose: () => void;
|
|
systemPrompt?: string;
|
|
initialPosition?: { x: number; y: number };
|
|
/** Bottom-anchor Y for "above" placement (panel grows upward) */
|
|
anchorBottom?: number;
|
|
behaviorConfig?: Partial<ModalBehaviorConfig>;
|
|
}
|
|
|
|
/**
|
|
* Content component for CustomCommandChatModal using the new MenuCommandModal.
|
|
*/
|
|
function CustomCommandChatModalContent({
|
|
originalText,
|
|
command,
|
|
onInsert,
|
|
onReplace,
|
|
onClose,
|
|
systemPrompt,
|
|
initialPosition,
|
|
anchorBottom,
|
|
behaviorConfig,
|
|
}: CustomCommandChatModalContentProps) {
|
|
// Resolve behavior configuration
|
|
const behavior = useMemo(
|
|
() => resolveBehaviorConfig(command, behaviorConfig),
|
|
[command, behaviorConfig]
|
|
);
|
|
|
|
// Prevent concurrent submissions (double-Enter / re-entrancy).
|
|
const followUpSubmitLockRef = useRef(false);
|
|
// Track mount state to avoid setState on unmounted component
|
|
const isMountedRef = useRef(true);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
isMountedRef.current = true;
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
// Reason: Create Component synchronously so renderMarkdown is available
|
|
// on the first render (child effects run before parent effects).
|
|
const obsidianComponentRef = useRef<Component | null>(null);
|
|
if (!obsidianComponentRef.current) {
|
|
const comp = new Component();
|
|
comp.load();
|
|
obsidianComponentRef.current = comp;
|
|
}
|
|
useEffect(() => {
|
|
return () => {
|
|
obsidianComponentRef.current?.unload();
|
|
obsidianComponentRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
// Reason: Snapshot file path at mount time for stable Markdown link resolution.
|
|
// Using dynamic getActiveFile() would resolve links against whichever note
|
|
// the user switches to after opening the modal.
|
|
const filePathSnapshotRef = useRef(app.workspace.getActiveFile()?.path ?? "");
|
|
|
|
/**
|
|
* Renders markdown content into a DOM element using Obsidian's MarkdownRenderer.
|
|
* Passed to ContentArea to enable preview mode for completed results.
|
|
*/
|
|
const renderMarkdown = useCallback(async (content: string, el: HTMLElement) => {
|
|
const comp = obsidianComponentRef.current;
|
|
if (!comp) return;
|
|
const preprocessed = preprocessAIResponse(content);
|
|
await MarkdownRenderer.renderMarkdown(preprocessed, el, filePathSnapshotRef.current, comp);
|
|
}, []);
|
|
|
|
// State
|
|
const [finalText, setFinalText] = useState<string>("");
|
|
const [editedText, setEditedText] = useState<string>("");
|
|
const [isLoading, setIsLoading] = useState(behavior.autoExecuteOnOpen);
|
|
const [followUpValue, setFollowUpValue] = useState("");
|
|
|
|
// Model selection
|
|
const [globalModelKey] = useModelKey();
|
|
const settings = useSettingsValue();
|
|
const modelSelectionScope = behavior.modelSelectionScope ?? "custom-command";
|
|
|
|
// Determine initial model key based on scope:
|
|
// - quick-command: Use quickCommandModelKey (shared with Quick Ask)
|
|
// - custom-command: Use command's modelKey if set, otherwise global model
|
|
const initialModelKey = useMemo(() => {
|
|
if (modelSelectionScope === "quick-command") {
|
|
// Use ?? to match QuickAskPanel behavior (empty string is valid, only null/undefined falls back)
|
|
return settings.quickCommandModelKey ?? globalModelKey;
|
|
}
|
|
// For custom-command scope, respect command-level config
|
|
// Use || here because empty string means "inherit from global"
|
|
return command.modelKey || globalModelKey;
|
|
}, [modelSelectionScope, settings.quickCommandModelKey, command.modelKey, globalModelKey]);
|
|
|
|
const [userSelectedModelKey, setUserSelectedModelKey] = useState(initialModelKey);
|
|
|
|
// Handle model change with scope-aware persistence
|
|
const handleModelChange = useCallback(
|
|
(newModelKey: string) => {
|
|
setUserSelectedModelKey(newModelKey);
|
|
// Only persist for quick-command scope (shared with Quick Ask)
|
|
if (modelSelectionScope === "quick-command") {
|
|
updateSetting("quickCommandModelKey", newModelKey);
|
|
}
|
|
// For custom-command scope, changes only affect current session
|
|
},
|
|
[modelSelectionScope]
|
|
);
|
|
|
|
// Include note context state (for Quick Command mode)
|
|
// Use local state for immediate UI updates, sync to settings on change
|
|
const [includeNoteContext, setIncludeNoteContext] = useState(
|
|
() => settings.quickCommandIncludeNoteContext
|
|
);
|
|
|
|
const handleIncludeNoteContextChange = useCallback((checked: boolean) => {
|
|
setIncludeNoteContext(checked);
|
|
updateSetting("quickCommandIncludeNoteContext", checked);
|
|
}, []);
|
|
|
|
// Safely resolve the selected model with fallback to first enabled model
|
|
const resolvedModel = useMemo((): CustomModel | null => {
|
|
try {
|
|
const model = findCustomModel(userSelectedModelKey, settings.activeModels);
|
|
// Treat disabled models as invalid selections (ModelSelector won't present them)
|
|
if (!model.enabled) {
|
|
throw new Error(`Selected model is disabled: ${userSelectedModelKey}`);
|
|
}
|
|
return model;
|
|
} catch {
|
|
// Stale model key can happen when a model is removed/renamed/disabled; don't crash the modal.
|
|
// Avoid side effects during render; notify/log in the effect below.
|
|
return settings.activeModels.find((m) => m.enabled) ?? null;
|
|
}
|
|
}, [userSelectedModelKey, settings.activeModels]);
|
|
|
|
// Compute the key for the resolved model
|
|
const resolvedModelKey = useMemo(() => {
|
|
if (!resolvedModel) return null;
|
|
return `${resolvedModel.name}|${resolvedModel.provider}`;
|
|
}, [resolvedModel]);
|
|
|
|
// Effective model key for the UI — falls back to user selection when resolution fails.
|
|
const effectiveModelKey = resolvedModelKey ?? userSelectedModelKey;
|
|
|
|
// Use shared streaming hook
|
|
const {
|
|
isStreaming,
|
|
streamingText,
|
|
runTurn,
|
|
stop: stopStreaming,
|
|
getLatestStreamingText,
|
|
} = useStreamingChatSession({
|
|
model: resolvedModel,
|
|
systemPrompt: systemPrompt || "",
|
|
excludeThinking: true,
|
|
onNoModel: () => {
|
|
new Notice("No active model is configured. Please configure a model in Copilot settings.");
|
|
setIsLoading(false);
|
|
},
|
|
onNonAbortError: (error) => {
|
|
logError("Error generating response:", error);
|
|
new Notice("Error generating response. Please try again.");
|
|
setIsLoading(false);
|
|
},
|
|
});
|
|
|
|
// Track the last input prompt for saving context on stop
|
|
const lastInputPromptRef = useRef<string>("");
|
|
|
|
// Sync editedText with finalText when finalText changes. Render-phase tracker
|
|
// preserves user edits made after the last finalText change until the next change.
|
|
const [prevFinalText, setPrevFinalText] = useState(finalText);
|
|
if (prevFinalText !== finalText) {
|
|
setPrevFinalText(finalText);
|
|
if (finalText) {
|
|
setEditedText(finalText);
|
|
}
|
|
}
|
|
|
|
// Compute content state for MenuCommandModal
|
|
const contentState: ContentState = useMemo(() => {
|
|
if (isLoading && !isStreaming && !streamingText && !finalText) {
|
|
return { type: "loading" };
|
|
}
|
|
if (isStreaming || streamingText) {
|
|
return { type: "result", text: streamingText || finalText, isStreaming };
|
|
}
|
|
if (finalText) {
|
|
return { type: "result", text: finalText, isStreaming: false };
|
|
}
|
|
return { type: "idle" };
|
|
}, [isLoading, isStreaming, streamingText, finalText]);
|
|
|
|
// Track if auto-execute has already run (prevent re-execution on model change)
|
|
const didAutoExecuteRef = useRef(false);
|
|
|
|
// Generate initial response (only for autoExecuteOnOpen mode)
|
|
useEffect(() => {
|
|
if (!behavior.autoExecuteOnOpen) return;
|
|
// Only execute once - prevent re-execution when model changes
|
|
if (didAutoExecuteRef.current) return;
|
|
didAutoExecuteRef.current = true;
|
|
|
|
let cancelled = false;
|
|
|
|
async function generateInitialResponse() {
|
|
try {
|
|
const result = await runTurn(async (ctx: StreamingChatTurnContext) => {
|
|
if (ctx.signal.aborted) return "";
|
|
const prompt = await processCommandPrompt(command.content, originalText);
|
|
lastInputPromptRef.current = prompt;
|
|
return prompt;
|
|
});
|
|
|
|
if (!cancelled && result) {
|
|
setFinalText(result);
|
|
lastInputPromptRef.current = "";
|
|
}
|
|
} catch (error) {
|
|
logError("Error in initial response:", error);
|
|
} finally {
|
|
if (!cancelled) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void generateInitialResponse();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
stopStreaming(ABORT_REASON.UNMOUNT);
|
|
};
|
|
}, [behavior.autoExecuteOnOpen, command.content, originalText, runTurn, stopStreaming]);
|
|
|
|
const handleFollowUpSubmit = async () => {
|
|
if (!followUpValue.trim()) return;
|
|
|
|
// Prevent concurrent submissions
|
|
if (followUpSubmitLockRef.current) return;
|
|
if (isLoading || isStreaming) return;
|
|
|
|
followUpSubmitLockRef.current = true;
|
|
|
|
// Clear input and previous result immediately for responsive UI
|
|
const inputValue = followUpValue;
|
|
setFollowUpValue("");
|
|
setFinalText("");
|
|
setEditedText("");
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
|
|
const result = await runTurn(async (ctx: StreamingChatTurnContext) => {
|
|
if (ctx.signal.aborted) return "";
|
|
|
|
// Use ctx.isFirstTurn to determine if this is the first turn
|
|
const isFirstTurn = ctx.isFirstTurn;
|
|
|
|
// Apply first submit transform if provided (e.g., append note context placeholders)
|
|
let rawInput = inputValue;
|
|
if (isFirstTurn && behavior.firstSubmitTransform) {
|
|
rawInput = behavior.firstSubmitTransform(rawInput, includeNoteContext);
|
|
}
|
|
|
|
// Process prompt (expand placeholders)
|
|
const prompt = await processCommandPrompt(rawInput, originalText, !isFirstTurn);
|
|
lastInputPromptRef.current = prompt;
|
|
return prompt;
|
|
});
|
|
|
|
// Guard against unmount during async operation
|
|
if (!isMountedRef.current) return;
|
|
|
|
if (result) {
|
|
setFinalText(result);
|
|
lastInputPromptRef.current = "";
|
|
}
|
|
} catch (error) {
|
|
// Handle errors in follow-up submit
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
// Silently ignore abort errors
|
|
} else {
|
|
logError("Error in follow-up submit:", error);
|
|
if (isMountedRef.current) {
|
|
new Notice("Failed to send message. Please try again.");
|
|
}
|
|
}
|
|
} finally {
|
|
if (isMountedRef.current) {
|
|
setIsLoading(false);
|
|
}
|
|
followUpSubmitLockRef.current = false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle user stop action.
|
|
* The shared hook handles memory persistence on USER_STOPPED automatically.
|
|
* We only need to capture the latest text for UI display.
|
|
*/
|
|
const handleStop = useCallback(() => {
|
|
// Reason: Use getLatestStreamingText() to bypass RAF throttle,
|
|
// ensuring we capture the most recent streamed content.
|
|
const latestStreamedText = getLatestStreamingText().trim();
|
|
|
|
stopStreaming(ABORT_REASON.USER_STOPPED);
|
|
|
|
if (latestStreamedText) {
|
|
setFinalText(latestStreamedText);
|
|
}
|
|
lastInputPromptRef.current = "";
|
|
}, [stopStreaming, getLatestStreamingText]);
|
|
|
|
const handleCopy = useCallback(async () => {
|
|
const text = editedText || finalText || streamingText;
|
|
if (!text) return;
|
|
const cleaned = cleanMessageForCopy(text);
|
|
try {
|
|
await navigator.clipboard.writeText(cleaned);
|
|
new Notice("Copied to clipboard");
|
|
} catch {
|
|
new Notice("Failed to copy to clipboard");
|
|
}
|
|
}, [editedText, finalText, streamingText]);
|
|
|
|
const handleInsert = () => {
|
|
const text = editedText || finalText || streamingText;
|
|
if (text) onInsert(text);
|
|
};
|
|
|
|
const handleReplace = () => {
|
|
const text = editedText || finalText || streamingText;
|
|
if (text) onReplace(text);
|
|
};
|
|
|
|
return (
|
|
<MenuCommandModal
|
|
open={true}
|
|
onClose={onClose}
|
|
commandIcon={behavior.commandIcon}
|
|
commandLabel={behavior.commandLabel}
|
|
contentState={contentState}
|
|
editableContent={editedText}
|
|
onEditableContentChange={setEditedText}
|
|
followUpValue={followUpValue}
|
|
onFollowUpChange={setFollowUpValue}
|
|
onFollowUpSubmit={handleFollowUpSubmit}
|
|
selectedModel={effectiveModelKey}
|
|
onSelectModel={handleModelChange}
|
|
onStop={handleStop}
|
|
onCopy={handleCopy}
|
|
onInsert={handleInsert}
|
|
onReplace={handleReplace}
|
|
initialPosition={initialPosition}
|
|
anchorBottom={anchorBottom}
|
|
resizable
|
|
hideContentAreaOnIdle={behavior.hideContentAreaOnIdle}
|
|
includeNoteContext={behavior.showIncludeNoteContext ? includeNoteContext : undefined}
|
|
onIncludeNoteContextChange={
|
|
behavior.showIncludeNoteContext ? handleIncludeNoteContextChange : undefined
|
|
}
|
|
renderMarkdown={renderMarkdown}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Modal Class
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Standalone floating modal for CustomCommandChat (not using Obsidian Modal).
|
|
* This avoids the overlay issue caused by Obsidian's Modal class.
|
|
* Positions itself near the cursor/selection like Quick Ask.
|
|
*/
|
|
export class CustomCommandChatModal {
|
|
private root: Root | null = null;
|
|
private container: HTMLElement | null = null;
|
|
private highlightView: EditorView | null = null;
|
|
private replaceGuard: ReplaceGuard | null = null;
|
|
private scope: Scope | null = null;
|
|
|
|
constructor(
|
|
private app: App,
|
|
private configs: {
|
|
selectedText: string;
|
|
command: CustomCommand;
|
|
systemPrompt?: string;
|
|
behaviorConfig?: Partial<ModalBehaviorConfig>;
|
|
}
|
|
) {}
|
|
|
|
/**
|
|
* Resolve the correct window for a given view (or the active view).
|
|
* Reason: In Obsidian popout windows, the global `window` refers to the main window,
|
|
* but the editor lives in a different window. Using ownerDocument.defaultView ensures
|
|
* the modal is positioned relative to the correct window.
|
|
*/
|
|
private resolveWindow(view?: MarkdownView | null): Window {
|
|
return view?.containerEl?.win ?? window;
|
|
}
|
|
|
|
/**
|
|
* Resolve the correct document for a given view (or the active view).
|
|
* Reason: Same multi-window concern as resolveWindow — the modal container must be
|
|
* appended to the document that owns the triggering view.
|
|
*/
|
|
private resolveDocument(view?: MarkdownView | null): Document {
|
|
return view?.containerEl?.doc ?? activeDocument;
|
|
}
|
|
|
|
/**
|
|
* Calculate initial position based on cursor/selection in the editor.
|
|
* Strategy: vertical-first (determines placement), then horizontal (depends on placement).
|
|
* - Multi-line selections always center horizontally on the editor.
|
|
* - Space checks use scrollRect (editor visible area), not window.
|
|
* - Horizontal clamp to scrollRect first, then viewport as safety net.
|
|
*/
|
|
private getInitialPosition(activeView: MarkdownView | null): {
|
|
x: number;
|
|
y: number;
|
|
anchorBottom?: number;
|
|
} {
|
|
const win = this.resolveWindow(activeView);
|
|
const panelWidth = Math.min(500, win.innerWidth * 0.9);
|
|
// Reason: The actual initial panel height depends on whether ContentArea is shown.
|
|
// Quick Command starts idle with no ContentArea (compact).
|
|
// Custom Commands always show ContentArea (expanded).
|
|
// Using the correct height prevents gaps (above) or overlaps (below).
|
|
const hideContentAreaOnIdle = this.configs.behaviorConfig?.hideContentAreaOnIdle ?? false;
|
|
const panelHeight = hideContentAreaOnIdle
|
|
? MODAL_MIN_HEIGHT_COMPACT
|
|
: MODAL_MIN_HEIGHT_EXPANDED;
|
|
const margin = 12;
|
|
const gap = 6;
|
|
|
|
// Fallback: center on screen
|
|
const fallback = {
|
|
x: Math.max(margin, (win.innerWidth - panelWidth) / 2),
|
|
y: Math.max(margin, (win.innerHeight - panelHeight) / 2),
|
|
};
|
|
|
|
if (!activeView?.editor?.cm) {
|
|
return fallback;
|
|
}
|
|
|
|
const view = activeView.editor.cm;
|
|
const selection = view.state.selection.main;
|
|
const isCursor = selection.empty;
|
|
|
|
// Reason: Dual-anchor model — compute separate top/bottom anchor positions.
|
|
// bottomPos is used for "place below selection", topPos for "place above selection".
|
|
// The shared utility handles the line-start trap correction.
|
|
const anchors = computeSelectionAnchors(selection, view.state.doc);
|
|
|
|
// Get coordinates for all anchor positions
|
|
const focusCoords = view.coordsAtPos(anchors.focusPos);
|
|
const bottomCoords = view.coordsAtPos(anchors.bottomPos);
|
|
const topCoords = view.coordsAtPos(anchors.topPos);
|
|
|
|
if (!focusCoords && !bottomCoords && !topCoords) {
|
|
return fallback;
|
|
}
|
|
|
|
// Check visibility of each anchor against the editor's visible scroll area
|
|
const scrollRect = view.scrollDOM.getBoundingClientRect();
|
|
const isVisible = (coords: { top: number; bottom: number; left: number; right: number }) =>
|
|
coords.bottom >= scrollRect.top &&
|
|
coords.top <= scrollRect.bottom &&
|
|
coords.right >= scrollRect.left &&
|
|
coords.left <= scrollRect.right;
|
|
|
|
const visibleFocus = focusCoords && isVisible(focusCoords) ? focusCoords : null;
|
|
const visibleBottom = bottomCoords && isVisible(bottomCoords) ? bottomCoords : null;
|
|
const visibleTop = topCoords && isVisible(topCoords) ? topCoords : null;
|
|
|
|
if (!visibleFocus && !visibleBottom && !visibleTop) {
|
|
return fallback;
|
|
}
|
|
|
|
// --- Visual multi-line detection ---
|
|
// Reason: Using visual line height comparison instead of logical line numbers
|
|
// correctly handles soft-wrapped lines that span multiple visual rows.
|
|
const caretHeight = Math.min(
|
|
(topCoords?.bottom ?? 0) - (topCoords?.top ?? 0),
|
|
(bottomCoords?.bottom ?? 0) - (bottomCoords?.top ?? 0)
|
|
);
|
|
const isVisualMultiLine =
|
|
!isCursor &&
|
|
topCoords &&
|
|
bottomCoords &&
|
|
Math.abs(topCoords.top - bottomCoords.top) > Math.max(caretHeight / 2, 2);
|
|
|
|
// --- Vertical positioning (decides placement first) ---
|
|
// Reason: Extracted to a pure helper (computeVerticalPlacement) so the
|
|
// branching logic is testable without DOM/CodeMirror dependencies.
|
|
const { top: rawTop, anchorBottomY } = computeVerticalPlacement({
|
|
scrollRect,
|
|
visibleBottom,
|
|
visibleTop,
|
|
panelHeight,
|
|
margin,
|
|
gap,
|
|
viewportHeight: win.innerHeight,
|
|
});
|
|
const top = rawTop;
|
|
|
|
// --- Horizontal positioning (depends on vertical placement) ---
|
|
let left: number;
|
|
|
|
if (isCursor) {
|
|
// Cursor: anchor at cursor position
|
|
const anchor = visibleFocus ?? visibleBottom ?? visibleTop!;
|
|
left = anchor.left;
|
|
} else if (!isVisualMultiLine) {
|
|
// Visual single-line: center panel on the selection span
|
|
// Reason: Use normalized anchor positions instead of raw selection.from/to
|
|
// to handle newline-at-end selections (line-start trap).
|
|
const fromCoords = view.coordsAtPos(anchors.topPos);
|
|
const toCoords = view.coordsAtPos(anchors.bottomPos);
|
|
if (fromCoords && toCoords) {
|
|
const centerX = (fromCoords.left + toCoords.right) / 2;
|
|
left = centerX - panelWidth / 2;
|
|
} else {
|
|
// Coords unavailable — fall back to editor center
|
|
left = (scrollRect.left + scrollRect.right) / 2 - panelWidth / 2;
|
|
}
|
|
} else {
|
|
// Reason: Visual multi-line selections center on the editor regardless of
|
|
// below/above/center placement, preventing left-edge snapping on reverse selections.
|
|
left = (scrollRect.left + scrollRect.right) / 2 - panelWidth / 2;
|
|
}
|
|
|
|
// Clamp to editor visible area first, then viewport as safety net
|
|
left = Math.max(scrollRect.left, Math.min(left, scrollRect.right - panelWidth));
|
|
left = Math.max(margin, Math.min(left, win.innerWidth - margin - panelWidth));
|
|
|
|
return { x: left, y: top, anchorBottom: anchorBottomY };
|
|
}
|
|
|
|
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.
|
|
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
|
|
const doc = this.resolveDocument(activeView);
|
|
this.container = doc.createElement("div");
|
|
this.container.className = "copilot-menu-command-modal-container";
|
|
doc.body.appendChild(this.container);
|
|
|
|
this.root = createPluginRoot(this.container, this.app);
|
|
|
|
// Capture ReplaceGuard (replaces captureReplaceSnapshot)
|
|
const { selectedText, command, systemPrompt, behaviorConfig } = this.configs;
|
|
let selectedTextSnapshot = selectedText;
|
|
|
|
if (activeView?.editor?.cm) {
|
|
const view = activeView.editor.cm;
|
|
const selection = view.state.selection.main;
|
|
const filePath = activeView.file?.path ?? null;
|
|
selectedTextSnapshot = view.state.doc.sliceString(selection.from, selection.to);
|
|
|
|
// Show persistent selection highlight
|
|
SelectionHighlight.show(view, selection.from, selection.to);
|
|
this.highlightView = view;
|
|
|
|
// Create ReplaceGuard
|
|
this.replaceGuard = createHighlightReplaceGuard({
|
|
editorView: view,
|
|
filePathSnapshot: filePath,
|
|
selectedTextSnapshot,
|
|
getCurrentContext: () => {
|
|
const currentView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
return {
|
|
editorView: currentView?.editor?.cm ?? null,
|
|
filePath: currentView?.file?.path ?? null,
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
const { anchorBottom, ...initialPosition } = this.getInitialPosition(activeView);
|
|
|
|
const handleInsert = (message: string) => {
|
|
void insertIntoEditor(message);
|
|
this.close();
|
|
};
|
|
|
|
const handleReplace = (message: string) => {
|
|
if (!this.replaceGuard) {
|
|
new Notice("No selection to replace.");
|
|
return;
|
|
}
|
|
|
|
const cleanedMessage = cleanMessageForCopy(message);
|
|
const result = this.replaceGuard.replace(cleanedMessage);
|
|
|
|
if (!result.ok) {
|
|
new Notice(result.message ?? "Cannot replace.");
|
|
return;
|
|
}
|
|
|
|
new Notice("Message replaced in the active note.");
|
|
this.close();
|
|
};
|
|
|
|
const handleClose = () => {
|
|
this.close();
|
|
};
|
|
|
|
this.root.render(
|
|
<CustomCommandChatModalContent
|
|
originalText={selectedTextSnapshot}
|
|
command={command}
|
|
onInsert={handleInsert}
|
|
onReplace={handleReplace}
|
|
onClose={handleClose}
|
|
systemPrompt={systemPrompt}
|
|
initialPosition={initialPosition}
|
|
anchorBottom={anchorBottom}
|
|
behaviorConfig={behaviorConfig}
|
|
/>
|
|
);
|
|
}
|
|
|
|
close() {
|
|
if (this.scope) {
|
|
this.app.keymap.popScope(this.scope);
|
|
this.scope = null;
|
|
}
|
|
// Hide selection highlight
|
|
if (this.highlightView) {
|
|
SelectionHighlight.hide(this.highlightView);
|
|
this.highlightView = null;
|
|
}
|
|
// Clean up ReplaceGuard reference
|
|
this.replaceGuard = null;
|
|
this.root?.unmount();
|
|
this.root = null;
|
|
this.container?.remove();
|
|
this.container = null;
|
|
}
|
|
}
|