diff --git a/src/inspector.ts b/src/inspector.ts new file mode 100644 index 0000000..5e496d9 --- /dev/null +++ b/src/inspector.ts @@ -0,0 +1,79 @@ +import { ItemView, setIcon, TFile, WorkspaceLeaf } from "obsidian"; +import type SequentialNoteNavigator from "./main"; + +export const SEQUENCE_INSPECTOR_VIEW_TYPE = "sequencer-inspector"; + +export type SequenceNode = { + file: TFile; + isCurrent: boolean; +}; + +export class SequenceInspectorView extends ItemView { + constructor( + leaf: WorkspaceLeaf, + private plugin: SequentialNoteNavigator, + ) { + super(leaf); + } + + getViewType(): string { + return SEQUENCE_INSPECTOR_VIEW_TYPE; + } + + getDisplayText(): string { + return "Sequence inspector"; + } + + getIcon(): string { + return "signpost"; + } + + async onOpen(): Promise { + this.render(); + } + + render(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("seq-inspector"); + + const currentFile = this.plugin.getCurrentMarkdownFile(); + + if (!currentFile) { + contentEl.createEl("p", { + cls: "seq-inspector-empty", + text: "Open a sequenced note to inspect its chain.", + }); + return; + } + + const nodes = this.plugin.getSequenceNodes(currentFile); + if (nodes.length === 0) { + contentEl.createEl("p", { + cls: "seq-inspector-empty", + text: "This note is not connected to a sequence.", + }); + return; + } + + const listEl = contentEl.createDiv({ cls: "seq-inspector-chain" }); + for (const node of nodes) { + const rowEl = listEl.createDiv({ + cls: `seq-inspector-node${node.isCurrent ? " is-current" : ""}`, + }); + const markerEl = rowEl.createDiv({ cls: "seq-inspector-marker" }); + setIcon(markerEl, node.isCurrent ? "circle-dot" : "circle"); + + const buttonEl = rowEl.createEl("button", { + cls: "seq-inspector-note", + text: node.file.path, + }); + buttonEl.ariaLabel = `Open ${node.file.basename}`; + buttonEl.onpointerdown = async (event) => { + event.preventDefault(); + event.stopPropagation(); + await this.plugin.openSequenceFile(node.file); + }; + } + } +} diff --git a/src/main.ts b/src/main.ts index 06d68ea..8097966 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,13 @@ -import { CachedMetadata, FrontMatterCache, MarkdownView, Notice, Plugin, setIcon, TFile } from "obsidian"; +import { CachedMetadata, FrontMatterCache, MarkdownView, Notice, Plugin, setIcon, TFile, WorkspaceLeaf } from "obsidian"; +import { SequenceInspectorView, SEQUENCE_INSPECTOR_VIEW_TYPE } from "./inspector"; +import type { SequenceNode } from "./inspector"; import { ConfirmSequenceDeleteModal, InsertSequenceNoteModal, LinkToFileModal } from "./modal"; import { DEFAULT_SETTINGS, SequencerSettings, SequencerSettingTab } from "./settings"; export default class SequentialNoteNavigator extends Plugin { settings: SequencerSettings; + private lastFocusedMarkdownFile: TFile | null = null; + private lastFocusedMarkdownLeaf: WorkspaceLeaf | null = null; private pluginDeletedPaths = new Set(); async onload() { @@ -12,12 +16,21 @@ export default class SequentialNoteNavigator extends Plugin { await this.loadSettings(); this.addSettingTab(new SequencerSettingTab(this.app, this)); + this.registerView( + SEQUENCE_INSPECTOR_VIEW_TYPE, + (leaf) => new SequenceInspectorView(leaf, this), + ); this.registerEvent( - this.app.workspace.on("active-leaf-change", () => this.addNavigationButtons()) + this.app.workspace.on("active-leaf-change", () => { + this.rememberActiveMarkdownView(); + this.addNavigationButtons(); + this.refreshSequenceInspectors(); + }) ); // run on startup + this.rememberActiveMarkdownView(); this.addNavigationButtons(); this.registerEvent( @@ -26,9 +39,14 @@ export default class SequentialNoteNavigator extends Plugin { if (file.path === activeFile?.path) { this.addNavigationButtons(); } + this.refreshSequenceInspectors(); }) ); + this.addRibbonIcon("signpost", "Open sequence inspector", () => { + void this.activateSequenceInspector(); + }); + this.registerEvent( this.app.metadataCache.on("deleted", (file, prevCache) => { void this.handleDeletedFile(file, prevCache); @@ -47,6 +65,14 @@ export default class SequentialNoteNavigator extends Plugin { callback: () => this.insertLink("next"), }); + this.addCommand({ + id: "open-sequence-inspector", + name: "Open sequence inspector", + callback: () => { + void this.activateSequenceInspector(); + }, + }); + this.addCommand({ id: "insert-note-before-current", name: "Insert note before current note", @@ -78,14 +104,6 @@ export default class SequentialNoteNavigator extends Plugin { void this.removeCurrentNoteFromSequence(); }, }); - - this.addCommand({ - id: "unlink-current-note-from-sequence", - name: "Unlink current note from sequence", - callback: () => { - void this.removeCurrentNoteFromSequence(); - }, - }); } async loadSettings() { @@ -100,6 +118,90 @@ export default class SequentialNoteNavigator extends Plugin { console.debug("Unloading Obsidian Sequencer plugin..."); } + getCurrentMarkdownFile(): TFile | null { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (view?.file) { + this.lastFocusedMarkdownFile = view.file; + this.lastFocusedMarkdownLeaf = view.leaf; + return view.file; + } + + return this.lastFocusedMarkdownFile; + } + + rememberActiveMarkdownView() { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view?.file) return; + + this.lastFocusedMarkdownFile = view.file; + this.lastFocusedMarkdownLeaf = view.leaf; + } + + async openSequenceFile(file: TFile) { + const activeMarkdownView = this.app.workspace.getActiveViewOfType(MarkdownView); + const leaf = activeMarkdownView?.leaf ?? this.lastFocusedMarkdownLeaf ?? this.app.workspace.getLeaf(false); + + await leaf.openFile(file); + this.lastFocusedMarkdownFile = file; + this.lastFocusedMarkdownLeaf = leaf; + this.refreshSequenceInspectors(); + } + + async activateSequenceInspector() { + const existingLeaves = this.app.workspace.getLeavesOfType(SEQUENCE_INSPECTOR_VIEW_TYPE); + if (existingLeaves.length > 0) { + await this.app.workspace.revealLeaf(existingLeaves[0]!); + this.refreshSequenceInspectors(); + return; + } + + const leaf = this.app.workspace.getRightLeaf(false); + if (!leaf) return; + + await leaf.setViewState({ type: SEQUENCE_INSPECTOR_VIEW_TYPE, active: true }); + await this.app.workspace.revealLeaf(leaf); + } + + refreshSequenceInspectors() { + for (const leaf of this.app.workspace.getLeavesOfType(SEQUENCE_INSPECTOR_VIEW_TYPE)) { + if (leaf.view instanceof SequenceInspectorView) { + leaf.view.render(); + } + } + } + + getSequenceNodes(currentFile: TFile): SequenceNode[] { + const currentFrontmatter = this.getFrontmatter(currentFile); + if (!currentFrontmatter?.prev && !currentFrontmatter?.next) { + return []; + } + + let firstFile = currentFile; + const reverseVisited = new Set([currentFile.path]); + let previousFile = this.resolveSequenceLink(currentFile, "prev"); + + while (previousFile && !reverseVisited.has(previousFile.path)) { + firstFile = previousFile; + reverseVisited.add(previousFile.path); + previousFile = this.resolveSequenceLink(previousFile, "prev"); + } + + const nodes: SequenceNode[] = []; + const forwardVisited = new Set(); + let nextFile: TFile | null = firstFile; + + while (nextFile && !forwardVisited.has(nextFile.path)) { + nodes.push({ + file: nextFile, + isCurrent: nextFile.path === currentFile.path, + }); + forwardVisited.add(nextFile.path); + nextFile = this.resolveSequenceLink(nextFile, "next"); + } + + return nodes; + } + addNavigationButtons() { const view = this.app.workspace.getActiveViewOfType(MarkdownView); if (!view) return; diff --git a/styles.css b/styles.css index 72cd355..5ee3bc2 100644 --- a/styles.css +++ b/styles.css @@ -10,3 +10,94 @@ If your plugin does not need CSS, delete this file. .seq-nav-button { padding: 4px; } + +.seq-inspector { + align-items: center; + box-sizing: border-box; + display: flex; + justify-content: center; + min-height: 100%; + padding: 12px; + width: 100%; +} + +.seq-inspector-empty { + color: var(--text-muted); + font-size: var(--font-ui-small); + margin: 0; +} + +.seq-inspector-chain { + display: flex; + flex-direction: column; + gap: 0; + margin-inline: auto; + max-width: 280px; + width: 100%; +} + +.seq-inspector-node { + display: grid; + grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); + grid-template-rows: auto; + padding: 8px 0 14px; + position: relative; +} + +.seq-inspector-node::before { + background: var(--background-modifier-border); + bottom: -2px; + content: ""; + left: calc(50% - 1px); + position: absolute; + top: 28px; + width: 2px; +} + +.seq-inspector-node:last-child::before { + display: none; +} + +.seq-inspector-marker { + align-items: center; + color: var(--text-muted); + display: flex; + grid-column: 2; + height: 24px; + justify-content: center; + position: relative; + z-index: 1; +} + +.seq-inspector-node.is-current .seq-inspector-marker { + color: var(--interactive-accent); +} + +.seq-inspector-note { + background: transparent; + border: 0; + box-shadow: none; + color: var(--text-normal); + cursor: pointer; + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); + grid-column: 3; + height: auto; + justify-self: start; + line-height: 1.3; + margin: 0; + max-width: 100%; + overflow: hidden; + padding: 2px 4px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.seq-inspector-note:hover { + color: var(--text-accent); +} + +.seq-inspector-node.is-current .seq-inspector-note { + color: var(--text-accent); +}