diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx index 2778c840f..176aa1edd 100644 --- a/apps/obsidian/src/components/NodeSearchFooter.tsx +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -3,7 +3,9 @@ import { getHintKeys, type HintKey } from "~/utils/keyboardHints"; type NodeSearchFooterProps = { canAct: boolean; + canInsertLink: boolean; onClose: () => void; + onInsertLink: () => void; onOpenInNewTab: () => void; onOpenInSplit: () => void; }; @@ -50,11 +52,22 @@ const FooterAction = ({ // full-width result list, so the actions start at its left edge instead. export const NodeSearchFooter = ({ canAct, + canInsertLink, onClose, + onInsertLink, onOpenInNewTab, onOpenInSplit, }: NodeSearchFooterProps): ReactElement => (
+ {/* Absent, not disabled: with no cursor there is nothing to insert into. */} + {canInsertLink && ( + + )} void; }): ReactElement => { const { app } = plugin; @@ -440,6 +447,19 @@ const NodeSearch = ({ if (!nextOpen) inputRef.current?.focus(); }; + // Closes before inserting, like `openActiveResult`. + const insertLinkToActiveResult = (): void => { + if (!activeResult || !insertTarget) return; + const { file } = activeResult; + onClose(); + try { + insertLinkAtInsertTarget({ app, file, target: insertTarget }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + new Notice(`Could not insert a link to ${file.basename}: ${message}`); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { // Otherwise the caret jumps to the start or end of the query. @@ -451,7 +471,19 @@ const NodeSearch = ({ if (event.key !== "Enter") return; // Enter also commits an IME candidate, which must not open a file. if (event.nativeEvent.isComposing) return; - // Mod+Enter and Alt+Enter are left alone for the insert and dock actions. + // activeResult is part of the gate so the chord is not claimed while the + // results are still loading, matching the footer button's disabled state. + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + insertTarget && + activeResult + ) { + event.preventDefault(); + insertLinkToActiveResult(); + return; + } + // Alt+Enter is left alone for the dock action. if (event.metaKey || event.ctrlKey || event.altKey) return; // A footer button reached by Tab runs its own action on Enter. Preventing the // default here would suppress that click and open a new tab instead. @@ -516,7 +548,9 @@ const NodeSearch = ({
openActiveResult(openFileInNewTab)} onOpenInSplit={() => openActiveResult(openFileInNewLeaf)} /> @@ -527,10 +561,13 @@ const NodeSearch = ({ export class NodeSearchModal extends Modal { private plugin: DiscourseGraphPlugin; private root: Root | null = null; + /** Snapshotted in the constructor: `open()` has not taken focus yet. */ + private insertTarget: EditorInsertTarget | null; constructor(app: App, plugin: DiscourseGraphPlugin) { super(app); this.plugin = plugin; + this.insertTarget = snapshotInsertTarget(app); } onOpen() { @@ -549,7 +586,11 @@ export class NodeSearchModal extends Modal { this.root = createRoot(contentEl); this.root.render( - this.close()} /> + this.close()} + /> , ); } diff --git a/apps/obsidian/src/utils/editorInsertTarget.ts b/apps/obsidian/src/utils/editorInsertTarget.ts new file mode 100644 index 000000000..bc386dcd3 --- /dev/null +++ b/apps/obsidian/src/utils/editorInsertTarget.ts @@ -0,0 +1,50 @@ +import { App, MarkdownView, TFile, type EditorPosition } from "obsidian"; + +/** Held rather than re-looked-up, so the link lands in the pre-open note. */ +export type EditorInsertTarget = { + view: MarkdownView; + from: EditorPosition; + to: EditorPosition; +}; + +/** + * Call before the modal mounts, while the editor still owns the cursor. + * `hasFocus()` is not part of the gate: opening the search from the command + * palette means that palette already took focus. + */ +export const snapshotInsertTarget = (app: App): EditorInsertTarget | null => { + const view = app.workspace.getActiveViewOfType(MarkdownView); + // Reading mode has no cursor. + if (!view || !view.file || view.getMode() !== "source") return null; + + const { editor } = view; + return { + view, + // A selection is replaced rather than left beside the link. + from: editor.getCursor("from"), + to: editor.getCursor("to"), + }; +}; + +/** `generateMarkdownLink` is what honours the vault's link-format settings. */ +export const insertLinkAtInsertTarget = ({ + app, + file, + target, +}: { + app: App; + file: TFile; + target: EditorInsertTarget; +}): void => { + const { view, from, to } = target; + const sourceFile = view.file; + if (!sourceFile) return; + + const link = app.fileManager.generateMarkdownLink(file, sourceFile.path); + const { editor } = view; + editor.replaceRange(link, from, to); + + app.workspace.setActiveLeaf(view.leaf, { focus: true }); + editor.setCursor({ line: from.line, ch: from.ch + link.length }); + editor.focus(); +};