-
Notifications
You must be signed in to change notification settings - Fork 7
ENG-1249 Implement Discourse context overlay in Live Preview #1414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
trangdoan982
wants to merge
4
commits into
eng-2248-add-a-cached-relations-index-and-link-resolution
from
eng-1249-live-preview-overlay
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ebe6cdb
ENG-1249 Add the discourse context overlay in Live Preview
trangdoan982 0b31179
ENG-1249 Update badges in place instead of replacing them
trangdoan982 00ff81d
ENG-1249 Fix popover dismissal, placement and empty state
trangdoan982 2a60173
ENG-1249 Trim comments that restated their function names
trangdoan982 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
187 changes: 187 additions & 0 deletions
187
apps/obsidian/src/components/DiscourseContextPopover.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| import { TFile } from "obsidian"; | ||
| import { createRoot, Root } from "react-dom/client"; | ||
| import type DiscourseGraphPlugin from "~/index"; | ||
| import { PluginProvider } from "~/components/PluginContext"; | ||
| import { RelationshipSection } from "~/components/RelationshipSection"; | ||
| import { | ||
| countDisplayableRelations, | ||
| getEndpointIdsFromFrontmatter, | ||
| } from "~/utils/discourseLinkFrontmatter"; | ||
| import { getRelationTypeById } from "~/utils/typeUtils"; | ||
|
|
||
| const countRelationsForFile = ( | ||
| plugin: DiscourseGraphPlugin, | ||
| file: TFile, | ||
| ): number => { | ||
| const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; | ||
| const endpointIds = getEndpointIdsFromFrontmatter(frontmatter); | ||
| return countDisplayableRelations({ | ||
| relations: plugin.relationsIndex.getRelationsForEndpointIds(endpointIds), | ||
| isConfiguredType: (id) => !!getRelationTypeById(plugin, id), | ||
| }); | ||
| }; | ||
|
|
||
| const POPOVER_CLASS = "dg-discourse-context-popover"; | ||
| const VIEWPORT_MARGIN = 8; | ||
| const EMPTY_MESSAGE = "No discourse relation found"; | ||
|
|
||
| const positionPopover = (popover: HTMLElement, anchor: HTMLElement): void => { | ||
| // The anchor's own window, or a popout gets clamped to the wrong viewport. | ||
| const win = anchor.ownerDocument.defaultView ?? window; | ||
| const anchorRect = anchor.getBoundingClientRect(); | ||
| const { width, height } = popover.getBoundingClientRect(); | ||
|
|
||
| const left = Math.min( | ||
| Math.max(VIEWPORT_MARGIN, anchorRect.left), | ||
| Math.max(VIEWPORT_MARGIN, win.innerWidth - width - VIEWPORT_MARGIN), | ||
| ); | ||
|
|
||
| const spaceBelow = win.innerHeight - anchorRect.bottom; | ||
| // Open upward only when that genuinely has more room, so a popover taller | ||
| // than either side still lands on the roomier one instead of clipping. | ||
| const openUpward = | ||
| spaceBelow < height + VIEWPORT_MARGIN && anchorRect.top > spaceBelow; | ||
| const top = openUpward | ||
| ? Math.max(VIEWPORT_MARGIN, anchorRect.top - height - 4) | ||
| : anchorRect.bottom + 4; | ||
|
|
||
| popover.style.left = `${left}px`; | ||
| popover.style.top = `${top}px`; | ||
| }; | ||
|
|
||
| type PopoverOptions = { | ||
| plugin: DiscourseGraphPlugin; | ||
| file: TFile; | ||
| anchor: HTMLElement; | ||
| relationCount: number; | ||
| }; | ||
|
|
||
| /** | ||
| * Reuses RelationshipSection so it cannot disagree with the sidebar panel. | ||
| * Only one is open at a time. | ||
| */ | ||
| class DiscourseContextPopover { | ||
| private containerEl: HTMLElement; | ||
| private root: Root; | ||
| private plugin: DiscourseGraphPlugin; | ||
| private win: Window; | ||
| private reposition: () => void = () => {}; | ||
| private resizeObserver: ResizeObserver | null = null; | ||
| private emptyEl: HTMLElement | null = null; | ||
| private cleanupListeners: (() => void)[] = []; | ||
|
|
||
| constructor({ plugin, file, anchor, relationCount }: PopoverOptions) { | ||
| this.plugin = plugin; | ||
| const doc = anchor.ownerDocument; | ||
| this.win = doc.defaultView ?? window; | ||
| this.containerEl = doc.body.createDiv({ cls: POPOVER_CLASS }); | ||
| this.containerEl.addClass( | ||
| "fixed", | ||
| "z-50", | ||
| "max-h-[60vh]", | ||
| "w-80", | ||
| "overflow-y-auto", | ||
| "rounded-md", | ||
| "border", | ||
| "border-solid", | ||
| "border-[var(--background-modifier-border)]", | ||
| "bg-[var(--background-primary)]", | ||
| "p-3", | ||
| "shadow-lg", | ||
| ); | ||
|
|
||
| // CurrentRelationships renders nothing when empty, leaving a bare button. | ||
| if (relationCount === 0) { | ||
| this.emptyEl = this.containerEl.createDiv({ | ||
| cls: "mb-2 text-sm text-[var(--text-muted)]", | ||
| text: EMPTY_MESSAGE, | ||
| }); | ||
| // It lives outside React, so RelationshipSection cannot clear it when the | ||
| // first relation is added from this very popover. | ||
| this.cleanupListeners.push( | ||
| plugin.relationsIndex.onChange(() => { | ||
| if (countRelationsForFile(plugin, file) === 0) return; | ||
| this.emptyEl?.remove(); | ||
| this.emptyEl = null; | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| const reactHost = this.containerEl.createDiv(); | ||
| this.root = createRoot(reactHost); | ||
| this.root.render( | ||
| <PluginProvider plugin={this.plugin}> | ||
| <RelationshipSection activeFile={file} /> | ||
| </PluginProvider>, | ||
| ); | ||
|
|
||
| // A React 18 root commits async, so measure again after paint and on resize. | ||
| positionPopover(this.containerEl, anchor); | ||
| this.reposition = () => positionPopover(this.containerEl, anchor); | ||
| this.win.requestAnimationFrame(this.reposition); | ||
| this.resizeObserver = new ResizeObserver(this.reposition); | ||
| this.resizeObserver.observe(this.containerEl); | ||
|
|
||
| this.registerDismissListeners(); | ||
| } | ||
|
|
||
| private registerDismissListeners(): void { | ||
| const doc = this.containerEl.ownerDocument; | ||
| const closeIfOutside = (event: MouseEvent): void => { | ||
| if (this.containerEl.contains(event.target as Node)) return; | ||
| // AbstractInputSuggest mounts its list on body, so a click picking a node | ||
| // for a new relation would otherwise dismiss the popover behind it. | ||
| if ((event.target as Element)?.closest?.(".suggestion-container")) return; | ||
| this.close(); | ||
| }; | ||
|
trangdoan982 marked this conversation as resolved.
|
||
| const closeOnEscape = (event: KeyboardEvent): void => { | ||
| if (event.key !== "Escape") return; | ||
| event.preventDefault(); | ||
| this.close(); | ||
| }; | ||
| // Scrolling the note dismisses; scrolling the popover's own content must not. | ||
| const closeOnScroll = (event: Event): void => { | ||
| if (this.containerEl.contains(event.target as Node)) return; | ||
| this.close(); | ||
| }; | ||
|
|
||
| // Deferred so the opening click is not read as an outside click. | ||
| const attach = this.win.setTimeout(() => { | ||
| doc.addEventListener("click", closeIfOutside, true); | ||
| }, 0); | ||
|
|
||
| doc.addEventListener("keydown", closeOnEscape); | ||
| // Capture phase: scrolling happens inside panes, not on window. | ||
| doc.addEventListener("scroll", closeOnScroll, true); | ||
|
|
||
| this.cleanupListeners.push(() => { | ||
| this.win.clearTimeout(attach); | ||
| doc.removeEventListener("click", closeIfOutside, true); | ||
| doc.removeEventListener("keydown", closeOnEscape); | ||
| doc.removeEventListener("scroll", closeOnScroll, true); | ||
| }); | ||
| } | ||
|
|
||
| close(): void { | ||
| for (const cleanup of this.cleanupListeners) cleanup(); | ||
| this.cleanupListeners = []; | ||
| this.resizeObserver?.disconnect(); | ||
| this.resizeObserver = null; | ||
| // Deferred: unmounting during React's event handling warns. | ||
| const root = this.root; | ||
| this.win.setTimeout(() => root.unmount(), 0); | ||
| this.containerEl.remove(); | ||
| if (activePopover === this) activePopover = null; | ||
| } | ||
| } | ||
|
|
||
| let activePopover: DiscourseContextPopover | null = null; | ||
|
|
||
| export const openDiscourseContextPopover = (options: PopoverOptions): void => { | ||
| activePopover?.close(); | ||
| activePopover = new DiscourseContextPopover(options); | ||
| }; | ||
|
|
||
| export const closeDiscourseContextPopover = (): void => { | ||
| activePopover?.close(); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { setIcon, setTooltip } from "obsidian"; | ||
|
|
||
| type InfoTooltipProps = { | ||
| content: string; | ||
| }; | ||
|
|
||
| export const InfoTooltip = ({ content }: InfoTooltipProps) => ( | ||
| <button | ||
| ref={(el) => { | ||
| if (el) setTooltip(el, content); | ||
| }} | ||
| className="clickable-icon text-muted hover:text-normal flex h-4 w-4 items-center justify-center" | ||
| > | ||
| <div ref={(el) => (el && setIcon(el, "info")) || undefined} /> | ||
| </button> | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { setIcon, setTooltip, TFile } from "obsidian"; | ||
| import type { DiscourseNode } from "~/types"; | ||
|
|
||
| /** Marks a badge so a re-run can find and update it. */ | ||
| export const DISCOURSE_CONTEXT_BADGE_CLASS = "dg-discourse-context-badge"; | ||
| const BADGE_COUNT_CLASS = "dg-discourse-context-badge-count"; | ||
| const BADGE_PATH_ATTR = "data-dg-path"; | ||
|
|
||
| export type DiscourseContextBadgeProps = { | ||
| file: TFile; | ||
| nodeType: DiscourseNode; | ||
| relationCount: number; | ||
| onActivate: (args: { file: TFile; anchor: HTMLElement }) => void; | ||
| }; | ||
|
|
||
| const badgeTooltip = ({ | ||
| nodeType, | ||
| relationCount, | ||
| }: Pick<DiscourseContextBadgeProps, "nodeType" | "relationCount">): string => { | ||
| const relations = relationCount === 1 ? "relation" : "relations"; | ||
| return `${nodeType.name}: ${relationCount} ${relations} — open discourse context`; | ||
| }; | ||
|
|
||
| /** | ||
| * Plain DOM, not React, so both render paths share it without mounting a React | ||
| * root per link. | ||
| */ | ||
| export const createDiscourseContextBadge = ({ | ||
| file, | ||
| nodeType, | ||
| relationCount, | ||
| onActivate, | ||
| }: DiscourseContextBadgeProps): HTMLElement => { | ||
| const badge = createSpan(); | ||
| badge.className = `${DISCOURSE_CONTEXT_BADGE_CLASS} inline-flex items-center gap-0.5 align-middle ml-1 px-1 rounded cursor-pointer select-none text-[10px] leading-none text-[var(--text-muted)] hover:text-[var(--text-normal)] hover:bg-[var(--background-modifier-hover)] transition-colors duration-150`; | ||
|
|
||
| const icon = badge.createSpan({ | ||
| cls: "inline-flex items-center [&>svg]:h-3 [&>svg]:w-3", | ||
| }); | ||
| setIcon(icon, "network"); | ||
|
|
||
| badge.createSpan({ cls: BADGE_COUNT_CLASS, text: String(relationCount) }); | ||
|
|
||
| const label = badgeTooltip({ nodeType, relationCount }); | ||
| setTooltip(badge, label); | ||
| badge.setAttribute("aria-label", label); | ||
| badge.setAttribute("role", "button"); | ||
| badge.setAttribute("tabindex", "0"); | ||
| badge.setAttribute(BADGE_PATH_ATTR, file.path); | ||
|
|
||
| const activate = (event: Event): void => { | ||
| // Do not follow the link the badge sits next to. | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| onActivate({ file, anchor: badge }); | ||
| }; | ||
|
|
||
| // Otherwise the caret moves, expanding the raw [[...]] under the popover. | ||
| badge.addEventListener("mousedown", (event: MouseEvent) => { | ||
| event.preventDefault(); | ||
| }); | ||
| badge.addEventListener("click", activate); | ||
| badge.addEventListener("keydown", (event: KeyboardEvent) => { | ||
| if (event.key !== "Enter" && event.key !== " ") return; | ||
| activate(event); | ||
| }); | ||
|
|
||
| return badge; | ||
| }; | ||
|
|
||
| /** The badge's target, so a refresh can tell an update from a replacement. */ | ||
| export const badgeTargetPath = (badge: Element): string | null => | ||
| badge.getAttribute(BADGE_PATH_ATTR); | ||
|
|
||
| /** In place, so an open popover anchored to it keeps a connected anchor. */ | ||
| export const updateDiscourseContextBadge = ({ | ||
| badge, | ||
| nodeType, | ||
| relationCount, | ||
| }: { | ||
| badge: HTMLElement; | ||
| nodeType: DiscourseNode; | ||
| relationCount: number; | ||
| }): void => { | ||
| const count = badge.querySelector(`.${BADGE_COUNT_CLASS}`); | ||
| if (count) count.textContent = String(relationCount); | ||
|
|
||
| const label = badgeTooltip({ nodeType, relationCount }); | ||
| setTooltip(badge, label); | ||
| badge.setAttribute("aria-label", label); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.