Skip to content

Commit 32c8cc1

Browse files
authored
[ENG-1052] Enable node tags via command key (#554)
* working first ver * address PR comments * address PR comments * fix type issue
1 parent 20199dc commit 32c8cc1

File tree

7 files changed

+406
-88
lines changed

7 files changed

+406
-88
lines changed

apps/obsidian/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,12 @@
3636
"zod": "^3.24.1"
3737
},
3838
"dependencies": {
39+
"@codemirror/view": "^6.38.8",
40+
"date-fns": "^4.1.0",
3941
"nanoid": "^4.0.2",
4042
"react": "catalog:obsidian",
4143
"react-dom": "catalog:obsidian",
42-
"date-fns": "^4.1.0",
4344
"tailwindcss-animate": "^1.0.7",
4445
"tldraw": "3.14.2"
4546
}
46-
}
47+
}

apps/obsidian/src/components/GeneralSettings.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ const GeneralSettings = () => {
154154
);
155155
const [canvasAttachmentsFolderPath, setCanvasAttachmentsFolderPath] =
156156
useState<string>(plugin.settings.canvasAttachmentsFolderPath);
157+
const [nodeTagHotkey, setNodeTagHotkey] = useState<string>(
158+
plugin.settings.nodeTagHotkey,
159+
);
157160
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
158161

