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();
+};