From 81c5e4fb55e8b96e4e12e506e38bbfc38a561792 Mon Sep 17 00:00:00 2001 From: Lyova Potyomkin Date: Mon, 17 Nov 2025 00:50:21 +0200 Subject: [PATCH 1/3] initial library view implementation --- src/library.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 16 +++++++++++++++- src/settings.ts | 2 +- src/ui.ts | 6 +----- src/utils.ts | 5 +++++ styles.css | 9 +++++++++ 6 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 src/library.ts diff --git a/src/library.ts b/src/library.ts new file mode 100644 index 0000000..3ef6fd9 --- /dev/null +++ b/src/library.ts @@ -0,0 +1,43 @@ +import { ItemView, WorkspaceLeaf } from 'obsidian'; +import BeastVault from './main'; +import { subTitle, hexToRgb } from './utils'; + +export const LIBRARY_VIEW_TYPE = 'beastvault-library-view'; + +export class LibraryView extends ItemView { + constructor(leaf: WorkspaceLeaf, private plugin: BeastVault) { + super(leaf); + this.icon = 'library'; + this.navigation = false; + } + + getViewType() { + return LIBRARY_VIEW_TYPE; + } + + getDisplayText() { + return 'BeastVault Library'; + } + + // TODO: add source, add search/filter/sort by tier, source, type, name + // TODO: add a popup on hover/click with full block displayed "copy" button + // TODO: when popup active, add right/left navigation + async onOpen() { + this.contentEl.empty(); + const grid = this.contentEl.createDiv('daggerheart-library'); + const everything = [...this.plugin.allAdversaries(), ...this.plugin.allEnvironments()]; + for (const item of everything) { + const card = grid.createDiv({ cls: 'callout daggerheart library-item', attr: { 'data-callout': 'daggerheart-card' } }); + card.createDiv({ cls: 'callout-title larger' }).createEl('b', { text: item.name }); + card.createDiv({ text: subTitle(item.tier, item.type) }) + card.createEl('p', { cls: 'smaller muted' }).createEl('i', { text: item.desc }) + + 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() { } +} diff --git a/src/main.ts b/src/main.ts index 9b73ab8..df2395a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ -import { Editor, Plugin, setTooltip, parseYaml, Menu, Notice, type TFolder } from 'obsidian'; +import { Editor, Plugin, setTooltip, parseYaml, Menu, Notice, type TFolder, type WorkspaceLeaf } from 'obsidian'; import { SettingTab, type PluginSettings, DEFAULT_SETTINGS } from './settings'; import { ADV_LIBRARY, ENV_LIBRARY, ADV_TEMPLATE, ENV_TEMPLATE, processAdversary, walkFolder } from './utils'; import { AdversaryCard, AdversaryModal, type Adversary } from './ui'; +import { LIBRARY_VIEW_TYPE, LibraryView } from './library'; export type PluginState = { settings: PluginSettings; @@ -41,6 +42,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 }); + } + this.app.workspace.revealLeaf(leaf); + } + calculateBattlePoints(filePath: string): number { let totalBP = 0; const bpPerType: Record = { @@ -144,6 +157,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.registerMarkdownCodeBlockProcessor("daggerheart", (src, el, ctx) => { const adv = processAdversary(parseYaml(src) ?? {}, this.app.workspace.getActiveFile()!.path); diff --git a/src/settings.ts b/src/settings.ts index ffb0c0b..9c12e5c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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 0b16c14..737170c 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 } from './utils'; +import { hexToRgb, DICE_PATTERN, subTitle } from './utils'; type Feature = { name?: string; @@ -42,10 +42,6 @@ export type Adversary = { 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: Adversary[]) { super(app); diff --git a/src/utils.ts b/src/utils.ts index 2fc6250..255e43b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -93,3 +93,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 3713f6b..54039b8 100644 --- a/styles.css +++ b/styles.css @@ -86,3 +86,12 @@ input[type=checkbox].daggerheart-slot { .block-language-daggerheart + .edit-block-button { opacity: var(--icon-opacity) !important; } + +.daggerheart-library { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(20em, 1fr)); +} + +.library-item { + margin: 0.5em 0.5em !important; +} From 05053bfc66fe2a8037cc4f494e8c13956273050b Mon Sep 17 00:00:00 2001 From: Lyova Potyomkin Date: Mon, 24 Nov 2025 15:10:45 +0200 Subject: [PATCH 2/3] a bunch of library viewer ui --- src/library.ts | 145 ++++++++++++++++++++++++++++++++++++++++++++++--- src/main.ts | 2 +- src/ui.ts | 61 +++++++++++++++++---- styles.css | 40 ++++++++++++++ 4 files changed, 228 insertions(+), 20 deletions(-) diff --git a/src/library.ts b/src/library.ts index 3ef6fd9..8573c3c 100644 --- a/src/library.ts +++ b/src/library.ts @@ -1,16 +1,50 @@ -import { ItemView, WorkspaceLeaf } from 'obsidian'; +import { ItemView, WorkspaceLeaf, Modal } from 'obsidian'; import BeastVault from './main'; -import { subTitle, hexToRgb } from './utils'; +import { subTitle, hexToRgb, processAdversary } from './utils'; +import { type Adversary, AdversaryCard } from './ui'; export const LIBRARY_VIEW_TYPE = 'beastvault-library-view'; export class LibraryView extends ItemView { + private sortBy: 'tier' | 'type' | 'source' | 'name' = '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; } @@ -19,18 +53,105 @@ export class LibraryView extends ItemView { return 'BeastVault Library'; } - // TODO: add source, add search/filter/sort by tier, source, type, name - // TODO: add a popup on hover/click with full block displayed "copy" button + // 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 grid = this.contentEl.createDiv('daggerheart-library'); - const everything = [...this.plugin.allAdversaries(), ...this.plugin.allEnvironments()]; + const controls = this.contentEl.createDiv({ cls: '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: 'tier-button inactive', text: `${i}` }); + button.addEventListener('click', () => { + button.toggleClass('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 any; + this.renderBlocks(); + }); + + this.renderBlocks(); + } + + renderBlocks() { + this.grid ??= this.contentEl.createDiv('daggerheart-library'); + this.grid.empty(); + const everything = this.everything(); for (const item of everything) { - const card = grid.createDiv({ cls: 'callout daggerheart library-item', attr: { 'data-callout': 'daggerheart-card' } }); + const card = this.grid.createDiv({ cls: 'callout daggerheart library-item', attr: { 'data-callout': 'daggerheart-card' } }); card.createDiv({ cls: 'callout-title larger' }).createEl('b', { text: item.name }); card.createDiv({ text: subTitle(item.tier, item.type) }) - card.createEl('p', { cls: 'smaller muted' }).createEl('i', { text: item.desc }) + card.createEl('p', { cls: 'smaller muted' }).createEl('i', { text: item.desc || '' }) + card.createDiv({ cls: '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)); @@ -41,3 +162,11 @@ export class LibraryView extends ItemView { async onClose() { } } + +class AdversaryPreviewModal extends Modal { + constructor(plugin: BeastVault, adv: Adversary) { + super(plugin.app); + const card = new AdversaryCard(this.contentEl, processAdversary(adv, '/'), plugin, true); + card.render(); + } +} diff --git a/src/main.ts b/src/main.ts index df2395a..d63f2b9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -111,7 +111,7 @@ export default class BeastVault extends Plugin { if (!Array.isArray(content)) content = [content]; for (const item of content) { if (item && typeof item == 'object' && typeof item.name == 'string') { - newLibrary.push(processAdversary({ source: file.path, ...item }, file.path)); + newLibrary.push(processAdversary({ source: 'homebrew', ...item }, file.path)); } } }) diff --git a/src/ui.ts b/src/ui.ts index 737170c..071b98f 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -74,7 +74,8 @@ export class AdversaryCard extends MarkdownRenderChild { constructor( private container: HTMLElement, public adv: Adversary, - private plugin: BeastVault + private plugin: BeastVault, + private preview: boolean = false ) { super(container); this.count = this.plugin.state.cards[this.adv.id]?.count || 1; @@ -147,7 +148,7 @@ export class AdversaryCard extends MarkdownRenderChild { .replace(/\b([mM])ark a [sS]tress\b/g, "$1ark a Stress") .replace(DICE_PATTERN, `$&`), featureDiv, - this.plugin.app.workspace.getActiveFile()!.path, + this.plugin.app.workspace.getActiveFile()?.path ?? '/', this ).then(() => { // Using innerHTML like this is safe since we're only replacing tags @@ -174,11 +175,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; @@ -248,7 +251,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) + } hordeSize?.update(); }; @@ -258,7 +263,37 @@ 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 daggerheart-count', + attr: { 'aria-label': 'Copy to clipboard' } + }) + setIcon(copy, 'copy'); + copy.addEventListener('click', () => { + // TODO: for library, add `raw` field to paste them as they were entered + const adv: Partial = { ...this.adv }; + adv.id = Math.random().toString(36).slice(2); + if (adv.thresholds?.length == 0) { + delete adv.thresholds; + } else { + adv.thresholds = adv.thresholds?.join('/') as any; + } + if (adv.xp?.length == 0) { + delete adv.xp; + } else { + adv.xp = adv.xp?.join(', ') as any; + } + if (adv.hp == 0) delete adv.hp; + if (adv.stress == 0) delete adv.stress; + if (adv.features?.length == 0) delete adv.features; + delete adv.source; + + navigator.clipboard.writeText(`\`\`\`daggerheart\n${stringifyYaml(adv)}\`\`\`\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: 'daggerheart-count clickable-icon invisible', @@ -349,7 +384,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; @@ -362,7 +401,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: 'corner' }); colorpicker.addEventListener('input', () => { applyColor(colorpicker.value); diff --git a/styles.css b/styles.css index 54039b8..4587335 100644 --- a/styles.css +++ b/styles.css @@ -94,4 +94,44 @@ input[type=checkbox].daggerheart-slot { .library-item { margin: 0.5em 0.5em !important; + display: flex; + flex-direction: column; +} + +.source { + text-align: right; + color: var(--text-muted); + margin-top: auto; +} + +.inactive { + color: var(--text-faint) !important; +} + +.tier-button { + font-family: monospace; + font-weight: bold; +} + +.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; +} + +.fixed > select { + flex-shrink: 1; + min-width: 0; +} + +.view-content:has(.fixed) { + padding-top: 0; + scrollbar-gutter: stable; } From 1e7b5a6e90349e854303c40f5440bcd375eb8341 Mon Sep 17 00:00:00 2001 From: Lyova Potyomkin Date: Sat, 29 Nov 2025 21:10:04 +0200 Subject: [PATCH 3/3] minor fixes --- package.json | 1 - src/library.ts | 8 +++++--- src/main.ts | 2 +- src/settings.ts | 2 +- src/ui.ts | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) 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 index af803b6..b886ef4 100644 --- a/src/library.ts +++ b/src/library.ts @@ -5,8 +5,10 @@ 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: 'tier' | 'type' | 'source' | 'name' = 'tier'; + 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', @@ -132,7 +134,7 @@ export class LibraryView extends ItemView { sortDropdown.createEl('option', { text: 'Source', value: 'source' }); sortBy.addEventListener('change', (event) => { - this.sortBy = (event.target as HTMLSelectElement).value as any; + this.sortBy = (event.target as HTMLSelectElement).value as SortBy; this.renderBlocks(); }); @@ -140,7 +142,7 @@ export class LibraryView extends ItemView { } renderBlocks() { - this.grid ??= this.contentEl.createDiv('daggerheart-library'); + this.grid ??= this.contentEl.createDiv('bv-library'); this.grid.empty(); const everything = this.everything(); for (const item of everything) { diff --git a/src/main.ts b/src/main.ts index 0b5e9de..5d8f912 100644 --- a/src/main.ts +++ b/src/main.ts @@ -52,7 +52,7 @@ export default class BeastVault extends Plugin { leaf = this.app.workspace.getLeaf('tab'); await leaf?.setViewState({ type: LIBRARY_VIEW_TYPE, active: true }); } - this.app.workspace.revealLeaf(leaf); + await this.app.workspace.revealLeaf(leaf); } calculateBattlePoints(filePath: string): number { diff --git a/src/settings.ts b/src/settings.ts index 9c12e5c..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 = { diff --git a/src/ui.ts b/src/ui.ts index d34a653..e4fe0d5 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -296,7 +296,7 @@ export class AdversaryCard extends MarkdownRenderChild { delete copy.source; delete copy.raw; - navigator.clipboard.writeText(`\`\`\`daggerheart\n${this.raw.raw ? this.raw.raw : stringifyYaml(copy)}\`\`\`\n`) + void navigator.clipboard.writeText(`\`\`\`daggerheart\n${this.raw.raw ? this.raw.raw : stringifyYaml(copy)}\`\`\`\n`) new Notice('Adversary copied to clipboard'); }) }