159162
const handleToggleChange = (newValue: boolean) => {
@@ -179,11 +182,20 @@ const GeneralSettings = () => {
179182
[],
180183
);
181184

185+
const handleNodeTagHotkeyChange = useCallback((newValue: string) => {
186+
// Only allow single character
187+
if (newValue.length <= 1) {
188+
setNodeTagHotkey(newValue);
189+
setHasUnsavedChanges(true);
190+
}
191+
}, []);
192+
182193
const handleSave = async () => {
183194
plugin.settings.showIdsInFrontmatter = showIdsInFrontmatter;
184195
plugin.settings.nodesFolderPath = nodesFolderPath;
185196
plugin.settings.canvasFolderPath = canvasFolderPath;
186197
plugin.settings.canvasAttachmentsFolderPath = canvasAttachmentsFolderPath;
198+
plugin.settings.nodeTagHotkey = nodeTagHotkey || "";
187199
await plugin.saveSettings();
188200
new Notice("General settings saved");
189201
setHasUnsavedChanges(false);
@@ -262,6 +274,35 @@ const GeneralSettings = () => {
262274
</div>
263275
</div>
264276

277+
<div className="setting-item">
278+
<div className="setting-item-info">
279+
<div className="setting-item-name">Node tag hotkey</div>
280+
<div className="setting-item-description">
281+
Key to press after a space to open the node tags menu. Default:
282+
&quot;\&quot;.
283+
</div>
284+
</div>
285+
<div className="setting-item-control">
286+
<input
287+
type="text"
288+
value={nodeTagHotkey}
289+
onChange={(e) => handleNodeTagHotkeyChange(e.target.value)}
290+
onKeyDown={(e) => {
291+
// Capture the key pressed
292+
if (e.key.length === 1) {
293+
e.preventDefault();
294+
handleNodeTagHotkeyChange(e.key);
295+
} else if (e.key === "Backspace") {
296+
handleNodeTagHotkeyChange("");
297+
}
298+
}}
299+
placeholder="\\"
300+
maxLength={1}
301+
className="setting-item-control"
302+
/>
303+
</div>
304+
</div>
305+
265306
<div className="setting-item">
266307
<button
267308
onClick={() => void handleSave()}
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { Editor } from "obsidian";
2+
import { DiscourseNode } from "~/types";
3+
4+
type NodeTagItem = {
5+
nodeType: DiscourseNode;
6+
tag: string;
7+
};
8+
9+
export class NodeTagSuggestPopover {
10+
private popover: HTMLElement | null = null;
11+
private items: NodeTagItem[] = [];
12+
private selectedIndex = 0;
13+
private keydownHandler: ((e: KeyboardEvent) => void) | null = null;
14+
private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
15+
16+
constructor(
17+
private editor: Editor,
18+
private nodeTypes: DiscourseNode[],
19+
) {
20+
this.initializeItems();
21+
}
22+
23+
private initializeItems() {
24+
this.items = [];
25+
this.nodeTypes.forEach((nodeType) => {
26+
if (nodeType.tag) {
27+
this.items.push({
28+
nodeType,
29+
tag: nodeType.tag,
30+
});
31+
}
32+
});
33+
}
34+
35+
private getCursorPosition(): { x: number; y: number } | null {
36+
try {
37+
const selection = window.getSelection();
38+
if (!selection || selection.rangeCount === 0) {
39+
console.error("No selection found");
40+
return null;
41+
}
42+
43+
const range = selection.getRangeAt(0);
44+
const rect = range.getBoundingClientRect();
45+
46+
// If the rect has no dimensions (collapsed cursor), try using a temporary span to get cursor position
47+
if (rect.width === 0 && rect.height === 0) {
48+
const span = document.createElement("span");
49+
span.textContent = "\u200B";
50+
range.insertNode(span);
51+
const spanRect = span.getBoundingClientRect();
52+
span.remove();
53+
54+
if (spanRect.width === 0 && spanRect.height === 0) {
55+
console.error("Could not determine cursor position");
56+
return null;
57+
}
58+
59+
return {
60+
x: spanRect.left,
61+
y: spanRect.bottom,
62+
};
63+
}
64+
65+
return {
66+
x: rect.left,
67+
y: rect.bottom,
68+
};
69+
} catch (error) {
70+
console.error("Error getting cursor position:", error);
71+
return null;
72+
}
73+
}
74+
75+
private createPopover(): HTMLElement {
76+
const popover = document.createElement("div");
77+
popover.className =
78+
"node-tag-suggest-popover 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]";
79+
80+
const itemsContainer = document.createElement("div");
81+
itemsContainer.className = "node-tag-items-container";
82+
popover.appendChild(itemsContainer);
83+
84+
this.renderItems(itemsContainer);
85+
86+
return popover;
87+
}
88+
89+
private renderItems(container: HTMLElement) {
90+
container.innerHTML = "";
91+
92+
if (this.items.length === 0) {
93+
const noResults = document.createElement("div");
94+
noResults.className = "p-3 text-center text-muted text-sm";
95+
noResults.textContent = "No node tags available";
96+
container.appendChild(noResults);
97+
return;
98+
}
99+
100+
this.items.forEach((item, index) => {
101+
const itemEl = document.createElement("div");
102+
itemEl.className = `node-tag-item px-3 py-2 cursor-pointer flex items-center gap-2 border-b border-[var(--background-modifier-border-hover)]${
103+
index === this.selectedIndex ? " bg-modifier-hover" : ""
104+
}`;
105+
itemEl.dataset.index = index.toString();
106+
107+
if (item.nodeType.color) {
108+
const colorDot = document.createElement("div");
109+
colorDot.className = `w-3 h-3 rounded-full shrink-0`;
110+
colorDot.style.backgroundColor = item.nodeType.color;
111+
itemEl.appendChild(colorDot);
112+
}
113+
114+
const textContainer = document.createElement("div");
115+
textContainer.className = "flex flex-col gap-0.5 flex-1";
116+
117+
const tagText = document.createElement("div");
118+
tagText.textContent = `#${item.tag}`;
119+
tagText.className = "font-medium text-normal text-sm";
120+
121+
const nodeTypeText = document.createElement("div");
122+
nodeTypeText.textContent = item.nodeType.name;
123+
nodeTypeText.className = "text-xs text-muted";
124+
125+
textContainer.appendChild(tagText);
126+
textContainer.appendChild(nodeTypeText);
127+
itemEl.appendChild(textContainer);
128+
129+
itemEl.addEventListener("mousedown", (e) => {
130+
e.preventDefault();
131+
e.stopPropagation();
132+
this.selectItem(item);
133+
});
134+
135+
itemEl.addEventListener("mouseenter", () => {
136+
this.updateSelectedIndex(index);
137+
});
138+
139+
container.appendChild(itemEl);
140+
});
141+
}
142+
143+
private updateSelectedIndex(newIndex: number) {
144+
if (newIndex === this.selectedIndex) return;
145+
146+
const prevSelected = this.popover?.querySelector(
147+
`.node-tag-item[data-index="${this.selectedIndex}"]`,
148+
) as HTMLElement;
149+
if (prevSelected) {
150+
prevSelected.classList.remove("bg-modifier-hover");
151+
}
152+
153+
this.selectedIndex = newIndex;
154+
155+
const newSelected = this.popover?.querySelector(
156+
`.node-tag-item[data-index="${this.selectedIndex}"]`,
157+
) as HTMLElement;
158+
if (newSelected) {
159+
newSelected.classList.add("bg-modifier-hover");
160+
}
161+
}
162+
163+
private scrollToSelected() {
164+
const selectedEl = this.popover?.querySelector(
165+
`.node-tag-item[data-index="${this.selectedIndex}"]`,
166+
) as HTMLElement;
167+
if (selectedEl) {
168+
selectedEl.scrollIntoView({ block: "nearest", behavior: "smooth" });
169+
}
170+
}
171+
172+
private selectItem(item: NodeTagItem) {
173+
const tagText = `#${item.tag} `;
174+
const cursor = this.editor.getCursor();
175+
this.editor.replaceRange(tagText, cursor, cursor);
176+
const newCursor = {
177+
line: cursor.line,
178+
ch: cursor.ch + tagText.length,
179+
};
180+
this.editor.setCursor(newCursor);
181+
this.close();
182+
}
183+
184+
private setupEventHandlers() {
185+
this.keydownHandler = (e: KeyboardEvent) => {
186+
if (!this.popover) return;
187+
188+
if (e.key === "ArrowDown") {
189+
e.preventDefault();
190+
e.stopPropagation();
191+
const newIndex = Math.min(
192+
this.selectedIndex + 1,
193+
this.items.length - 1,
194+
);
195+
this.updateSelectedIndex(newIndex);
196+
this.scrollToSelected();
197+
} else if (e.key === "ArrowUp") {
198+
e.preventDefault();
199+
e.stopPropagation();
200+
const newIndex = Math.max(this.selectedIndex - 1, 0);
201+
this.updateSelectedIndex(newIndex);
202+
this.scrollToSelected();
203+
} else if (e.key === "Enter") {
204+
e.preventDefault();
205+
e.stopPropagation();
206+
const selectedItem = this.items[this.selectedIndex];
207+
if (selectedItem) {
208+
this.selectItem(selectedItem);
209+
}
210+
} else if (e.key === "Escape") {
211+
e.preventDefault();
212+
e.stopPropagation();
213+
this.close();
214+
}
215+
};
216+
217+
this.clickOutsideHandler = (e: MouseEvent) => {
218+
if (
219+
this.popover &&
220+
!this.popover.contains(e.target as Node) &&
221+
!(e.target as HTMLElement).closest(".node-tag-suggest-popover")
222+
) {
223+
this.close();
224+
}
225+
};
226+
227+
document.addEventListener("keydown", this.keydownHandler, true);
228+
document.addEventListener("mousedown", this.clickOutsideHandler, true);
229+
}
230+
231+
private removeEventHandlers() {
232+
if (this.keydownHandler) {
233+
document.removeEventListener("keydown", this.keydownHandler, true);
234+
this.keydownHandler = null;
235+
}
236+
if (this.clickOutsideHandler) {
237+
document.removeEventListener("mousedown", this.clickOutsideHandler, true);
238+
this.clickOutsideHandler = null;
239+
}
240+
}
241+
242+
public open() {
243+
if (this.popover) {
244+
this.close();
245+
}
246+
247+
const position = this.getCursorPosition();
248+
if (!position) {
249+
console.error("Could not get cursor position for popover");
250+
return;
251+
}
252+
253+
this.popover = this.createPopover();
254+
document.body.appendChild(this.popover);
255+
256+
const popoverRect = this.popover.getBoundingClientRect();
257+
const viewportWidth = window.innerWidth;
258+
const viewportHeight = window.innerHeight;
259+
260+
let left = position.x;
261+
let top = position.y + 4;
262+
263+
if (left + popoverRect.width > viewportWidth) {
264+
left = viewportWidth - popoverRect.width - 10;
265+
}
266+
if (left < 10) {
267+
left = 10;
268+
}
269+
270+
if (top + popoverRect.height > viewportHeight) {
271+
// Position above cursor instead
272+
top = position.y - popoverRect.height - 4;
273+
}
274+
if (top < 10) {
275+
top = 10;
276+
}
277+
278+
this.popover.style.left = `${left}px`;
279+
this.popover.style.top = `${top}px`;
280+
281+
this.setupEventHandlers();
282+
}
283+
284+
public close() {
285+
this.removeEventHandlers();
286+
if (this.popover) {
287+
this.popover.remove();
288+
this.popover = null;
289+
}
290+
this.selectedIndex = 0;
291+
}
292+
}

apps/obsidian/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const DEFAULT_SETTINGS: Settings = {
6868
nodesFolderPath: "",
6969
canvasFolderPath: "Discourse Canvas",
7070
canvasAttachmentsFolderPath: "attachments",
71+
nodeTagHotkey: "\\",
7172
};
7273
export const FRONTMATTER_KEY = "tldr-dg";
7374
export const TLDATA_DELIMITER_START =

0 commit comments

Comments
 (0)