Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
174 changes: 174 additions & 0 deletions src/library.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
16 changes: 15 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string, number> = {
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
import { App, PluginSettingTab, Setting } from 'obsidian';
import BeastVault from './main';

export type PluginSettings = {
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 38 additions & 16 deletions src/ui.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -58,10 +58,6 @@ export type Adversary = Omit<RawAdversary, 'source' | 'raw'> & {
id: string;
}

function subTitle(tier?: number, type?: string) {
return (tier ? `Tier ${tier} ` : '') + (type ? type : '');
}

export class AdversaryModal extends SuggestModal<RawAdversary> {
constructor(app: App, private editor: Editor, private library: RawAdversary[]) {
super(app);
Expand All @@ -82,7 +78,7 @@ export class AdversaryModal extends SuggestModal<RawAdversary> {
}

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;
Expand All @@ -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 ?? '/';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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?.();
};

Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 : '');
}

Loading