diff --git a/package.json b/package.json index d6d4ef8..0eeb633 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "devDependencies": { "@airjp73/dice-notation": "^2.2.2", "@types/node": "^24.0.0", - "builtin-modules": "3.3.0", "esbuild": "0.17.3", "obsidian": "latest", "tslib": "^2.4.0", diff --git a/src/library.ts b/src/library.ts new file mode 100644 index 0000000..b886ef4 --- /dev/null +++ b/src/library.ts @@ -0,0 +1,174 @@ +import { ItemView, WorkspaceLeaf, Modal } from 'obsidian'; +import BeastVault from './main'; +import { subTitle, hexToRgb } from './utils'; +import { type RawAdversary, AdversaryCard } from './ui'; + +export const LIBRARY_VIEW_TYPE = 'beastvault-library-view'; + +type SortBy = 'tier' | 'type' | 'source' | 'name'; + +export class LibraryView extends ItemView { + private sortBy: SortBy = 'tier'; + private filters: { tier: boolean[]; type: string; source: string; search?: string } = { + tier: [false /* this is a fake one */, false, false, false, false], + type: 'all', + source: 'all', + search: undefined + }; + private grid: HTMLElement; + + constructor(leaf: WorkspaceLeaf, private plugin: BeastVault) { + super(leaf); + this.icon = 'library'; + this.navigation = false; + } + + everything() { + return [...this.plugin.allAdversaries(), ...this.plugin.allEnvironments()].sort((a, b) => { + // Default sort: first by Tier, then by Name + const [aTier, bTier] = [a.tier ?? 999, b.tier ?? 999]; + const comp = aTier - bTier; + return comp !== 0 ? comp : a.name!.localeCompare(b.name!); + }).sort((a, b) => { + if (this.sortBy === 'tier') return (a.tier ?? 999) - (b.tier ?? 999); + if (this.sortBy === 'type') return (a.type ?? 'zzz').localeCompare(b.type ?? 'zzz'); + if (this.sortBy === 'source') return (a.source ?? 'homebrew').localeCompare(b.source ?? 'homebrew'); + if (this.sortBy === 'name') return a.name!.localeCompare(b.name!); + return 0; + }).filter(item => { // user filter + const tier = this.filters.tier.every(x => !x) || (item.tier != null && this.filters.tier[item.tier]); + const source = this.filters.source === 'all' || (item.source ?? 'homebrew') === this.filters.source; + const type = this.filters.type === 'all' || + (this.filters.type == 'adversaries' && (item.hp != null || item.stress != null)) || + (this.filters.type == 'environments' && item.hp == null && item.stress == null) || + item.type != null && item.type.toLowerCase().startsWith(this.filters.type); + const search = !this.filters.search || item.name!.toLowerCase().includes(this.filters.search) || (item.desc && item.desc.toLowerCase().includes(this.filters.search)); + return tier && source && type && search; + }); + } + + getViewType() { + return LIBRARY_VIEW_TYPE; + } + + getDisplayText() { + 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(); + const controls = this.contentEl.createDiv({ cls: 'bv-fixed' }); + + const searchInput = controls.createDiv('search-input-container').createEl('input', { + attr: { + enterkeyhint: 'search', + type: 'search', + spellcheck: 'false', + placeholder: 'Search...', + } + }); + + searchInput.addEventListener('input', (event) => { + this.filters.search = (event.target as HTMLInputElement).value.trim().toLowerCase() || undefined; + this.renderBlocks(); + }); + + + // TODO: instead of hardcoding options, scan library for existing values + + 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' }); + + sourceDropdown.addEventListener('change', (event) => { + this.filters.source = (event.target as HTMLSelectElement).value; + this.renderBlocks(); + }); + + 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' }); + + typeDropdown.addEventListener('change', (event) => { + this.filters.type = (event.target as HTMLSelectElement).value; + this.renderBlocks(); + }); + + 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}` }); + button.addEventListener('click', () => { + button.toggleClass('bv-inactive', this.filters.tier[i]); + this.filters.tier[i] = !this.filters.tier[i]; + this.renderBlocks(); + }) + } + + + const sortBy = controls.createDiv({ text: 'Sort by: ' }); + const sortDropdown = sortBy.createEl('select', { cls: 'dropdown' }); + sortDropdown.createEl('option', { text: 'Tier', value: 'tier' }); + sortDropdown.createEl('option', { text: 'Name', value: 'name' }); + sortDropdown.createEl('option', { text: 'Type', value: 'type' }); + sortDropdown.createEl('option', { text: 'Source', value: 'source' }); + + sortBy.addEventListener('change', (event) => { + this.sortBy = (event.target as HTMLSelectElement).value as SortBy; + this.renderBlocks(); + }); + + this.renderBlocks(); + } + + renderBlocks() { + this.grid ??= this.contentEl.createDiv('bv-library'); + this.grid.empty(); + const everything = this.everything(); + for (const item of everything) { + 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}]` }); + card.addEventListener('click', () => { + new AdversaryPreviewModal(this.plugin, item).open(); + }) + + const color = this.plugin.state.settings.defaultColor; + card.style.setProperty('--callout-color', hexToRgb(color)); + card.style.setProperty('--checkbox-color', color) + card.style.setProperty('--checkbox-color-hover', color) + } + } + + async onClose() { } +} + +class AdversaryPreviewModal extends Modal { + constructor(plugin: BeastVault, adv: RawAdversary) { + super(plugin.app); + const card = new AdversaryCard(this.contentEl, adv, plugin, true); + card.render(); + } +} diff --git a/src/main.ts b/src/main.ts index 34518aa..5d8f912 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ -import { Editor, Plugin, setTooltip, parseYaml, Menu, Notice, type TFolder, debounce, type Debouncer } from 'obsidian'; +import { Editor, Plugin, setTooltip, parseYaml, Menu, Notice, type TFolder, type WorkspaceLeaf, debounce, type Debouncer } from 'obsidian'; import { SettingTab, type PluginSettings, DEFAULT_SETTINGS } from './settings'; import { ADV_LIBRARY, ENV_LIBRARY, ADV_TEMPLATE, ENV_TEMPLATE, walkFolder } from './utils'; import { AdversaryCard, AdversaryModal, type RawAdversary } from './ui'; +import { LIBRARY_VIEW_TYPE, LibraryView } from './library'; export type PluginState = { settings: PluginSettings; @@ -42,6 +43,18 @@ export default class BeastVault extends Plugin { this.battlePoints.setText(''); } + async openLibraryView() { + const leaves = this.app.workspace.getLeavesOfType(LIBRARY_VIEW_TYPE); + let leaf: WorkspaceLeaf | null; + if (leaves.length > 0) { + leaf = leaves[0]; + } else { + leaf = this.app.workspace.getLeaf('tab'); + await leaf?.setViewState({ type: LIBRARY_VIEW_TYPE, active: true }); + } + await this.app.workspace.revealLeaf(leaf); + } + calculateBattlePoints(filePath: string): number { let totalBP = 0; const bpPerType: Record = { @@ -149,6 +162,7 @@ export default class BeastVault extends Plugin { this.battlePoints = this.addStatusBarItem(); this.registerEvent(this.app.workspace.on('active-leaf-change', () => this.updateStatusBar())); this.app.workspace.onLayoutReady(() => this.scanLibrary(false, 'no')); + this.registerView(LIBRARY_VIEW_TYPE, (leaf) => new LibraryView(leaf, this)) this.updateState = debounce(() => this.saveData(this.state), 1000, true); this.registerMarkdownCodeBlockProcessor("daggerheart", (src, el, ctx) => { diff --git a/src/settings.ts b/src/settings.ts index ffb0c0b..053acd9 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,4 @@ -import { App, PluginSettingTab, Setting, Notice } from 'obsidian'; +import { App, PluginSettingTab, Setting } from 'obsidian'; import BeastVault from './main'; export type PluginSettings = { @@ -91,7 +91,7 @@ export class SettingTab extends PluginSettingTab { .setTooltip('View library') .onClick(async () => { await this.plugin.scanLibrary(true, 'no'); - new Notice('Library viewer under construction!'); + await this.plugin.openLibraryView(); })); new Setting(containerEl) diff --git a/src/ui.ts b/src/ui.ts index f5a4928..e4fe0d5 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,7 +1,7 @@ import { App, Editor, SuggestModal, Notice, MarkdownRenderChild, stringifyYaml, setIcon, MarkdownRenderer } from 'obsidian'; import { roll } from '@airjp73/dice-notation'; import BeastVault from './main'; -import { hexToRgb, DICE_PATTERN, processAdversary } from './utils'; +import { hexToRgb, DICE_PATTERN, processAdversary, subTitle } from './utils'; type Feature = { name?: string; @@ -58,10 +58,6 @@ export type Adversary = Omit & { id: string; } -function subTitle(tier?: number, type?: string) { - return (tier ? `Tier ${tier} ` : '') + (type ? type : ''); -} - export class AdversaryModal extends SuggestModal { constructor(app: App, private editor: Editor, private library: RawAdversary[]) { super(app); @@ -82,7 +78,7 @@ export class AdversaryModal extends SuggestModal { } onChooseSuggestion(adv: RawAdversary, evt: MouseEvent | KeyboardEvent) { - const copy = { ... adv }; + const copy = { ...adv }; copy.id = Math.random().toString(36).slice(2); delete copy.source; delete copy.raw; @@ -98,7 +94,8 @@ export class AdversaryCard extends MarkdownRenderChild { constructor( private container: HTMLElement, public raw: RawAdversary, - private plugin: BeastVault + private plugin: BeastVault, + private preview: boolean = false ) { super(container); this.filePath = this.plugin.app.workspace.getActiveFile()?.path ?? '/'; @@ -197,11 +194,13 @@ export class AdversaryCard extends MarkdownRenderChild { slots.push(slot); } statBar.createEl('br'); - statBar.addEventListener('input', (event) => { - if (!slots.contains(event.target as HTMLInputElement)) return; - let marked = slots.reduce((sum, slot) => sum + (slot.checked ? 1 : 0), 0); - this.plugin.updateCard(keys, marked) - }); + if (!this.preview) { + statBar.addEventListener('input', (event) => { + if (!slots.contains(event.target as HTMLInputElement)) return; + let marked = slots.reduce((sum, slot) => sum + (slot.checked ? 1 : 0), 0); + this.plugin.updateCard(keys, marked) + }); + } } return slots; @@ -273,7 +272,9 @@ export class AdversaryCard extends MarkdownRenderChild { } if (slot.checked) marked++; } - this.plugin.updateCard([this.adv.id, index, 'hp'], marked) + if (!this.preview) { + this.plugin.updateCard([this.adv.id, index, 'hp'], marked) + } updateHordeSize?.(); }; @@ -283,7 +284,24 @@ export class AdversaryCard extends MarkdownRenderChild { massive?.addEventListener('click', slotMarker(4)); } - createPlusMinosButtons(card: HTMLElement, features: HTMLElement, statBlock: HTMLElement) { + createCopyButton(card: HTMLElement) { + const copy = card.createEl('button', { + cls: 'clickable-icon bv-top-corner', + attr: { 'aria-label': 'Copy to clipboard' } + }) + setIcon(copy, 'copy'); + copy.addEventListener('click', () => { + const copy = { ... this.raw }; + copy.id = Math.random().toString(36).slice(2); + delete copy.source; + delete copy.raw; + + void navigator.clipboard.writeText(`\`\`\`daggerheart\n${this.raw.raw ? this.raw.raw : stringifyYaml(copy)}\`\`\`\n`) + new Notice('Adversary copied to clipboard'); + }) + } + + createPlusMinusButtons(card: HTMLElement, features: HTMLElement, statBlock: HTMLElement) { if (!this.adv.hp && !this.adv.stress) return; const add = card.createEl('button', { cls: 'bv-top-corner clickable-icon bv-invisible', @@ -374,7 +392,11 @@ export class AdversaryCard extends MarkdownRenderChild { this.createHeader(header); this.createFeaturesAndStats(features, statBlock); - this.createPlusMinosButtons(card, features, statBlock); + if (this.preview) { + this.createCopyButton(card); + } else { + this.createPlusMinusButtons(card, features, statBlock); + } const data = this.plugin.state.cards[this.adv.id]?.color; const defaultColor = data || this.plugin.state.settings.defaultColor; @@ -387,7 +409,7 @@ export class AdversaryCard extends MarkdownRenderChild { applyColor(defaultColor); - if (this.plugin.state.settings.showColorPicker) { + if (this.plugin.state.settings.showColorPicker && !this.preview) { const colorpicker = card.createEl('input', { type: 'color', value: defaultColor, cls: 'bv-bottom-corner' }); colorpicker.addEventListener('input', () => { applyColor(colorpicker.value); diff --git a/src/utils.ts b/src/utils.ts index 5c12696..167fd9e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -122,3 +122,8 @@ export async function walkFolder(folder: TFolder, callback: (file: TFile) => Pro } } } + +export function subTitle(tier?: number, type?: string) { + return (tier ? `Tier ${tier} ` : '') + (type ? type : ''); +} + diff --git a/styles.css b/styles.css index ff362ed..35af6a4 100644 --- a/styles.css +++ b/styles.css @@ -86,3 +86,52 @@ input[type=checkbox].bv-slot { .block-language-daggerheart + .edit-block-button { opacity: var(--icon-opacity) !important; } + +.bv-library { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(20em, 1fr)); +} + +.bv-library-item { + margin: 0.5em 0.5em !important; + display: flex; + flex-direction: column; +} + +.bv-source { + text-align: right; + color: var(--text-muted); + margin-top: auto; +} + +.bv-inactive { + color: var(--text-faint) !important; +} + +.bv-tier-button { + font-family: monospace; + font-weight: bold; +} + +.bv-fixed { + position: sticky; + top: 0; + background: var(--background-primary); + z-index: 999; + padding: var(--size-4-3); + display: flex; + flex-direction: row; + align-items: center; + gap: var(--size-4-2); + flex-wrap: wrap; +} + +.bv-fixed > select { + flex-shrink: 1; + min-width: 0; +} + +.view-content:has(.bv-fixed) { + padding-top: 0; + scrollbar-gutter: stable; +}