From bde45fa258965fe781f3dc1723f8280ab1f8ae78 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 26 Mar 2026 16:14:01 -0400 Subject: [PATCH] [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 --- .../src/components/InlineNodeTypePicker.ts | 255 ++++++++++++++++++ apps/obsidian/src/index.ts | 27 +- 2 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 apps/obsidian/src/components/InlineNodeTypePicker.ts diff --git a/apps/obsidian/src/components/InlineNodeTypePicker.ts b/apps/obsidian/src/components/InlineNodeTypePicker.ts new file mode 100644 index 000000000..1176989d1 --- /dev/null +++ b/apps/obsidian/src/components/InlineNodeTypePicker.ts @@ -0,0 +1,255 @@ +import { Editor } from "obsidian"; +import { DiscourseNode } from "~/types"; +import { createDiscourseNode } from "~/utils/createNode"; +import type DiscourseGraphPlugin from "~/index"; + +/** + * A popover that shows all node types inline near the cursor/selection. + * When the user picks a node type, the selected text is transformed into + * a discourse node and the selection is replaced with a [[link]]. + */ +export class InlineNodeTypePicker { + private popover: HTMLElement | null = null; + private items: DiscourseNode[] = []; + private selectedIndex = 0; + private keydownHandler: ((e: KeyboardEvent) => void) | null = null; + private clickOutsideHandler: ((e: MouseEvent) => void) | null = null; + + constructor( + private options: { + editor: Editor; + nodeTypes: DiscourseNode[]; + plugin: DiscourseGraphPlugin; + selectedText: string; + }, + ) { + this.items = this.options.nodeTypes.filter((nt) => nt.name); + } + + private getCursorPosition(): { x: number; y: number } | null { + try { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + if (rect.width === 0 && rect.height === 0) { + const span = document.createElement("span"); + span.textContent = "\u200B"; + range.insertNode(span); + const spanRect = span.getBoundingClientRect(); + span.remove(); + + if (spanRect.width === 0 && spanRect.height === 0) return null; + + return { x: spanRect.left, y: spanRect.bottom }; + } + + return { x: rect.left, y: rect.bottom }; + } catch { + return null; + } + } + + private createPopover(): HTMLElement { + const popover = document.createElement("div"); + popover.className = + "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]"; + const itemsContainer = document.createElement("div"); + itemsContainer.className = "inline-node-type-items-container"; + popover.appendChild(itemsContainer); + + this.renderItems(itemsContainer); + + return popover; + } + + private renderItems(container: HTMLElement) { + container.innerHTML = ""; + + if (this.items.length === 0) { + const noResults = document.createElement("div"); + noResults.className = "p-3 text-center text-muted text-sm"; + noResults.textContent = "No node types available"; + container.appendChild(noResults); + return; + } + + this.items.forEach((item, index) => { + const itemEl = document.createElement("div"); + 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)]${ + index === this.selectedIndex ? " bg-modifier-hover" : "" + }`; + itemEl.dataset.index = index.toString(); + + if (item.color) { + const colorDot = document.createElement("div"); + colorDot.className = "w-3 h-3 rounded-full shrink-0"; + colorDot.style.backgroundColor = item.color; + itemEl.appendChild(colorDot); + } + + const nameText = document.createElement("div"); + nameText.textContent = item.name; + nameText.className = "font-medium text-normal text-sm"; + itemEl.appendChild(nameText); + + itemEl.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + void this.selectItem(item); + }); + + itemEl.addEventListener("mouseenter", () => { + this.updateSelectedIndex(index); + }); + + container.appendChild(itemEl); + }); + } + + private updateSelectedIndex(newIndex: number) { + if (newIndex === this.selectedIndex) return; + + const prevSelected = this.popover?.querySelector( + `.inline-node-type-item[data-index="${this.selectedIndex}"]`, + ) as HTMLElement; + if (prevSelected) { + prevSelected.classList.remove("bg-modifier-hover"); + } + + this.selectedIndex = newIndex; + + const newSelected = this.popover?.querySelector( + `.inline-node-type-item[data-index="${this.selectedIndex}"]`, + ) as HTMLElement; + if (newSelected) { + newSelected.classList.add("bg-modifier-hover"); + } + } + + private scrollToSelected() { + const selectedEl = this.popover?.querySelector( + `.inline-node-type-item[data-index="${this.selectedIndex}"]`, + ) as HTMLElement; + if (selectedEl) { + selectedEl.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + } + + private async selectItem(item: DiscourseNode) { + this.close(); + await createDiscourseNode({ + plugin: this.options.plugin, + nodeType: item, + text: this.options.selectedText, + editor: this.options.editor, + }); + } + + private setupEventHandlers() { + this.keydownHandler = (e: KeyboardEvent) => { + if (!this.popover) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + e.stopPropagation(); + const newIndex = Math.min( + this.selectedIndex + 1, + this.items.length - 1, + ); + this.updateSelectedIndex(newIndex); + this.scrollToSelected(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + const newIndex = Math.max(this.selectedIndex - 1, 0); + this.updateSelectedIndex(newIndex); + this.scrollToSelected(); + } else if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + const selectedItem = this.items[this.selectedIndex]; + if (selectedItem) { + void this.selectItem(selectedItem); + } + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }; + + this.clickOutsideHandler = (e: MouseEvent) => { + if ( + this.popover && + !this.popover.contains(e.target as Node) && + !(e.target as HTMLElement).closest(".inline-node-type-picker") + ) { + this.close(); + } + }; + + document.addEventListener("keydown", this.keydownHandler, true); + document.addEventListener("mousedown", this.clickOutsideHandler, true); + } + + private removeEventHandlers() { + if (this.keydownHandler) { + document.removeEventListener("keydown", this.keydownHandler, true); + this.keydownHandler = null; + } + if (this.clickOutsideHandler) { + document.removeEventListener("mousedown", this.clickOutsideHandler, true); + this.clickOutsideHandler = null; + } + } + + public open() { + if (this.popover) { + this.close(); + } + + const position = this.getCursorPosition(); + if (!position) return; + + this.popover = this.createPopover(); + document.body.appendChild(this.popover); + + const popoverRect = this.popover.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = position.x; + let top = position.y + 4; + + if (left + popoverRect.width > viewportWidth) { + left = viewportWidth - popoverRect.width - 10; + } + if (left < 10) { + left = 10; + } + + if (top + popoverRect.height > viewportHeight) { + top = position.y - popoverRect.height - 4; + } + if (top < 10) { + top = 10; + } + + this.popover.style.left = `${left}px`; + this.popover.style.top = `${top}px`; + + this.setupEventHandlers(); + } + + public close() { + this.removeEventHandlers(); + if (this.popover) { + this.popover.remove(); + this.popover = null; + } + this.selectedIndex = 0; + } +} diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 2153e95ab..a120ebfaf 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -28,6 +28,7 @@ import ModifyNodeModal from "~/components/ModifyNodeModal"; import { TagNodeHandler } from "~/utils/tagNodeHandler"; import { TldrawView } from "~/components/canvas/TldrawView"; import { NodeTagSuggestPopover } from "~/components/NodeTagSuggestModal"; +import { InlineNodeTypePicker } from "~/components/InlineNodeTypePicker"; import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase"; import { FileChangeListener } from "~/utils/fileChangeListener"; import generateUid from "~/utils/generateUid"; @@ -271,12 +272,26 @@ export default class DiscourseGraphPlugin extends Plugin { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView?.editor) { - // Open the node tag suggest popover - const popover = new NodeTagSuggestPopover( - activeView.editor, - this.settings.nodeTypes, - ); - popover.open(); + const editor = activeView.editor; + const selectedText = editor.getSelection(); + + if (selectedText && selectedText.trim().length > 0) { + // Text is selected: open node type picker to create node from selection + const picker = new InlineNodeTypePicker({ + editor, + nodeTypes: this.settings.nodeTypes, + plugin: this, + selectedText: selectedText.trim(), + }); + picker.open(); + } else { + // No selection: open the candidate node tag popover + const popover = new NodeTagSuggestPopover( + editor, + this.settings.nodeTypes, + ); + popover.open(); + } } return true;