Skip to content

Commit df40f9f

Browse files
trangdoan982claude
andauthored
[ENG-1582] Node menu hotkey on text selection (#917)
* [ENG-1582] Branch node menu hotkey based on text selection When the node tag hotkey is pressed with text selected, open an inline node type picker instead of the tag popover. Selecting a node type creates a discourse node from the selected text and replaces it with a wiki-link. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * address graphite comment --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d68f020 commit df40f9f

File tree

2 files changed

+277
-6
lines changed

2 files changed

+277
-6
lines changed
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { Editor } from "obsidian";
2+
import { DiscourseNode } from "~/types";
3+
import { createDiscourseNode } from "~/utils/createNode";
4+
import type DiscourseGraphPlugin from "~/index";
5+
6+
/**
7+
* A popover that shows all node types inline near the cursor/selection.
8+
* When the user picks a node type, the selected text is transformed into
9+
* a discourse node and the selection is replaced with a [[link]].
10+
*/
11+
export class InlineNodeTypePicker {
12+
private popover: HTMLElement | null = null;
13+
private items: DiscourseNode[] = [];
14+
private selectedIndex = 0;
15+
private keydownHandler: ((e: KeyboardEvent) => void) | null = null;
16+
private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
17+
18+
constructor(
19+
private options: {
20+
editor: Editor;
21+
nodeTypes: DiscourseNode[];
22+
plugin: DiscourseGraphPlugin;
23+
selectedText: string;
24+
},
25+
) {
26+
this.items = this.options.nodeTypes.filter((nt) => nt.name);
27+
}
28+
29+
private getCursorPosition(): { x: number; y: number } | null {
30+
try {
31+
const selection = window.getSelection();
32+
if (!selection || selection.rangeCount === 0) return null;
33+
34+
const range = selection.getRangeAt(0);
35+
const rect = range.getBoundingClientRect();
36+
37+
if (rect.width === 0 && rect.height === 0) {
38+
const span = document.createElement("span");
39+
span.textContent = "\u200B";
40+
range.insertNode(span);
41+
const spanRect = span.getBoundingClientRect();
42+
span.remove();
43+
44+
if (spanRect.width === 0 && spanRect.height === 0) return null;
45+
46+
return { x: spanRect.left, y: spanRect.bottom };
47+
}
48+
49+
return { x: rect.left, y: rect.bottom };
50+
} catch {
51+
return null;
52+
}
53+
}
54+
55+
private createPopover(): HTMLElement {
56+
const popover = document.createElement("div");
57+
popover.className =
58+
"inline-node-type-picker fixed z-[10000] bg-primary border border-modifier-border rounded-md shadow-[0_4px_12px_rgba(0,0,0,0.15)] max-h-[300px] overflow-y-auto min-w-[200px] max-w-[400px]";
59+
const itemsContainer = document.createElement("div");
60+
itemsContainer.className = "inline-node-type-items-container";
61+
popover.appendChild(itemsContainer);
62+
63+
this.renderItems(itemsContainer);
64+
65+
return popover;
66+
}
67+
68+
private renderItems(container: HTMLElement) {
69+
container.innerHTML = "";
70+
71+
if (this.items.length === 0) {
72+
const noResults = document.createElement("div");
73+
noResults.className = "p-3 text-center text-muted text-sm";
74+
noResults.textContent = "No node types available";
75+
container.appendChild(noResults);
76+
return;
77+
}
78+
79+
this.items.forEach((item, index) => {
80+
const itemEl = document.createElement("div");
81+
itemEl.className = `inline-node-type-item px-3 py-2 cursor-pointer flex items-center gap-2 border-b border-[var(--background-modifier-border-hover)]${
82+
index === this.selectedIndex ? " bg-modifier-hover" : ""
83+
}`;
84+
itemEl.dataset.index = index.toString();
85+
86+
if (item.color) {
87+
const colorDot = document.createElement("div");
88+
colorDot.className = "w-3 h-3 rounded-full shrink-0";
89+
colorDot.style.backgroundColor = item.color;
90+
itemEl.appendChild(colorDot);
91+
}
92+
93+
const nameText = document.createElement("div");
94+
nameText.textContent = item.name;
95+
nameText.className = "font-medium text-normal text-sm";
96+
itemEl.appendChild(nameText);
97+
98+
itemEl.addEventListener("mousedown", (e) => {
99+
e.preventDefault();
100+
e.stopPropagation();
101+
void this.selectItem(item);
102+
});
103+
104+
itemEl.addEventListener("mouseenter", () => {
105+
this.updateSelectedIndex(index);
106+
});
107+
108+
container.appendChild(itemEl);
109+
});
110+
}
111+
112+
private updateSelectedIndex(newIndex: number) {
113+
if (newIndex === this.selectedIndex) return;
114+
115+
const prevSelected = this.popover?.querySelector(
116+
`.inline-node-type-item[data-index="${this.selectedIndex}"]`,
117+
) as HTMLElement;
118+
if (prevSelected) {
119+
prevSelected.classList.remove("bg-modifier-hover");
120+
}
121+
122+
this.selectedIndex = newIndex;
123+
124+
const newSelected = this.popover?.querySelector(
125+
`.inline-node-type-item[data-index="${this.selectedIndex}"]`,
126+
) as HTMLElement;
127+
if (newSelected) {
128+
newSelected.classList.add("bg-modifier-hover");
129+
}
130+
}
131+
132+
private scrollToSelected() {
133+
const selectedEl = this.popover?.querySelector(
134+
`.inline-node-type-item[data-index="${this.selectedIndex}"]`,
135+
) as HTMLElement;
136+
if (selectedEl) {
137+
selectedEl.scrollIntoView({ block: "nearest", behavior: "smooth" });
138+
}
139+
}
140+
141+
private async selectItem(item: DiscourseNode) {
142+
this.close();
143+
await createDiscourseNode({
144+
plugin: this.options.plugin,
145+
nodeType: item,
146+
text: this.options.selectedText,
147+
editor: this.options.editor,
148+
});
149+
}
150+
151+
private setupEventHandlers() {
152+
this.keydownHandler = (e: KeyboardEvent) => {
153+
if (!this.popover) return;
154+
155+
if (e.key === "ArrowDown") {
156+
e.preventDefault();
157+
e.stopPropagation();
158+
if (this.items.length === 0) return;
159+
const newIndex = Math.min(
160+
this.selectedIndex + 1,
161+
this.items.length - 1,
162+
);
163+
this.updateSelectedIndex(newIndex);
164+
this.scrollToSelected();
165+
} else if (e.key === "ArrowUp") {
166+
e.preventDefault();
167+
e.stopPropagation();
168+
const newIndex = Math.max(this.selectedIndex - 1, 0);
169+
this.updateSelectedIndex(newIndex);
170+
this.scrollToSelected();
171+
} else if (e.key === "Enter") {
172+
e.preventDefault();
173+
e.stopPropagation();
174+
const selectedItem = this.items[this.selectedIndex];
175+
if (selectedItem) {
176+
void this.selectItem(selectedItem);
177+
}
178+
} else if (e.key === "Escape") {
179+
e.preventDefault();
180+
e.stopPropagation();
181+
this.close();
182+
}
183+
};
184+
185+
this.clickOutsideHandler = (e: MouseEvent) => {
186+
if (
187+
this.popover &&
188+
!this.popover.contains(e.target as Node) &&
189+
!(e.target as HTMLElement).closest(".inline-node-type-picker")
190+
) {
191+
this.close();
192+
}
193+
};
194+
195+
document.addEventListener("keydown", this.keydownHandler, true);
196+
document.addEventListener("mousedown", this.clickOutsideHandler, true);
197+
}
198+
199+
private removeEventHandlers() {
200+
if (this.keydownHandler) {
201+
document.removeEventListener("keydown", this.keydownHandler, true);
202+
this.keydownHandler = null;
203+
}
204+
if (this.clickOutsideHandler) {
205+
document.removeEventListener("mousedown", this.clickOutsideHandler, true);
206+
this.clickOutsideHandler = null;
207+
}
208+
}
209+
210+
public open() {
211+
if (this.popover) {
212+
this.close();
213+
}
214+
215+
const position = this.getCursorPosition();
216+
if (!position) return;
217+
218+
this.popover = this.createPopover();
219+
document.body.appendChild(this.popover);
220+
221+
const popoverRect = this.popover.getBoundingClientRect();
222+
const viewportWidth = window.innerWidth;
223+
const viewportHeight = window.innerHeight;
224+
225+
let left = position.x;
226+
let top = position.y + 4;
227+
228+
if (left + popoverRect.width > viewportWidth) {
229+
left = viewportWidth - popoverRect.width - 10;
230+
}
231+
if (left < 10) {
232+
left = 10;
233+
}
234+
235+
if (top + popoverRect.height > viewportHeight) {
236+
top = position.y - popoverRect.height - 4;
237+
}
238+
if (top < 10) {
239+
top = 10;
240+
}
241+
242+
this.popover.style.left = `${left}px`;
243+
this.popover.style.top = `${top}px`;
244+
245+
this.setupEventHandlers();
246+
}
247+
248+
public close() {
249+
this.removeEventHandlers();
250+
if (this.popover) {
251+
this.popover.remove();
252+
this.popover = null;
253+
}
254+
this.selectedIndex = 0;
255+
}
256+
}

apps/obsidian/src/index.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import ModifyNodeModal from "~/components/ModifyNodeModal";
2828
import { TagNodeHandler } from "~/utils/tagNodeHandler";
2929
import { TldrawView } from "~/components/canvas/TldrawView";
3030
import { NodeTagSuggestPopover } from "~/components/NodeTagSuggestModal";
31+
import { InlineNodeTypePicker } from "~/components/InlineNodeTypePicker";
3132
import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase";
3233
import { FileChangeListener } from "~/utils/fileChangeListener";
3334
import generateUid from "~/utils/generateUid";
@@ -271,12 +272,26 @@ export default class DiscourseGraphPlugin extends Plugin {
271272

272273
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
273274
if (activeView?.editor) {
274-
// Open the node tag suggest popover
275-
const popover = new NodeTagSuggestPopover(
276-
activeView.editor,
277-
this.settings.nodeTypes,
278-
);
279-
popover.open();
275+
const editor = activeView.editor;
276+
const selectedText = editor.getSelection();
277+
278+
if (selectedText && selectedText.trim().length > 0) {
279+
// Text is selected: open node type picker to create node from selection
280+
const picker = new InlineNodeTypePicker({
281+
editor,
282+
nodeTypes: this.settings.nodeTypes,
283+
plugin: this,
284+
selectedText: selectedText.trim(),
285+
});
286+
picker.open();
287+
} else {
288+
// No selection: open the candidate node tag popover
289+
const popover = new NodeTagSuggestPopover(
290+
editor,
291+
this.settings.nodeTypes,
292+
);
293+
popover.open();
294+
}
280295
}
281296

282297
return true;

0 commit comments

Comments
 (0)