From eb140ad5bcf3e9d9d599e1b08a4726d28cd2393a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 5 Mar 2026 16:11:51 +0100 Subject: [PATCH] fix library view TODOs: dynamic dropdowns, copy button, keyboard nav, refresh - Dynamic source/type dropdowns populated from library data instead of hardcoded - Copy button on mini cards (visible on hover, bottom-left) - Left/right arrow key navigation in preview modal - Ctrl/Cmd+F focuses the search input - Library view refreshes on library scan and default color change - Fix grid not re-rendering when view is refreshed (detached DOM element) - Preserve filter/sort state across view refreshes - Prevent accidental modal open when selecting text - Clear status bar battle points when library view opens Co-Authored-By: Claude Opus 4.6 --- src/library.ts | 97 ++++++++++++++++++++++++++++++++++---------------- src/main.ts | 10 ++++++ styles.css | 12 +++++++ 3 files changed, 88 insertions(+), 31 deletions(-) diff --git a/src/library.ts b/src/library.ts index b886ef4..bd84873 100644 --- a/src/library.ts +++ b/src/library.ts @@ -1,4 +1,4 @@ -import { ItemView, WorkspaceLeaf, Modal } from 'obsidian'; +import { ItemView, WorkspaceLeaf, Modal, Scope, setIcon, stringifyYaml, Notice } from 'obsidian'; import BeastVault from './main'; import { subTitle, hexToRgb } from './utils'; import { type RawAdversary, AdversaryCard } from './ui'; @@ -16,11 +16,18 @@ export class LibraryView extends ItemView { search: undefined }; private grid: HTMLElement; + private searchInput: HTMLInputElement; constructor(leaf: WorkspaceLeaf, private plugin: BeastVault) { super(leaf); this.icon = 'library'; this.navigation = false; + this.scope = new Scope(this.app.scope); + this.scope.register(['Mod'], 'f', (e) => { + e.preventDefault(); + this.searchInput?.focus(); + return false; + }); } everything() { @@ -55,17 +62,12 @@ export class LibraryView extends ItemView { return 'BeastVault Library'; } - // TODO: cannot copy any text from view for some reason - // TODO: add copy button to mini cards - // TODO: when popup active, add right/left navigation - // TODO: refresh on library refresh, default color change - // TODO: ctrl+f to focus on search - // FIXME: for some reason battle points are sometimes displayed async onOpen() { this.contentEl.empty(); + this.grid = null!; const controls = this.contentEl.createDiv({ cls: 'bv-fixed' }); - const searchInput = controls.createDiv('search-input-container').createEl('input', { + this.searchInput = controls.createDiv('search-input-container').createEl('input', { attr: { enterkeyhint: 'search', type: 'search', @@ -74,41 +76,37 @@ export class LibraryView extends ItemView { } }); - searchInput.addEventListener('input', (event) => { + if (this.filters.search) this.searchInput.value = this.filters.search; + + this.searchInput.addEventListener('input', (event) => { this.filters.search = (event.target as HTMLInputElement).value.trim().toLowerCase() || undefined; this.renderBlocks(); }); + const items = [...this.plugin.allAdversaries(), ...this.plugin.allEnvironments()]; - // TODO: instead of hardcoding options, scan library for existing values - + const sources = [...new Set(items.map(i => i.source ?? 'homebrew'))].sort(); const sourceDropdown = controls.createEl('select', { cls: 'dropdown' }); sourceDropdown.createEl('option', { text: 'All Sources', value: 'all' }); - sourceDropdown.createEl('option', { text: 'Core Rulebook', value: 'corebook' }); - sourceDropdown.createEl('option', { text: 'Homebrew', value: 'homebrew' }); + for (const source of sources) { + sourceDropdown.createEl('option', { text: source.charAt(0).toUpperCase() + source.slice(1), value: source }); + } + sourceDropdown.value = this.filters.source; sourceDropdown.addEventListener('change', (event) => { this.filters.source = (event.target as HTMLSelectElement).value; this.renderBlocks(); }); + const types = [...new Set(items.map(i => i.type).filter(Boolean).map(t => t!.toLowerCase().split(/\s/)[0]))].sort(); const typeDropdown = controls.createEl('select', { cls: 'dropdown' }); typeDropdown.createEl('option', { text: 'All Types', value: 'all' }); typeDropdown.createEl('option', { text: 'Adversaries', value: 'adversaries' }); typeDropdown.createEl('option', { text: 'Environments', value: 'environments' }); - typeDropdown.createEl('option', { text: 'Minion', value: 'minion' }); - typeDropdown.createEl('option', { text: 'Horde', value: 'horde' }); - typeDropdown.createEl('option', { text: 'Standard', value: 'standard' }); - typeDropdown.createEl('option', { text: 'Skulk', value: 'skulk' }); - typeDropdown.createEl('option', { text: 'Leader', value: 'leader' }); - typeDropdown.createEl('option', { text: 'Ranged', value: 'ranged' }); - typeDropdown.createEl('option', { text: 'Bruiser', value: 'bruiser' }); - typeDropdown.createEl('option', { text: 'Solo', value: 'solo' }); - typeDropdown.createEl('option', { text: 'Social', value: 'social' }); - // typeDropdown.createEl('option', { text: 'Social Environment' }); // TODO - typeDropdown.createEl('option', { text: 'Traversal', value: 'traversal' }); - typeDropdown.createEl('option', { text: 'Exploration', value: 'exploration' }); - typeDropdown.createEl('option', { text: 'Event', value: 'event' }); + for (const type of types) { + typeDropdown.createEl('option', { text: type.charAt(0).toUpperCase() + type.slice(1), value: type }); + } + typeDropdown.value = this.filters.type; typeDropdown.addEventListener('change', (event) => { this.filters.type = (event.target as HTMLSelectElement).value; @@ -117,7 +115,7 @@ export class LibraryView extends ItemView { const byTier = controls.createDiv({ text: 'Tiers: ' }); for (let i = 1; i <= 4; i++) { - const button = byTier.createEl('button', { cls: 'bv-tier-button bv-inactive', text: `${i}` }); + const button = byTier.createEl('button', { cls: `bv-tier-button ${this.filters.tier[i] ? '' : 'bv-inactive'}`, text: `${i}` }); button.addEventListener('click', () => { button.toggleClass('bv-inactive', this.filters.tier[i]); this.filters.tier[i] = !this.filters.tier[i]; @@ -132,6 +130,7 @@ export class LibraryView extends ItemView { sortDropdown.createEl('option', { text: 'Name', value: 'name' }); sortDropdown.createEl('option', { text: 'Type', value: 'type' }); sortDropdown.createEl('option', { text: 'Source', value: 'source' }); + sortDropdown.value = this.sortBy; sortBy.addEventListener('change', (event) => { this.sortBy = (event.target as HTMLSelectElement).value as SortBy; @@ -139,20 +138,38 @@ export class LibraryView extends ItemView { }); this.renderBlocks(); + this.plugin.updateStatusBar(); } renderBlocks() { this.grid ??= this.contentEl.createDiv('bv-library'); this.grid.empty(); const everything = this.everything(); - for (const item of everything) { + for (const [i, item] of everything.entries()) { const card = this.grid.createDiv({ cls: 'callout bv-statblock bv-library-item', attr: { 'data-callout': 'daggerheart-card' } }); card.createDiv({ cls: 'callout-title bv-larger' }).createEl('b', { text: item.name }); card.createDiv({ text: subTitle(item.tier, item.type) }) card.createEl('p', { cls: 'bv-smaller bv-muted' }).createEl('i', { text: item.desc || '' }) card.createDiv({ cls: 'bv-source', text: `[${item.source}]` }); + + const copyBtn = card.createEl('button', { + cls: 'clickable-icon bv-mini-copy', + attr: { 'aria-label': 'Copy to clipboard' } + }); + setIcon(copyBtn, 'copy'); + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const copy = { ...item }; + copy.id = Math.random().toString(36).slice(2); + delete copy.source; + delete copy.raw; + void navigator.clipboard.writeText(`\`\`\`daggerheart\n${item.raw ? item.raw : stringifyYaml(copy)}\`\`\`\n`); + new Notice('Copied to clipboard'); + }); + card.addEventListener('click', () => { - new AdversaryPreviewModal(this.plugin, item).open(); + if (window.getSelection()?.toString()) return; + new AdversaryPreviewModal(this.plugin, everything, i).open(); }) const color = this.plugin.state.settings.defaultColor; @@ -166,9 +183,27 @@ export class LibraryView extends ItemView { } class AdversaryPreviewModal extends Modal { - constructor(plugin: BeastVault, adv: RawAdversary) { + private index: number; + + constructor(private plugin: BeastVault, private items: RawAdversary[], index: number) { super(plugin.app); - const card = new AdversaryCard(this.contentEl, adv, plugin, true); + this.index = index; + this.scope.register([], 'ArrowLeft', () => { this.navigate(-1); return false; }); + this.scope.register([], 'ArrowRight', () => { this.navigate(1); return false; }); + this.renderCard(); + } + + private navigate(delta: number) { + const newIndex = this.index + delta; + if (newIndex >= 0 && newIndex < this.items.length) { + this.index = newIndex; + this.renderCard(); + } + } + + private renderCard() { + this.contentEl.empty(); + const card = new AdversaryCard(this.contentEl, this.items[this.index], this.plugin, true); card.render(); } } diff --git a/src/main.ts b/src/main.ts index d638d71..4064f84 100644 --- a/src/main.ts +++ b/src/main.ts @@ -191,6 +191,7 @@ export default class BeastVault extends Plugin { } } + this.refreshLibraryViews(); return newLibrary; } @@ -332,6 +333,15 @@ export default class BeastVault extends Plugin { for (const [block] of this.activeBlocks) { block.render(); } + for (const leaf of this.app.workspace.getLeavesOfType(LIBRARY_VIEW_TYPE)) { + (leaf.view as LibraryView).renderBlocks(); + } + } + + refreshLibraryViews() { + for (const leaf of this.app.workspace.getLeavesOfType(LIBRARY_VIEW_TYPE)) { + void (leaf.view as LibraryView).onOpen(); + } } updateCard(keys: (string | number)[], value: string | number) { diff --git a/styles.css b/styles.css index 35af6a4..313b966 100644 --- a/styles.css +++ b/styles.css @@ -104,6 +104,18 @@ input[type=checkbox].bv-slot { margin-top: auto; } +.bv-mini-copy { + position: absolute; + bottom: var(--size-2-2); + left: var(--size-2-2); + opacity: 0; + transition: opacity 0.1s; +} + +.bv-library-item:hover .bv-mini-copy { + opacity: var(--icon-opacity); +} + .bv-inactive { color: var(--text-faint) !important; }