From ef2a05765e4649b26875828d65459d333009bd64 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 24 Sep 2025 19:52:13 -0400 Subject: [PATCH 1/8] feature complete --- apps/obsidian/src/index.ts | 18 +- apps/obsidian/src/utils/colorUtils.ts | 103 +++++ apps/obsidian/src/utils/tagNodeHandler.ts | 446 ++++++++++++++++++++++ apps/obsidian/styles.css | 17 + 4 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 apps/obsidian/src/utils/colorUtils.ts create mode 100644 apps/obsidian/src/utils/tagNodeHandler.ts diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 6a3597805..2f30ede5a 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,4 +1,4 @@ -import { Plugin, Editor, Menu, TFile, Events } from "obsidian"; +import { Plugin, Editor, Menu, TFile } from "obsidian"; import { SettingsTab } from "~/components/Settings"; import { Settings } from "~/types"; import { registerCommands } from "~/utils/registerCommands"; @@ -10,10 +10,12 @@ import { } from "~/utils/createNode"; import { DEFAULT_SETTINGS } from "~/constants"; import { CreateNodeModal } from "~/components/CreateNodeModal"; +import { TagNodeHandler } from "~/utils/tagNodeHandler"; export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; private styleElement: HTMLStyleElement | null = null; + private tagNodeHandler: TagNodeHandler | null = null; async onload() { await this.loadSettings(); @@ -32,6 +34,15 @@ export default class DiscourseGraphPlugin extends Plugin { // Initialize frontmatter CSS this.updateFrontmatterStyles(); + // Initialize tag node handler + try { + this.tagNodeHandler = new TagNodeHandler(this); + this.tagNodeHandler.initialize(); + } catch (error) { + console.error("Failed to initialize TagNodeHandler:", error); + this.tagNodeHandler = null; + } + this.registerEvent( // @ts-ignore - file-menu event exists but is not in the type definitions this.app.workspace.on("file-menu", (menu: Menu, file: TFile) => { @@ -200,6 +211,11 @@ export default class DiscourseGraphPlugin extends Plugin { this.styleElement.remove(); } + if (this.tagNodeHandler) { + this.tagNodeHandler.cleanup(); + this.tagNodeHandler = null; + } + this.app.workspace.detachLeavesOfType(VIEW_TYPE_DISCOURSE_CONTEXT); } } diff --git a/apps/obsidian/src/utils/colorUtils.ts b/apps/obsidian/src/utils/colorUtils.ts new file mode 100644 index 000000000..945f4b14e --- /dev/null +++ b/apps/obsidian/src/utils/colorUtils.ts @@ -0,0 +1,103 @@ +import { DiscourseNode } from "~/types"; + +// Color palette similar to Roam's implementation +const COLOR_PALETTE: Record = { + black: "#1d1d1d", + blue: "#4263eb", + green: "#099268", + grey: "#adb5bd", + lightBlue: "#4dabf7", + lightGreen: "#40c057", + lightRed: "#ff8787", + lightViolet: "#e599f7", + orange: "#f76707", + red: "#e03131", + violet: "#ae3ec9", + white: "#ffffff", + yellow: "#ffc078", +}; + +const COLOR_ARRAY = [ + "yellow", + "white", + "violet", + "red", + "orange", + "lightViolet", + "lightRed", + "lightGreen", + "lightBlue", + "grey", + "green", + "blue", + "black", +]; + +/** + * Format hex color to ensure it starts with # + */ +export const formatHexColor = (color: string): string => { + if (!color) return ""; + const COLOR_TEST = /^[0-9a-f]{6}$/i; + const COLOR_TEST_WITH_HASH = /^#[0-9a-f]{6}$/i; + if (color.startsWith("#")) { + return COLOR_TEST_WITH_HASH.test(color) ? color : ""; + } else if (COLOR_TEST.test(color)) { + return "#" + color; + } + return ""; +}; + +/** + * Calculate contrast color (black or white) based on background color + * Simplified version of contrast-color logic + */ +export const getContrastColor = (bgColor: string): string => { + // Remove # if present + const hex = bgColor.replace("#", ""); + + // Ensure we have a valid hex string + if (hex.length !== 6) return "#000000"; + + // Convert to RGB + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + // Check for NaN values + if (isNaN(r) || isNaN(g) || isNaN(b)) return "#000000"; + + // Calculate luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + // Return black for light backgrounds, white for dark backgrounds + return luminance > 0.5 ? "#000000" : "#ffffff"; +}; + +/** + * Get colors for a discourse node type + */ +export const getDiscourseNodeColors = (nodeType: DiscourseNode, nodeIndex: number): { backgroundColor: string; textColor: string } => { + // Use custom color from node type if available + const customColor = nodeType.color ? formatHexColor(nodeType.color) : ""; + + // Fall back to palette color based on index + const safeIndex = nodeIndex >= 0 && nodeIndex < COLOR_ARRAY.length ? nodeIndex : 0; + const paletteColorKey = COLOR_ARRAY[safeIndex]; + const paletteColor = paletteColorKey ? COLOR_PALETTE[paletteColorKey] : COLOR_PALETTE.blue; + + const backgroundColor = customColor || paletteColor || "#4263eb"; + const textColor = getContrastColor(backgroundColor); + + return { backgroundColor, textColor }; +}; + +/** + * Get all discourse node colors for CSS variable generation + */ +export const getAllDiscourseNodeColors = (nodeTypes: DiscourseNode[]): Array<{ nodeType: DiscourseNode; colors: { backgroundColor: string; textColor: string } }> => { + return nodeTypes.map((nodeType, index) => ({ + nodeType, + colors: getDiscourseNodeColors(nodeType, index), + })); +}; diff --git a/apps/obsidian/src/utils/tagNodeHandler.ts b/apps/obsidian/src/utils/tagNodeHandler.ts new file mode 100644 index 000000000..8a1822a23 --- /dev/null +++ b/apps/obsidian/src/utils/tagNodeHandler.ts @@ -0,0 +1,446 @@ +import { App, Editor, Notice, MarkdownView } from "obsidian"; +import { DiscourseNode } from "~/types"; +import type DiscourseGraphPlugin from "~/index"; +import { CreateNodeModal } from "~/components/CreateNodeModal"; +import { createDiscourseNodeFile, formatNodeName } from "./createNode"; +import { getDiscourseNodeColors } from "./colorUtils"; + +export class TagNodeHandler { + private plugin: DiscourseGraphPlugin; + private app: App; + private registeredEventHandlers: (() => void)[] = []; + private tagObserver: MutationObserver | null = null; + + constructor(plugin: DiscourseGraphPlugin) { + this.plugin = plugin; + this.app = plugin.app; + } + + /** + * Create a MutationObserver to watch for discourse node tags + */ + private createTagObserver(): MutationObserver { + return new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + // Only process nodes that are likely to contain tags + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (node instanceof HTMLElement) { + if (node.classList.contains('cm-line') || + node.querySelector('[class*="cm-tag-"]') || + Array.from(node.classList).some(cls => cls.startsWith('cm-tag-'))) { + this.processElement(node); + } + } + }); + } + + // Only watch class changes on elements that might be tags + if (mutation.type === 'attributes' && + mutation.attributeName === 'class' && + mutation.target instanceof HTMLElement) { + const target = mutation.target; + const classList = Array.from(target.classList); + + // Only process if it has or gained a cm-tag-* class + if (classList.some(cls => cls.startsWith('cm-tag-'))) { + this.processElement(target); + } + } + }); + }); + } + + /** + * Process an element and its children for discourse node tags + */ + private processElement(element: HTMLElement) { + this.plugin.settings.nodeTypes.forEach((nodeType) => { + const nodeTypeName = nodeType.name.toLowerCase(); + const tagSelector = `.cm-tag-${nodeTypeName}`; + + // Check if the element itself matches + if (element.matches(tagSelector)) { + this.applyDiscourseTagStyling(element, nodeType); + } + + // Check all children + const childTags = element.querySelectorAll(tagSelector); + childTags.forEach((tagEl) => { + if (tagEl instanceof HTMLElement) { + this.applyDiscourseTagStyling(tagEl, nodeType); + } + }); + }); + } + + /** + * Apply colors and hover functionality to a discourse tag + */ + private applyDiscourseTagStyling(tagElement: HTMLElement, nodeType: DiscourseNode) { + const alreadyProcessed = tagElement.dataset.discourseTagProcessed === 'true'; + + const nodeIndex = this.plugin.settings.nodeTypes.findIndex((nt) => nt.id === nodeType.id); + const colors = getDiscourseNodeColors(nodeType, nodeIndex); + + tagElement.style.backgroundColor = colors.backgroundColor; + tagElement.style.color = colors.textColor; + tagElement.style.cursor = "pointer"; + + if (!alreadyProcessed) { + const editor = this.getActiveEditor(); + if (editor) { + this.addHoverFunctionality(tagElement, nodeType, editor); + } + } + } + + /** + * Get the active editor (helper method) + */ + private getActiveEditor(): Editor | null { + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + return activeView?.editor || null; + } + /** + * Extract content from the line up to the clicked tag using simple text approach + */ + private extractContentUpToTag(tagElement: HTMLElement): { contentUpToTag: string; tagName: string } | null { + const lineDiv = tagElement.closest('.cm-line'); + if (!lineDiv) return null; + + const fullLineText = lineDiv.textContent || ''; + + const tagClasses = Array.from(tagElement.classList); + const tagClass = tagClasses.find(cls => cls.startsWith('cm-tag-')); + if (!tagClass) return null; + + const tagName = tagClass.replace('cm-tag-', ''); + const tagWithHash = `#${tagName}`; + + const tagIndex = fullLineText.indexOf(tagWithHash); + if (tagIndex === -1) return null; + + const contentUpToTag = fullLineText.substring(0, tagIndex).trim(); + + return { + contentUpToTag, + tagName + }; + } + + + + /** + * Handle tag click to create discourse node + */ + private handleTagClick(tagElement: HTMLElement, nodeType: DiscourseNode, editor: Editor): void { + const extractedData = this.extractContentUpToTag(tagElement); + if (!extractedData) { + new Notice("Could not extract content", 3000); + return; + } + + const cleanText = extractedData.contentUpToTag.replace(/#\w+/g, '').trim(); + + new CreateNodeModal(this.app, { + nodeTypes: this.plugin.settings.nodeTypes, + plugin: this.plugin, + initialTitle: cleanText, + initialNodeType: nodeType, + onNodeCreate: async (selectedNodeType, title) => { + await this.createNodeAndReplace({ + nodeType: selectedNodeType, + title, + editor, + tagElement, + }); + }, + }).open(); + } + + /** + * Create the discourse node and replace the content up to the tag + */ + private async createNodeAndReplace(params: { + nodeType: DiscourseNode; + title: string; + editor: Editor; + tagElement: HTMLElement; + }): Promise { + const { nodeType, title, editor, tagElement } = params; + try { + // Create the discourse node file + const formattedNodeName = formatNodeName(title, nodeType); + if (!formattedNodeName) { + new Notice("Failed to format node name", 3000); + return; + } + + const newFile = await createDiscourseNodeFile({ + plugin: this.plugin, + formattedNodeName, + nodeType, + }); + + if (!newFile) { + new Notice("Failed to create discourse node file", 3000); + return; + } + + const extractedData = this.extractContentUpToTag(tagElement); + if (!extractedData) { + new Notice("Could not determine content range for replacement", 3000); + return; + } + + const { contentUpToTag, tagName } = extractedData; + const tagWithHash = `#${tagName}`; + + // Find the actual line in editor that matches our DOM content + const allLines = editor.getValue().split('\n'); + let lineNumber = -1; + for (let i = 0; i < allLines.length; i++) { + if (allLines[i]?.includes(tagWithHash) && allLines[i]?.includes(contentUpToTag.substring(0, 10))) { + lineNumber = i; + break; + } + } + + if (lineNumber === -1) { + new Notice("Could not find matching line in editor", 3000); + return; + } + + const actualLineText = allLines[lineNumber]; + if (!actualLineText) { + new Notice("Could not find matching line in editor", 3000); + return; + } + const tagStartPos = actualLineText.indexOf(tagWithHash); + const tagEndPos = tagStartPos + tagWithHash.length; + + const linkText = `[[${formattedNodeName}]]`; + const contentAfterTag = actualLineText.substring(tagEndPos); + + editor.replaceRange( + linkText + contentAfterTag, + { line: lineNumber, ch: 0 }, + { line: lineNumber, ch: actualLineText.length } + ); + } catch (error) { + console.error("Error creating discourse node from tag:", error); + new Notice( + `Error creating discourse node: ${error instanceof Error ? error.message : String(error)}`, + 5000, + ); + } + } + + /** + * Add hover functionality with "Create [NodeType]" button + */ + private addHoverFunctionality(tagElement: HTMLElement, nodeType: DiscourseNode, editor: Editor) { + // Mark as processed to avoid duplicate handlers + if (tagElement.dataset.discourseTagProcessed === 'true') return; + tagElement.dataset.discourseTagProcessed = 'true'; + + let hoverTooltip: HTMLElement | null = null; + let hoverTimeout: number | null = null; + + const showTooltip = () => { + if (hoverTooltip) return; + const rect = tagElement.getBoundingClientRect(); + + hoverTooltip = document.createElement('div'); + hoverTooltip.className = 'discourse-tag-popover'; + hoverTooltip.style.cssText = ` + position: fixed; + top: ${rect.top - 40}px; + left: ${rect.left + rect.width / 2}px; + transform: translateX(-50%); + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.15); + z-index: 9999; + white-space: nowrap; + font-size: 12px; + pointer-events: auto; + `; + + const createButton = document.createElement('button'); + createButton.textContent = `Create ${nodeType.name}`; + createButton.className = 'mod-cta dg-create-node-button'; + + createButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.handleTagClick(tagElement, nodeType, editor); + + hideTooltip(); + }); + + hoverTooltip.appendChild(createButton); + + document.body.appendChild(hoverTooltip); + + hoverTooltip.addEventListener('mouseenter', () => { + if (hoverTimeout) { + clearTimeout(hoverTimeout); + hoverTimeout = null; + } + }); + + hoverTooltip.addEventListener('mouseleave', () => { + void setTimeout(hideTooltip, 100); + }); + }; + + const hideTooltip = () => { + if (hoverTooltip) { + hoverTooltip.remove(); + hoverTooltip = null; + } + }; + + tagElement.addEventListener('mouseenter', () => { + if (hoverTimeout) { + clearTimeout(hoverTimeout); + } + hoverTimeout = window.setTimeout(showTooltip, 200); + }); + + tagElement.addEventListener('mouseleave', (e) => { + if (hoverTimeout) { + clearTimeout(hoverTimeout); + hoverTimeout = null; + } + + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget || !hoverTooltip?.contains(relatedTarget)) { + void setTimeout(hideTooltip, 100); + } + }); + + const cleanup = () => { + if (hoverTimeout) { + clearTimeout(hoverTimeout); + } + hideTooltip(); + }; + + (tagElement as HTMLElement & { __discourseTagCleanup?: () => void }).__discourseTagCleanup = cleanup; + } + + + + /** + * Process existing tags in the current view (for initial setup) + */ + private processTagsInView() { + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView) return; + + this.processElement(activeView.contentEl); + } + + /** + * Refresh discourse tag colors when node types change + */ + public refreshColors() { + this.processTagsInView(); + } + + /** + * Initialize the tag node handler + */ + public initialize() { + // Create and start the tag observer (similar to Roam's approach) + this.tagObserver = this.createTagObserver(); + + // Start observing the workspace + this.startObserving(); + + // Process existing tags in the current view + this.processTagsInView(); + + // Re-start observing when the active view changes + const activeLeafChangeHandler = () => { + void setTimeout(() => { + this.stopObserving(); + this.startObserving(); + this.processTagsInView(); + }, 100); + }; + + this.app.workspace.on('active-leaf-change', activeLeafChangeHandler); + this.registeredEventHandlers.push(() => { + this.app.workspace.off('active-leaf-change', activeLeafChangeHandler); + }); + } + + /** + * Start observing the current active view for tag changes + */ + private startObserving() { + if (!this.tagObserver) return; + + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView) return; + + const targetElement = activeView.contentEl; + if (targetElement) { + this.tagObserver.observe(targetElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'] + }); + } + } + + /** + * Stop observing + */ + private stopObserving() { + if (this.tagObserver) { + this.tagObserver.disconnect(); + } + } + + /** + * Cleanup event handlers and tooltips + */ + public cleanup() { + this.registeredEventHandlers.forEach(cleanup => cleanup()); + this.registeredEventHandlers = []; + + // Stop and cleanup the tag observer + this.stopObserving(); + this.tagObserver = null; + + // Clean up any remaining tooltips + const tooltips = document.querySelectorAll('.discourse-tag-popover'); + tooltips.forEach(tooltip => tooltip.remove()); + + // Clean up processed tags + const processedTags = document.querySelectorAll('[data-discourse-tag-processed="true"]'); + processedTags.forEach(tag => { + const tagWithCleanup = tag as HTMLElement & { __discourseTagCleanup?: () => void }; + const cleanup = tagWithCleanup.__discourseTagCleanup; + if (typeof cleanup === 'function') { + cleanup(); + } + tag.removeAttribute('data-discourse-tag-processed'); + + // Reset styles for the tag element + const htmlTag = tag as HTMLElement; + htmlTag.style.backgroundColor = ''; + htmlTag.style.color = ''; + htmlTag.style.cursor = ''; + }); + } +} diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 295645138..7dcdc33d2 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -17,3 +17,20 @@ .dg-h4 { @apply text-lg font-bold mb-2; } + +.dg-create-node-button { + background: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + border-radius: 4px; + padding: 6px 12px; + font-size: 11px; + cursor: pointer; + font-weight: 500; + white-space: nowrap; + transition: background-color 0.2s ease; +} + +.dg-create-node-button:hover { + background: var(--interactive-accent-hover); +} From ea9b9fce0e5e2534ec04e42dd83ec46c9d2facf8 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 24 Sep 2025 20:00:22 -0400 Subject: [PATCH 2/8] reorganize --- apps/obsidian/src/utils/tagNodeHandler.ts | 437 ++++++++++++++-------- 1 file changed, 275 insertions(+), 162 deletions(-) diff --git a/apps/obsidian/src/utils/tagNodeHandler.ts b/apps/obsidian/src/utils/tagNodeHandler.ts index 8a1822a23..de8407bb1 100644 --- a/apps/obsidian/src/utils/tagNodeHandler.ts +++ b/apps/obsidian/src/utils/tagNodeHandler.ts @@ -5,6 +5,30 @@ import { CreateNodeModal } from "~/components/CreateNodeModal"; import { createDiscourseNodeFile, formatNodeName } from "./createNode"; import { getDiscourseNodeColors } from "./colorUtils"; +// Constants +const HOVER_DELAY = 200; +const HIDE_DELAY = 100; +const OBSERVER_RESTART_DELAY = 100; +const TOOLTIP_OFFSET = 40; + +type ExtractedTagData = { + contentUpToTag: string; + tagName: string; +}; + +type NodeCreationParams = { + nodeType: DiscourseNode; + title: string; + editor: Editor; + tagElement: HTMLElement; +}; + +/** + * Handles discourse node tag interactions in Obsidian editor + * - Observes DOM for discourse node tags + * - Applies styling and hover functionality + * - Creates discourse nodes from tag clicks + */ export class TagNodeHandler { private plugin: DiscourseGraphPlugin; private app: App; @@ -16,6 +40,41 @@ export class TagNodeHandler { this.app = plugin.app; } + // ============================================================================ + // PUBLIC API + // ============================================================================ + + /** + * Initialize the tag node handler + */ + public initialize(): void { + this.tagObserver = this.createTagObserver(); + this.startObserving(); + this.processTagsInView(); + this.setupEventHandlers(); + } + + /** + * Refresh discourse tag colors when node types change + */ + public refreshColors(): void { + this.processTagsInView(); + } + + /** + * Cleanup event handlers and tooltips + */ + public cleanup(): void { + this.cleanupEventHandlers(); + this.cleanupObserver(); + this.cleanupTooltips(); + this.cleanupProcessedTags(); + } + + // ============================================================================ + // DOM OBSERVATION & PROCESSING + // ============================================================================ + /** * Create a MutationObserver to watch for discourse node tags */ @@ -23,27 +82,25 @@ export class TagNodeHandler { return new MutationObserver((mutations) => { mutations.forEach((mutation) => { // Only process nodes that are likely to contain tags - if (mutation.type === 'childList') { + if (mutation.type === "childList") { mutation.addedNodes.forEach((node) => { - if (node instanceof HTMLElement) { - if (node.classList.contains('cm-line') || - node.querySelector('[class*="cm-tag-"]') || - Array.from(node.classList).some(cls => cls.startsWith('cm-tag-'))) { - this.processElement(node); - } + if ( + node instanceof HTMLElement && + this.isTagRelevantElement(node) + ) { + this.processElement(node); } }); } - + // Only watch class changes on elements that might be tags - if (mutation.type === 'attributes' && - mutation.attributeName === 'class' && - mutation.target instanceof HTMLElement) { + if ( + mutation.type === "attributes" && + mutation.attributeName === "class" && + mutation.target instanceof HTMLElement + ) { const target = mutation.target; - const classList = Array.from(target.classList); - - // Only process if it has or gained a cm-tag-* class - if (classList.some(cls => cls.startsWith('cm-tag-'))) { + if (this.hasTagClass(target)) { this.processElement(target); } } @@ -51,19 +108,39 @@ export class TagNodeHandler { }); } + /** + * Check if element is relevant for tag processing + */ + private isTagRelevantElement(element: HTMLElement): boolean { + return ( + element.classList.contains("cm-line") || + element.querySelector('[class*="cm-tag-"]') !== null || + this.hasTagClass(element) + ); + } + + /** + * Check if element has cm-tag-* class + */ + private hasTagClass(element: HTMLElement): boolean { + return Array.from(element.classList).some((cls) => + cls.startsWith("cm-tag-"), + ); + } + /** * Process an element and its children for discourse node tags */ - private processElement(element: HTMLElement) { + private processElement(element: HTMLElement): void { this.plugin.settings.nodeTypes.forEach((nodeType) => { const nodeTypeName = nodeType.name.toLowerCase(); const tagSelector = `.cm-tag-${nodeTypeName}`; - + // Check if the element itself matches if (element.matches(tagSelector)) { this.applyDiscourseTagStyling(element, nodeType); } - + // Check all children const childTags = element.querySelectorAll(tagSelector); childTags.forEach((tagEl) => { @@ -74,13 +151,32 @@ export class TagNodeHandler { }); } + /** + * Process existing tags in the current view (for initial setup) + */ + private processTagsInView(): void { + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView) return; + this.processElement(activeView.contentEl); + } + + // ============================================================================ + // TAG STYLING & INTERACTION + // ============================================================================ + /** * Apply colors and hover functionality to a discourse tag */ - private applyDiscourseTagStyling(tagElement: HTMLElement, nodeType: DiscourseNode) { - const alreadyProcessed = tagElement.dataset.discourseTagProcessed === 'true'; + private applyDiscourseTagStyling( + tagElement: HTMLElement, + nodeType: DiscourseNode, + ): void { + const alreadyProcessed = + tagElement.dataset.discourseTagProcessed === "true"; - const nodeIndex = this.plugin.settings.nodeTypes.findIndex((nt) => nt.id === nodeType.id); + const nodeIndex = this.plugin.settings.nodeTypes.findIndex( + (nt) => nt.id === nodeType.id, + ); const colors = getDiscourseNodeColors(nodeType, nodeIndex); tagElement.style.backgroundColor = colors.backgroundColor; @@ -94,55 +190,55 @@ export class TagNodeHandler { } } } + // ============================================================================ + // CONTENT EXTRACTION & NODE CREATION + // ============================================================================ - /** - * Get the active editor (helper method) - */ - private getActiveEditor(): Editor | null { - const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); - return activeView?.editor || null; - } /** * Extract content from the line up to the clicked tag using simple text approach */ - private extractContentUpToTag(tagElement: HTMLElement): { contentUpToTag: string; tagName: string } | null { - const lineDiv = tagElement.closest('.cm-line'); + private extractContentUpToTag( + tagElement: HTMLElement, + ): ExtractedTagData | null { + const lineDiv = tagElement.closest(".cm-line"); if (!lineDiv) return null; - - const fullLineText = lineDiv.textContent || ''; - + + const fullLineText = lineDiv.textContent || ""; + const tagClasses = Array.from(tagElement.classList); - const tagClass = tagClasses.find(cls => cls.startsWith('cm-tag-')); + const tagClass = tagClasses.find((cls) => cls.startsWith("cm-tag-")); if (!tagClass) return null; - - const tagName = tagClass.replace('cm-tag-', ''); + + const tagName = tagClass.replace("cm-tag-", ""); const tagWithHash = `#${tagName}`; - + const tagIndex = fullLineText.indexOf(tagWithHash); if (tagIndex === -1) return null; - + const contentUpToTag = fullLineText.substring(0, tagIndex).trim(); return { contentUpToTag, - tagName + tagName, }; } - - /** * Handle tag click to create discourse node */ - private handleTagClick(tagElement: HTMLElement, nodeType: DiscourseNode, editor: Editor): void { + private handleTagClick( + tagElement: HTMLElement, + nodeType: DiscourseNode, + editor: Editor, + ): void { const extractedData = this.extractContentUpToTag(tagElement); if (!extractedData) { new Notice("Could not extract content", 3000); return; } - const cleanText = extractedData.contentUpToTag.replace(/#\w+/g, '').trim(); - + const cleanText = extractedData.contentUpToTag.replace(/#\w+/g, "").trim(); + new CreateNodeModal(this.app, { nodeTypes: this.plugin.settings.nodeTypes, plugin: this.plugin, @@ -162,12 +258,9 @@ export class TagNodeHandler { /** * Create the discourse node and replace the content up to the tag */ - private async createNodeAndReplace(params: { - nodeType: DiscourseNode; - title: string; - editor: Editor; - tagElement: HTMLElement; - }): Promise { + private async createNodeAndReplace( + params: NodeCreationParams, + ): Promise { const { nodeType, title, editor, tagElement } = params; try { // Create the discourse node file @@ -193,40 +286,43 @@ export class TagNodeHandler { new Notice("Could not determine content range for replacement", 3000); return; } - + const { contentUpToTag, tagName } = extractedData; const tagWithHash = `#${tagName}`; - + // Find the actual line in editor that matches our DOM content - const allLines = editor.getValue().split('\n'); + const allLines = editor.getValue().split("\n"); let lineNumber = -1; for (let i = 0; i < allLines.length; i++) { - if (allLines[i]?.includes(tagWithHash) && allLines[i]?.includes(contentUpToTag.substring(0, 10))) { + if ( + allLines[i]?.includes(tagWithHash) && + allLines[i]?.includes(contentUpToTag.substring(0, 10)) + ) { lineNumber = i; break; } } - + if (lineNumber === -1) { new Notice("Could not find matching line in editor", 3000); return; } - - const actualLineText = allLines[lineNumber]; + + const actualLineText = allLines[lineNumber]; if (!actualLineText) { new Notice("Could not find matching line in editor", 3000); return; } const tagStartPos = actualLineText.indexOf(tagWithHash); const tagEndPos = tagStartPos + tagWithHash.length; - + const linkText = `[[${formattedNodeName}]]`; const contentAfterTag = actualLineText.substring(tagEndPos); - + editor.replaceRange( linkText + contentAfterTag, { line: lineNumber, ch: 0 }, - { line: lineNumber, ch: actualLineText.length } + { line: lineNumber, ch: actualLineText.length }, ); } catch (error) { console.error("Error creating discourse node from tag:", error); @@ -237,26 +333,34 @@ export class TagNodeHandler { } } + // ============================================================================ + // HOVER FUNCTIONALITY & TOOLTIPS + // ============================================================================ + /** * Add hover functionality with "Create [NodeType]" button */ - private addHoverFunctionality(tagElement: HTMLElement, nodeType: DiscourseNode, editor: Editor) { + private addHoverFunctionality( + tagElement: HTMLElement, + nodeType: DiscourseNode, + editor: Editor, + ): void { // Mark as processed to avoid duplicate handlers - if (tagElement.dataset.discourseTagProcessed === 'true') return; - tagElement.dataset.discourseTagProcessed = 'true'; - + if (tagElement.dataset.discourseTagProcessed === "true") return; + tagElement.dataset.discourseTagProcessed = "true"; + let hoverTooltip: HTMLElement | null = null; let hoverTimeout: number | null = null; - + const showTooltip = () => { if (hoverTooltip) return; const rect = tagElement.getBoundingClientRect(); - - hoverTooltip = document.createElement('div'); - hoverTooltip.className = 'discourse-tag-popover'; + + hoverTooltip = document.createElement("div"); + hoverTooltip.className = "discourse-tag-popover"; hoverTooltip.style.cssText = ` position: fixed; - top: ${rect.top - 40}px; + top: ${rect.top - TOOLTIP_OFFSET}px; left: ${rect.left + rect.width / 2}px; transform: translateX(-50%); background: var(--background-primary); @@ -269,178 +373,187 @@ export class TagNodeHandler { font-size: 12px; pointer-events: auto; `; - - const createButton = document.createElement('button'); + + const createButton = document.createElement("button"); createButton.textContent = `Create ${nodeType.name}`; - createButton.className = 'mod-cta dg-create-node-button'; - - createButton.addEventListener('click', (e) => { + createButton.className = "mod-cta dg-create-node-button"; + + createButton.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); - + this.handleTagClick(tagElement, nodeType, editor); - + hideTooltip(); }); - + hoverTooltip.appendChild(createButton); - + document.body.appendChild(hoverTooltip); - - hoverTooltip.addEventListener('mouseenter', () => { + + hoverTooltip.addEventListener("mouseenter", () => { if (hoverTimeout) { clearTimeout(hoverTimeout); hoverTimeout = null; } }); - - hoverTooltip.addEventListener('mouseleave', () => { - void setTimeout(hideTooltip, 100); + + hoverTooltip.addEventListener("mouseleave", () => { + void setTimeout(hideTooltip, HIDE_DELAY); }); }; - + const hideTooltip = () => { if (hoverTooltip) { hoverTooltip.remove(); hoverTooltip = null; } }; - - tagElement.addEventListener('mouseenter', () => { + + tagElement.addEventListener("mouseenter", () => { if (hoverTimeout) { clearTimeout(hoverTimeout); } - hoverTimeout = window.setTimeout(showTooltip, 200); + hoverTimeout = window.setTimeout(showTooltip, HOVER_DELAY); }); - - tagElement.addEventListener('mouseleave', (e) => { + + tagElement.addEventListener("mouseleave", (e) => { if (hoverTimeout) { clearTimeout(hoverTimeout); hoverTimeout = null; } - + const relatedTarget = e.relatedTarget as HTMLElement; if (!relatedTarget || !hoverTooltip?.contains(relatedTarget)) { - void setTimeout(hideTooltip, 100); + void setTimeout(hideTooltip, HIDE_DELAY); } }); - + const cleanup = () => { if (hoverTimeout) { clearTimeout(hoverTimeout); } hideTooltip(); }; - - (tagElement as HTMLElement & { __discourseTagCleanup?: () => void }).__discourseTagCleanup = cleanup; + + ( + tagElement as HTMLElement & { __discourseTagCleanup?: () => void } + ).__discourseTagCleanup = cleanup; } - - + + // ============================================================================ + // OBSERVER MANAGEMENT + // ============================================================================ /** - * Process existing tags in the current view (for initial setup) + * Start observing the current active view for tag changes */ - private processTagsInView() { + private startObserving(): void { + if (!this.tagObserver) return; + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!activeView) return; - this.processElement(activeView.contentEl); + const targetElement = activeView.contentEl; + if (targetElement) { + this.tagObserver.observe(targetElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["class"], + }); + } } /** - * Refresh discourse tag colors when node types change + * Stop observing */ - public refreshColors() { - this.processTagsInView(); + private stopObserving(): void { + if (this.tagObserver) { + this.tagObserver.disconnect(); + } } + // ============================================================================ + // EVENT HANDLERS & LIFECYCLE + // ============================================================================ + /** - * Initialize the tag node handler + * Setup workspace event handlers */ - public initialize() { - // Create and start the tag observer (similar to Roam's approach) - this.tagObserver = this.createTagObserver(); - - // Start observing the workspace - this.startObserving(); - - // Process existing tags in the current view - this.processTagsInView(); - - // Re-start observing when the active view changes + private setupEventHandlers(): void { const activeLeafChangeHandler = () => { void setTimeout(() => { this.stopObserving(); this.startObserving(); this.processTagsInView(); - }, 100); + }, OBSERVER_RESTART_DELAY); }; - - this.app.workspace.on('active-leaf-change', activeLeafChangeHandler); + + this.app.workspace.on("active-leaf-change", activeLeafChangeHandler); this.registeredEventHandlers.push(() => { - this.app.workspace.off('active-leaf-change', activeLeafChangeHandler); + this.app.workspace.off("active-leaf-change", activeLeafChangeHandler); }); } /** - * Start observing the current active view for tag changes + * Cleanup event handlers */ - private startObserving() { - if (!this.tagObserver) return; - - const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (!activeView) return; - - const targetElement = activeView.contentEl; - if (targetElement) { - this.tagObserver.observe(targetElement, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['class'] - }); - } + private cleanupEventHandlers(): void { + this.registeredEventHandlers.forEach((cleanup) => cleanup()); + this.registeredEventHandlers = []; } /** - * Stop observing + * Cleanup observer */ - private stopObserving() { - if (this.tagObserver) { - this.tagObserver.disconnect(); - } + private cleanupObserver(): void { + this.stopObserving(); + this.tagObserver = null; } /** - * Cleanup event handlers and tooltips + * Cleanup tooltips */ - public cleanup() { - this.registeredEventHandlers.forEach(cleanup => cleanup()); - this.registeredEventHandlers = []; - - // Stop and cleanup the tag observer - this.stopObserving(); - this.tagObserver = null; - - // Clean up any remaining tooltips - const tooltips = document.querySelectorAll('.discourse-tag-popover'); - tooltips.forEach(tooltip => tooltip.remove()); - - // Clean up processed tags - const processedTags = document.querySelectorAll('[data-discourse-tag-processed="true"]'); - processedTags.forEach(tag => { - const tagWithCleanup = tag as HTMLElement & { __discourseTagCleanup?: () => void }; + private cleanupTooltips(): void { + const tooltips = document.querySelectorAll(".discourse-tag-popover"); + tooltips.forEach((tooltip) => tooltip.remove()); + } + + /** + * Cleanup processed tags + */ + private cleanupProcessedTags(): void { + const processedTags = document.querySelectorAll( + '[data-discourse-tag-processed="true"]', + ); + processedTags.forEach((tag) => { + const tagWithCleanup = tag as HTMLElement & { + __discourseTagCleanup?: () => void; + }; const cleanup = tagWithCleanup.__discourseTagCleanup; - if (typeof cleanup === 'function') { + if (typeof cleanup === "function") { cleanup(); } - tag.removeAttribute('data-discourse-tag-processed'); - + tag.removeAttribute("data-discourse-tag-processed"); + // Reset styles for the tag element const htmlTag = tag as HTMLElement; - htmlTag.style.backgroundColor = ''; - htmlTag.style.color = ''; - htmlTag.style.cursor = ''; + htmlTag.style.backgroundColor = ""; + htmlTag.style.color = ""; + htmlTag.style.cursor = ""; }); } + + // ============================================================================ + // UTILITY METHODS + // ============================================================================ + + /** + * Get the active editor (helper method) + */ + private getActiveEditor(): Editor | null { + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + return activeView?.editor || null; + } } From 3c6c359ac52e4bd59375f680e57f32a4d24e203f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 24 Sep 2025 20:01:35 -0400 Subject: [PATCH 3/8] rm box shadow style --- apps/obsidian/src/utils/tagNodeHandler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/obsidian/src/utils/tagNodeHandler.ts b/apps/obsidian/src/utils/tagNodeHandler.ts index de8407bb1..d8f106072 100644 --- a/apps/obsidian/src/utils/tagNodeHandler.ts +++ b/apps/obsidian/src/utils/tagNodeHandler.ts @@ -367,7 +367,6 @@ export class TagNodeHandler { border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 6px; - box-shadow: 0 4px 16px rgba(0,0,0,0.15); z-index: 9999; white-space: nowrap; font-size: 12px; From d4c7558e407672005c7bc5458aa8a79cced10ad4 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 1 Oct 2025 15:57:55 -0400 Subject: [PATCH 4/8] address some PR comments --- apps/obsidian/src/utils/tagNodeHandler.ts | 30 ++++++++++------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/apps/obsidian/src/utils/tagNodeHandler.ts b/apps/obsidian/src/utils/tagNodeHandler.ts index d8f106072..e3a8873f6 100644 --- a/apps/obsidian/src/utils/tagNodeHandler.ts +++ b/apps/obsidian/src/utils/tagNodeHandler.ts @@ -100,7 +100,7 @@ export class TagNodeHandler { mutation.target instanceof HTMLElement ) { const target = mutation.target; - if (this.hasTagClass(target)) { + if (hasTagClass(target)) { this.processElement(target); } } @@ -115,16 +115,7 @@ export class TagNodeHandler { return ( element.classList.contains("cm-line") || element.querySelector('[class*="cm-tag-"]') !== null || - this.hasTagClass(element) - ); - } - - /** - * Check if element has cm-tag-* class - */ - private hasTagClass(element: HTMLElement): boolean { - return Array.from(element.classList).some((cls) => - cls.startsWith("cm-tag-"), + hasTagClass(element) ); } @@ -233,7 +224,7 @@ export class TagNodeHandler { ): void { const extractedData = this.extractContentUpToTag(tagElement); if (!extractedData) { - new Notice("Could not extract content", 3000); + new Notice("Could not create discourse node", 3000); return; } @@ -283,7 +274,7 @@ export class TagNodeHandler { const extractedData = this.extractContentUpToTag(tagElement); if (!extractedData) { - new Notice("Could not determine content range for replacement", 3000); + new Notice("Could not create discourse node", 3000); return; } @@ -304,13 +295,13 @@ export class TagNodeHandler { } if (lineNumber === -1) { - new Notice("Could not find matching line in editor", 3000); + new Notice("Could not replace tag with discourse node", 3000); return; } const actualLineText = allLines[lineNumber]; if (!actualLineText) { - new Notice("Could not find matching line in editor", 3000); + new Notice("Could not replace tag with discourse node", 3000); return; } const tagStartPos = actualLineText.indexOf(tagWithHash); @@ -363,8 +354,6 @@ export class TagNodeHandler { top: ${rect.top - TOOLTIP_OFFSET}px; left: ${rect.left + rect.width / 2}px; transform: translateX(-50%); - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 6px; z-index: 9999; @@ -556,3 +545,10 @@ export class TagNodeHandler { return activeView?.editor || null; } } + +/** + * Check if element has cm-tag-* class + */ +export const hasTagClass = (element: HTMLElement): boolean => { + return Array.from(element.classList).some((cls) => cls.startsWith("cm-tag-")); +}; From c43ff6b167b05d787e1e395651a8c7482e307e43 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 2 Oct 2025 14:37:19 -0400 Subject: [PATCH 5/8] some problem sovled --- apps/obsidian/src/utils/tagNodeHandler.ts | 59 ++++++++++++----------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/apps/obsidian/src/utils/tagNodeHandler.ts b/apps/obsidian/src/utils/tagNodeHandler.ts index e3a8873f6..d21653bc9 100644 --- a/apps/obsidian/src/utils/tagNodeHandler.ts +++ b/apps/obsidian/src/utils/tagNodeHandler.ts @@ -11,8 +11,22 @@ const HIDE_DELAY = 100; const OBSERVER_RESTART_DELAY = 100; const TOOLTIP_OFFSET = 40; + +const sanitizeTitle = (title: string): string => { + const invalidChars = /[\\/:]/g; + + // Remove list item indicators (numbered, bulleted, etc.) + const listIndicator = /^(\s*)(\d+\.\s+|\-\s+|\*\s+|\+\s+)/; + + return title + .replace(listIndicator, "") + .replace(invalidChars, "") + .replace(/\s+/g, " ") + .trim(); +}; + type ExtractedTagData = { - contentUpToTag: string; + fullLineContent: string; tagName: string; }; @@ -48,6 +62,9 @@ export class TagNodeHandler { * Initialize the tag node handler */ public initialize(): void { + // Clean up any existing tooltips from previous instances + this.cleanupTooltips(); + this.tagObserver = this.createTagObserver(); this.startObserving(); this.processTagsInView(); @@ -186,11 +203,9 @@ export class TagNodeHandler { // ============================================================================ /** - * Extract content from the line up to the clicked tag using simple text approach + * Extract content from the entire line containing the clicked tag */ - private extractContentUpToTag( - tagElement: HTMLElement, - ): ExtractedTagData | null { + private extractContent(tagElement: HTMLElement): ExtractedTagData | null { const lineDiv = tagElement.closest(".cm-line"); if (!lineDiv) return null; @@ -201,15 +216,9 @@ export class TagNodeHandler { if (!tagClass) return null; const tagName = tagClass.replace("cm-tag-", ""); - const tagWithHash = `#${tagName}`; - - const tagIndex = fullLineText.indexOf(tagWithHash); - if (tagIndex === -1) return null; - - const contentUpToTag = fullLineText.substring(0, tagIndex).trim(); return { - contentUpToTag, + fullLineContent: fullLineText.trim(), tagName, }; } @@ -222,13 +231,15 @@ export class TagNodeHandler { nodeType: DiscourseNode, editor: Editor, ): void { - const extractedData = this.extractContentUpToTag(tagElement); + const extractedData = this.extractContent(tagElement); if (!extractedData) { new Notice("Could not create discourse node", 3000); return; } - const cleanText = extractedData.contentUpToTag.replace(/#\w+/g, "").trim(); + const cleanText = sanitizeTitle( + extractedData.fullLineContent.replace(/#\w+/g, ""), + ); new CreateNodeModal(this.app, { nodeTypes: this.plugin.settings.nodeTypes, @@ -272,23 +283,18 @@ export class TagNodeHandler { return; } - const extractedData = this.extractContentUpToTag(tagElement); + const extractedData = this.extractContent(tagElement); if (!extractedData) { new Notice("Could not create discourse node", 3000); return; } - const { contentUpToTag, tagName } = extractedData; - const tagWithHash = `#${tagName}`; - + const { fullLineContent } = extractedData; // Find the actual line in editor that matches our DOM content const allLines = editor.getValue().split("\n"); let lineNumber = -1; for (let i = 0; i < allLines.length; i++) { - if ( - allLines[i]?.includes(tagWithHash) && - allLines[i]?.includes(contentUpToTag.substring(0, 10)) - ) { + if (allLines[i]?.includes(fullLineContent)) { lineNumber = i; break; } @@ -304,14 +310,12 @@ export class TagNodeHandler { new Notice("Could not replace tag with discourse node", 3000); return; } - const tagStartPos = actualLineText.indexOf(tagWithHash); - const tagEndPos = tagStartPos + tagWithHash.length; const linkText = `[[${formattedNodeName}]]`; - const contentAfterTag = actualLineText.substring(tagEndPos); + // Replace the entire line with just the discourse node link editor.replaceRange( - linkText + contentAfterTag, + linkText, { line: lineNumber, ch: 0 }, { line: lineNumber, ch: actualLineText.length }, ); @@ -355,7 +359,7 @@ export class TagNodeHandler { left: ${rect.left + rect.width / 2}px; transform: translateX(-50%); border-radius: 6px; - padding: 6px; + padding: 66px; z-index: 9999; white-space: nowrap; font-size: 12px; @@ -393,6 +397,7 @@ export class TagNodeHandler { const hideTooltip = () => { if (hoverTooltip) { + console.log("Removing tooltip"); hoverTooltip.remove(); hoverTooltip = null; } From b10257edd56a5b9a1c5f23c73c7d2c007e11a540 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 2 Oct 2025 15:27:19 -0400 Subject: [PATCH 6/8] address all PR comments --- apps/obsidian/src/utils/colorUtils.ts | 72 +++++++++-------------- apps/obsidian/src/utils/tagNodeHandler.ts | 57 ++++++++++++------ 2 files changed, 67 insertions(+), 62 deletions(-) diff --git a/apps/obsidian/src/utils/colorUtils.ts b/apps/obsidian/src/utils/colorUtils.ts index 945f4b14e..605de8926 100644 --- a/apps/obsidian/src/utils/colorUtils.ts +++ b/apps/obsidian/src/utils/colorUtils.ts @@ -17,59 +17,30 @@ const COLOR_PALETTE: Record = { yellow: "#ffc078", }; -const COLOR_ARRAY = [ - "yellow", - "white", - "violet", - "red", - "orange", - "lightViolet", - "lightRed", - "lightGreen", - "lightBlue", - "grey", - "green", - "blue", - "black", -]; - -/** - * Format hex color to ensure it starts with # - */ -export const formatHexColor = (color: string): string => { - if (!color) return ""; - const COLOR_TEST = /^[0-9a-f]{6}$/i; - const COLOR_TEST_WITH_HASH = /^#[0-9a-f]{6}$/i; - if (color.startsWith("#")) { - return COLOR_TEST_WITH_HASH.test(color) ? color : ""; - } else if (COLOR_TEST.test(color)) { - return "#" + color; - } - return ""; -}; - +const COLOR_ARRAY = Object.keys(COLOR_PALETTE); /** * Calculate contrast color (black or white) based on background color * Simplified version of contrast-color logic */ +// TODO switch to colord - https://linear.app/discourse-graphs/issue/ENG-836/button-like-css-styling-for-node-tag export const getContrastColor = (bgColor: string): string => { // Remove # if present const hex = bgColor.replace("#", ""); - + // Ensure we have a valid hex string if (hex.length !== 6) return "#000000"; - + // Convert to RGB const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); - + // Check for NaN values if (isNaN(r) || isNaN(g) || isNaN(b)) return "#000000"; - + // Calculate luminance const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - + // Return black for light backgrounds, white for dark backgrounds return luminance > 0.5 ? "#000000" : "#ffffff"; }; @@ -77,27 +48,38 @@ export const getContrastColor = (bgColor: string): string => { /** * Get colors for a discourse node type */ -export const getDiscourseNodeColors = (nodeType: DiscourseNode, nodeIndex: number): { backgroundColor: string; textColor: string } => { +export const getNodeTagColors = ( + nodeType: DiscourseNode, + nodeIndex: number, +): { backgroundColor: string; textColor: string } => { // Use custom color from node type if available - const customColor = nodeType.color ? formatHexColor(nodeType.color) : ""; - + const customColor = nodeType.color || ""; + // Fall back to palette color based on index - const safeIndex = nodeIndex >= 0 && nodeIndex < COLOR_ARRAY.length ? nodeIndex : 0; + const safeIndex = + nodeIndex >= 0 && nodeIndex < COLOR_ARRAY.length ? nodeIndex : 0; const paletteColorKey = COLOR_ARRAY[safeIndex]; - const paletteColor = paletteColorKey ? COLOR_PALETTE[paletteColorKey] : COLOR_PALETTE.blue; - + const paletteColor = paletteColorKey + ? COLOR_PALETTE[paletteColorKey] + : COLOR_PALETTE.blue; + const backgroundColor = customColor || paletteColor || "#4263eb"; const textColor = getContrastColor(backgroundColor); - + return { backgroundColor, textColor }; }; /** * Get all discourse node colors for CSS variable generation */ -export const getAllDiscourseNodeColors = (nodeTypes: DiscourseNode[]): Array<{ nodeType: DiscourseNode; colors: { backgroundColor: string; textColor: string } }> => { +export const getAllDiscourseNodeColors = ( + nodeTypes: DiscourseNode[], +): Array<{ + nodeType: DiscourseNode; + colors: { backgroundColor: string; textColor: string }; +}> => { return nodeTypes.map((nodeType, index) => ({ nodeType, - colors: getDiscourseNodeColors(nodeType, index), + colors: getNodeTagColors(nodeType, index), })); }; diff --git a/apps/obsidian/src/utils/tagNodeHandler.ts b/apps/obsidian/src/utils/tagNodeHandler.ts index d21653bc9..b89aa7981 100644 --- a/apps/obsidian/src/utils/tagNodeHandler.ts +++ b/apps/obsidian/src/utils/tagNodeHandler.ts @@ -3,7 +3,7 @@ import { DiscourseNode } from "~/types"; import type DiscourseGraphPlugin from "~/index"; import { CreateNodeModal } from "~/components/CreateNodeModal"; import { createDiscourseNodeFile, formatNodeName } from "./createNode"; -import { getDiscourseNodeColors } from "./colorUtils"; +import { getNodeTagColors } from "./colorUtils"; // Constants const HOVER_DELAY = 200; @@ -11,7 +11,6 @@ const HIDE_DELAY = 100; const OBSERVER_RESTART_DELAY = 100; const TOOLTIP_OFFSET = 40; - const sanitizeTitle = (title: string): string => { const invalidChars = /[\\/:]/g; @@ -48,6 +47,7 @@ export class TagNodeHandler { private app: App; private registeredEventHandlers: (() => void)[] = []; private tagObserver: MutationObserver | null = null; + private currentTooltip: HTMLElement | null = null; constructor(plugin: DiscourseGraphPlugin) { this.plugin = plugin; @@ -129,6 +129,13 @@ export class TagNodeHandler { * Check if element is relevant for tag processing */ private isTagRelevantElement(element: HTMLElement): boolean { + if ( + element.classList.contains("discourse-tag-popover") || + element.closest(".discourse-tag-popover") === element + ) { + return false; + } + return ( element.classList.contains("cm-line") || element.querySelector('[class*="cm-tag-"]') !== null || @@ -153,6 +160,13 @@ export class TagNodeHandler { const childTags = element.querySelectorAll(tagSelector); childTags.forEach((tagEl) => { if (tagEl instanceof HTMLElement) { + // Skip if this tag is already being processed or is inside a tooltip + if ( + tagEl.dataset.discourseTagProcessed === "true" || + tagEl.closest(".discourse-tag-popover") === tagEl + ) { + return; + } this.applyDiscourseTagStyling(tagEl, nodeType); } }); @@ -185,7 +199,7 @@ export class TagNodeHandler { const nodeIndex = this.plugin.settings.nodeTypes.findIndex( (nt) => nt.id === nodeType.id, ); - const colors = getDiscourseNodeColors(nodeType, nodeIndex); + const colors = getNodeTagColors(nodeType, nodeIndex); tagElement.style.backgroundColor = colors.backgroundColor; tagElement.style.color = colors.textColor; @@ -344,22 +358,27 @@ export class TagNodeHandler { if (tagElement.dataset.discourseTagProcessed === "true") return; tagElement.dataset.discourseTagProcessed = "true"; - let hoverTooltip: HTMLElement | null = null; + // Also check if hover functionality is already attached + if ((tagElement as any).__discourseTagCleanup) { + return; + } + let hoverTimeout: number | null = null; const showTooltip = () => { - if (hoverTooltip) return; + if (this.currentTooltip) return; + const rect = tagElement.getBoundingClientRect(); - hoverTooltip = document.createElement("div"); - hoverTooltip.className = "discourse-tag-popover"; - hoverTooltip.style.cssText = ` + this.currentTooltip = document.createElement("div"); + this.currentTooltip.className = "discourse-tag-popover"; + this.currentTooltip.style.cssText = ` position: fixed; top: ${rect.top - TOOLTIP_OFFSET}px; left: ${rect.left + rect.width / 2}px; transform: translateX(-50%); border-radius: 6px; - padding: 66px; + padding: 6px; z-index: 9999; white-space: nowrap; font-size: 12px; @@ -379,27 +398,27 @@ export class TagNodeHandler { hideTooltip(); }); - hoverTooltip.appendChild(createButton); + this.currentTooltip.appendChild(createButton); - document.body.appendChild(hoverTooltip); + document.body.appendChild(this.currentTooltip); - hoverTooltip.addEventListener("mouseenter", () => { + this.currentTooltip.addEventListener("mouseenter", () => { if (hoverTimeout) { clearTimeout(hoverTimeout); hoverTimeout = null; } }); - hoverTooltip.addEventListener("mouseleave", () => { + this.currentTooltip.addEventListener("mouseleave", () => { void setTimeout(hideTooltip, HIDE_DELAY); }); }; const hideTooltip = () => { - if (hoverTooltip) { + if (this.currentTooltip) { console.log("Removing tooltip"); - hoverTooltip.remove(); - hoverTooltip = null; + this.currentTooltip.remove(); + this.currentTooltip = null; } }; @@ -417,7 +436,7 @@ export class TagNodeHandler { } const relatedTarget = e.relatedTarget as HTMLElement; - if (!relatedTarget || !hoverTooltip?.contains(relatedTarget)) { + if (!relatedTarget || !this.currentTooltip?.contains(relatedTarget)) { void setTimeout(hideTooltip, HIDE_DELAY); } }); @@ -509,6 +528,10 @@ export class TagNodeHandler { * Cleanup tooltips */ private cleanupTooltips(): void { + if (this.currentTooltip) { + this.currentTooltip.remove(); + this.currentTooltip = null; + } const tooltips = document.querySelectorAll(".discourse-tag-popover"); tooltips.forEach((tooltip) => tooltip.remove()); } From f956cd8b768c2a9933e03159939ad15fead2a08d Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 3 Oct 2025 11:18:24 -0400 Subject: [PATCH 7/8] sm fix --- apps/obsidian/src/utils/tagNodeHandler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/utils/tagNodeHandler.ts b/apps/obsidian/src/utils/tagNodeHandler.ts index b89aa7981..d6de28f6d 100644 --- a/apps/obsidian/src/utils/tagNodeHandler.ts +++ b/apps/obsidian/src/utils/tagNodeHandler.ts @@ -308,7 +308,10 @@ export class TagNodeHandler { const allLines = editor.getValue().split("\n"); let lineNumber = -1; for (let i = 0; i < allLines.length; i++) { - if (allLines[i]?.includes(fullLineContent)) { + if ( + allLines[i]?.includes(fullLineContent) && + allLines[i]?.includes(tagElement.textContent ?? "") + ) { lineNumber = i; break; } From 4596a880dd10890dcd731757eda7b0ecca6b12ef Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 10 Oct 2025 17:04:32 -0400 Subject: [PATCH 8/8] address PR comments --- apps/obsidian/src/utils/colorUtils.ts | 20 ++------------------ apps/obsidian/src/utils/tagNodeHandler.ts | 21 +++++++++++++++------ 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/apps/obsidian/src/utils/colorUtils.ts b/apps/obsidian/src/utils/colorUtils.ts index 605de8926..091667fa9 100644 --- a/apps/obsidian/src/utils/colorUtils.ts +++ b/apps/obsidian/src/utils/colorUtils.ts @@ -18,44 +18,30 @@ const COLOR_PALETTE: Record = { }; const COLOR_ARRAY = Object.keys(COLOR_PALETTE); -/** - * Calculate contrast color (black or white) based on background color - * Simplified version of contrast-color logic - */ + // TODO switch to colord - https://linear.app/discourse-graphs/issue/ENG-836/button-like-css-styling-for-node-tag export const getContrastColor = (bgColor: string): string => { - // Remove # if present const hex = bgColor.replace("#", ""); - // Ensure we have a valid hex string if (hex.length !== 6) return "#000000"; - // Convert to RGB const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); - // Check for NaN values if (isNaN(r) || isNaN(g) || isNaN(b)) return "#000000"; - // Calculate luminance const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - // Return black for light backgrounds, white for dark backgrounds return luminance > 0.5 ? "#000000" : "#ffffff"; }; -/** - * Get colors for a discourse node type - */ export const getNodeTagColors = ( nodeType: DiscourseNode, nodeIndex: number, ): { backgroundColor: string; textColor: string } => { - // Use custom color from node type if available const customColor = nodeType.color || ""; - // Fall back to palette color based on index const safeIndex = nodeIndex >= 0 && nodeIndex < COLOR_ARRAY.length ? nodeIndex : 0; const paletteColorKey = COLOR_ARRAY[safeIndex]; @@ -69,9 +55,7 @@ export const getNodeTagColors = ( return { backgroundColor, textColor }; }; -/** - * Get all discourse node colors for CSS variable generation - */ + export const getAllDiscourseNodeColors = ( nodeTypes: DiscourseNode[], ): Array<{ diff --git a/apps/obsidian/src/utils/tagNodeHandler.ts b/apps/obsidian/src/utils/tagNodeHandler.ts index d6de28f6d..187d46814 100644 --- a/apps/obsidian/src/utils/tagNodeHandler.ts +++ b/apps/obsidian/src/utils/tagNodeHandler.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { App, Editor, Notice, MarkdownView } from "obsidian"; import { DiscourseNode } from "~/types"; import type DiscourseGraphPlugin from "~/index"; @@ -5,7 +6,6 @@ import { CreateNodeModal } from "~/components/CreateNodeModal"; import { createDiscourseNodeFile, formatNodeName } from "./createNode"; import { getNodeTagColors } from "./colorUtils"; -// Constants const HOVER_DELAY = 200; const HIDE_DELAY = 100; const OBSERVER_RESTART_DELAY = 100; @@ -15,7 +15,7 @@ const sanitizeTitle = (title: string): string => { const invalidChars = /[\\/:]/g; // Remove list item indicators (numbered, bulleted, etc.) - const listIndicator = /^(\s*)(\d+\.\s+|\-\s+|\*\s+|\+\s+)/; + const listIndicator = /^(\s*)(\d+\.\s+|-\s+|\*\s+|\+\s+)/; return title .replace(listIndicator, "") @@ -147,6 +147,10 @@ export class TagNodeHandler { * Process an element and its children for discourse node tags */ private processElement(element: HTMLElement): void { + if (!document.contains(element)) { + return; + } + this.plugin.settings.nodeTypes.forEach((nodeType) => { const nodeTypeName = nodeType.name.toLowerCase(); const tagSelector = `.cm-tag-${nodeTypeName}`; @@ -163,7 +167,8 @@ export class TagNodeHandler { // Skip if this tag is already being processed or is inside a tooltip if ( tagEl.dataset.discourseTagProcessed === "true" || - tagEl.closest(".discourse-tag-popover") === tagEl + tagEl.closest(".discourse-tag-popover") === tagEl || + !document.contains(tagEl) ) { return; } @@ -336,6 +341,9 @@ export class TagNodeHandler { { line: lineNumber, ch: 0 }, { line: lineNumber, ch: actualLineText.length }, ); + + this.cleanupProcessedTags(); + this.cleanupTooltips(); } catch (error) { console.error("Error creating discourse node from tag:", error); new Notice( @@ -357,12 +365,13 @@ export class TagNodeHandler { nodeType: DiscourseNode, editor: Editor, ): void { - // Mark as processed to avoid duplicate handlers if (tagElement.dataset.discourseTagProcessed === "true") return; tagElement.dataset.discourseTagProcessed = "true"; - // Also check if hover functionality is already attached - if ((tagElement as any).__discourseTagCleanup) { + if ( + (tagElement as HTMLElement & { __discourseTagCleanup?: () => void }) + .__discourseTagCleanup + ) { return; }