diff --git a/src/DuckmagePlugin.ts b/src/DuckmagePlugin.ts index 8439e46..85056de 100644 --- a/src/DuckmagePlugin.ts +++ b/src/DuckmagePlugin.ts @@ -8,7 +8,7 @@ import { normalizeFolder, makeTableTemplate } from "./utils"; import type { DuckmagePluginSettings } from "./types"; import DEFAULT_HEX_TEMPLATE from "./defaultHexTemplate.md"; import { getTerrainFromFile, setTerrainInFile } from "./frontmatter"; -import { addLinkToSection, getLinksInSection } from "./sections"; +import { addLinkToSection, getLinksInSection, removeLinkFromSection } from "./sections"; export default class DuckmagePlugin extends Plugin { settings: DuckmagePluginSettings; @@ -251,6 +251,51 @@ export default class DuckmagePlugin extends Plugin { new Notice(`Duckmage: linked encounters tables for ${linked} hex${linked !== 1 ? "es" : ""}.`); } + /** + * Replace the terrain encounters-table link in a single hex's "Encounters Table" section. + * Removes any existing link that resolves to a file in the terrain subfolder, then adds + * the correct link for the new terrain (if non-null and the table file exists). + */ + async syncHexEncounterTableLink(hexFilePath: string, terrain: string | null): Promise { + const tablesFolder = normalizeFolder(this.settings.tablesFolder); + const subfolder = tablesFolder ? `${tablesFolder}/terrain` : "terrain"; + + // Remove any links that point to a terrain encounters table + const existing = await getLinksInSection(this.app, hexFilePath, "Encounters Table"); + for (const linkTarget of existing) { + const resolved = this.app.metadataCache.getFirstLinkpathDest(linkTarget, hexFilePath); + if (resolved && resolved.path.startsWith(subfolder + "/") && resolved.path.endsWith(" - encounters.md")) { + await removeLinkFromSection(this.app, hexFilePath, "Encounters Table", linkTarget); + } + } + + if (!terrain) return; + + const tablePath = `${subfolder}/${terrain} - encounters.md`; + const tableFile = this.app.vault.getAbstractFileByPath(tablePath); + if (!(tableFile instanceof TFile)) return; + const linkText = `[[${this.app.metadataCache.fileToLinktext(tableFile, hexFilePath)}]]`; + await addLinkToSection(this.app, hexFilePath, "Encounters Table", linkText); + } + + /** + * For every hex note on the map, replace its terrain encounters-table link with the + * one matching its current terrain. Intended as a one-shot repair tool. + */ + async refreshAllTerrainEncounterLinks(): Promise { + const hexFolder = normalizeFolder(this.settings.hexFolder); + const hexFiles = this.app.vault.getMarkdownFiles().filter(f => { + if (hexFolder && !f.path.startsWith(hexFolder + "/")) return false; + return /^(-?\d+)_(-?\d+)\.md$/.test(f.name); + }); + + for (const file of hexFiles) { + const terrain = getTerrainFromFile(this.app, file.path) ?? null; + await this.syncHexEncounterTableLink(file.path, terrain); + } + new Notice(`Duckmage: refreshed encounter links for ${hexFiles.length} hex${hexFiles.length !== 1 ? "es" : ""}.`); + } + /** Update every hex note whose terrain matches oldName to newName. * Reads file content directly (not the metadata cache) so successive renames * don't miss hexes whose cache entry hasn't refreshed yet. diff --git a/src/HexEditorModal.ts b/src/HexEditorModal.ts index 1f635ca..732fbf6 100644 --- a/src/HexEditorModal.ts +++ b/src/HexEditorModal.ts @@ -21,6 +21,12 @@ import { RandomTableModal } from "./RandomTableModal"; import { VIEW_TYPE_HEX_MAP, VIEW_TYPE_RANDOM_TABLES } from "./constants"; export class HexEditorModal extends Modal { + private hexExists = false; + private allText = new Map(); + private allLinks = new Map(); + private directTerrain: string | null = null; + private directIcon: string | null = null; + constructor( app: App, private plugin: DuckmagePlugin, @@ -34,40 +40,41 @@ export class HexEditorModal extends Modal { super(app); } - async onOpen() { - const { contentEl } = this; - contentEl.addClass("duckmage-hex-editor"); + async loadData(): Promise { + // Reset all fields so stale data from a previous hex never bleeds through + this.allText = new Map(); + this.allLinks = new Map(); + this.directTerrain = null; + this.directIcon = null; const path = this.plugin.hexPath(this.x, this.y); - const hexExists = + this.hexExists = this.app.vault.getAbstractFileByPath(path) instanceof TFile; + if (!this.hexExists) return; - // Fetch all section data in one file read before touching the DOM - let allText = new Map(); - let allLinks = new Map(); - // Read terrain + icon directly from file content (bypass stale metadata cache) - let directTerrain: string | null = null; - let directIcon: string | null = null; - if (hexExists) { - ({ text: allText, links: allLinks } = await getAllSectionData( - this.app, - path, - )); - // Re-read raw content for frontmatter (getAllSectionData doesn't expose it) - const rawContent = await this.app.vault.read( - this.app.vault.getAbstractFileByPath(path) as TFile, - ); - const fmMatch = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (fmMatch) { - const tm = fmMatch[1].match(/^\s*terrain:\s*(.+)$/m); - if (tm) directTerrain = tm[1].trim(); - const im = fmMatch[1].match(/^\s*icon:\s*(.+)$/m); - if (im) directIcon = im[1].trim(); - } + ({ text: this.allText, links: this.allLinks } = await getAllSectionData( + this.app, + path, + )); + const rawContent = await this.app.vault.read( + this.app.vault.getAbstractFileByPath(path) as TFile, + ); + const fmMatch = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (fmMatch) { + const tm = fmMatch[1].match(/^\s*terrain:\s*(.+)$/m); + if (tm) this.directTerrain = tm[1].trim(); + const im = fmMatch[1].match(/^\s*icon:\s*(.+)$/m); + if (im) this.directIcon = im[1].trim(); } + } - // Render entire modal synchronously from pre-fetched data β€” no more chunked paints + onOpen() { + const { contentEl } = this; contentEl.empty(); + contentEl.addClass("duckmage-hex-editor"); + + const { hexExists, allText, allLinks, directTerrain, directIcon } = this; + const path = this.plugin.hexPath(this.x, this.y); const titleRow = contentEl.createDiv({ cls: "duckmage-editor-title-row" }); const titleLeft = titleRow.createDiv({ cls: "duckmage-editor-title-left" }); titleLeft.createEl("h2", { text: `Hex ${this.x}, ${this.y}` }); @@ -290,7 +297,7 @@ export class HexEditorModal extends Modal { tile.addEventListener("click", () => { this.x = nx; this.y = ny; - void this.onOpen(); + this.loadData().then(() => this.onOpen()); }); } else { tile.title = "Off map"; @@ -344,6 +351,7 @@ export class HexEditorModal extends Modal { clearBtn.createSpan({ text: "Clear", cls: "duckmage-terrain-option-name" }); clearBtn.addEventListener("click", async () => { await setTerrainInFile(this.app, path, null); + void this.plugin.syncHexEncounterTableLink(path, null); this.onChanged(new Map([[path, null]])); this.close(); }); @@ -370,6 +378,7 @@ export class HexEditorModal extends Modal { btn.addEventListener("click", async () => { await this.ensureHexNote(); await setTerrainInFile(this.app, path, entry.name); + void this.plugin.syncHexEncounterTableLink(path, entry.name); this.onChanged(new Map([[path, entry.name]])); this.close(); }); diff --git a/src/HexMapView.ts b/src/HexMapView.ts index ffcb5b7..191368b 100644 --- a/src/HexMapView.ts +++ b/src/HexMapView.ts @@ -262,10 +262,13 @@ export class HexMapView extends ItemView { } }); - this.createExpandButtons(controlsEl); - this.createDrawingToolbar(controlsEl); + // All toolbar elements go in this panel so they can be hidden together + const toolbarPanel = controlsEl.createDiv({ cls: "duckmage-toolbar-panel" }); - const tableBtn = controlsEl.createEl("button", { + this.createExpandButtons(toolbarPanel); + this.createDrawingToolbar(toolbarPanel); + + const tableBtn = toolbarPanel.createEl("button", { cls: "duckmage-table-btn", title: "Open hex table", text: "⊞", @@ -276,7 +279,7 @@ export class HexMapView extends ItemView { .setViewState({ type: VIEW_TYPE_HEX_TABLE }); }); - const rtBtn = controlsEl.createEl("button", { + const rtBtn = toolbarPanel.createEl("button", { cls: "duckmage-rt-btn", title: "Open random tables", text: "🎲", @@ -287,7 +290,7 @@ export class HexMapView extends ItemView { .setViewState({ type: VIEW_TYPE_RANDOM_TABLES }); }); - const gotoBtn = controlsEl.createEl("button", { + const gotoBtn = toolbarPanel.createEl("button", { cls: "duckmage-goto-btn", title: "Go to hex", text: "βŒ–", @@ -296,13 +299,25 @@ export class HexMapView extends ItemView { new GotoHexModal(this.app, (x, y) => this.centerOnHex(x, y)).open(); }); - const helpBtn = controlsEl.createEl("button", { + const helpBtn = toolbarPanel.createEl("button", { cls: "duckmage-help-btn", title: "Controls & tools", text: "?", }); helpBtn.addEventListener("click", () => new HexHelpModal(this.app).open()); + // Toggle button β€” always visible, collapses/restores the toolbar panel + const toggleBtn = controlsEl.createEl("button", { + cls: "duckmage-toolbar-toggle-btn", + title: "Hide toolbar", + }); + toggleBtn.setText("≑"); + toggleBtn.addEventListener("click", () => { + const collapsed = controlsEl.hasClass("duckmage-toolbar-collapsed"); + controlsEl.toggleClass("duckmage-toolbar-collapsed", !collapsed); + toggleBtn.title = collapsed ? "Hide toolbar" : "Show toolbar"; + }); + this.renderGrid(); } @@ -943,7 +958,7 @@ export class HexMapView extends ItemView { this.onHexDeleteClick(x, y); return; } - new HexEditorModal(this.app, this.plugin, x, y, (t, i) => { + const modal = new HexEditorModal(this.app, this.plugin, x, y, (t, i) => { if (t !== undefined || i !== undefined) { // Terrain/icon changed β€” immediate update with explicit overrides avoids cache race this.renderGrid(t, i); @@ -951,7 +966,8 @@ export class HexMapView extends ItemView { // Link-only change β€” defer so metadata cache has time to repopulate after vault.modify setTimeout(() => this.renderGrid(), 300); } - }).open(); + }); + modal.loadData().then(() => modal.open()); } private async onHexClick(x: number, y: number): Promise { @@ -1168,8 +1184,10 @@ export class HexMapView extends ItemView { this.pendingTerrainWrites.delete(path); try { if (terrain === null) { - if (this.app.vault.getAbstractFileByPath(path) instanceof TFile) + if (this.app.vault.getAbstractFileByPath(path) instanceof TFile) { await setTerrainInFile(this.app, path, null); + void this.plugin.syncHexEncounterTableLink(path, null); + } } else { if ( !(this.app.vault.getAbstractFileByPath(path) instanceof TFile) @@ -1184,6 +1202,7 @@ export class HexMapView extends ItemView { ?.addClass("duckmage-hex-exists"); } await setTerrainInFile(this.app, path, terrain); + void this.plugin.syncHexEncounterTableLink(path, terrain); } } catch (err) { console.error(`[duckmage] terrain write failed for ${path}:`, err); @@ -1396,6 +1415,7 @@ export class HexMapView extends ItemView { private renderRoadRiverOverlay(gridContainer: HTMLElement): void { this.viewportEl?.querySelector("svg.duckmage-road-river-svg")?.remove(); + this.viewportEl?.removeClass("duckmage-svg-labels-active"); // Restore any icons that were hidden when the previous SVG elevated them gridContainer .querySelectorAll(".duckmage-hex-icon[data-svg-elevated]") @@ -1539,15 +1559,12 @@ export class HexMapView extends ItemView { if (this.drawingMode === "river") drawActiveEndMarker(this.activeRiverEnd, this.plugin.settings.riverColor); - // Elevate override icons above roads/rivers by rendering them inside the SVG, - // then re-render the coordinate label on top of the icon. + // Elevate override icons above roads/rivers by rendering them inside the SVG. gridContainer .querySelectorAll("[data-icon-override]") .forEach((hexEl) => { const iconName = hexEl.dataset.iconOverride!; - const x = hexEl.dataset.x!; - const y = hexEl.dataset.y!; - const key = `${x}_${y}`; + const key = `${hexEl.dataset.x!}_${hexEl.dataset.y!}`; const pos = centerMap.get(key); if (!pos) return; const origImg = hexEl.querySelector(".duckmage-hex-icon"); @@ -1565,24 +1582,41 @@ export class HexMapView extends ItemView { imgEl.setAttribute("href", getIconUrl(this.plugin, iconName)); imgEl.setAttribute("opacity", "0.75"); svg.appendChild(imgEl); + }); - // Coordinate label on top of the icon β€” mirrors .duckmage-hex-label styling + // Render all hex coordinate labels as SVG text so they sit above roads, + // rivers, and icons regardless of CSS stacking context. + gridContainer + .querySelectorAll(".duckmage-hex") + .forEach((hexEl) => { + const x = hexEl.dataset.x!; + const y = hexEl.dataset.y!; + const pos = centerMap.get(`${x}_${y}`); + if (!pos) return; + const hasTerrain = !!hexEl.style.backgroundColor; const textEl = document.createElementNS(svgNS, "text"); textEl.setAttribute("x", String(pos.cx)); - textEl.setAttribute("y", String(pos.cy)); + // Nudge label toward bottom of hex (same visual position as the HTML label) + textEl.setAttribute("y", String(pos.cy + hexEl.offsetHeight * 0.28)); textEl.setAttribute("text-anchor", "middle"); textEl.setAttribute("dominant-baseline", "middle"); textEl.setAttribute("font-size", String(hexEl.offsetHeight * 0.12)); textEl.setAttribute("font-weight", "600"); - textEl.setAttribute("fill", "#ffffff"); - textEl.setAttribute("paint-order", "stroke"); - textEl.setAttribute("stroke", "rgba(0,0,0,0.85)"); - textEl.setAttribute("stroke-width", "2"); - textEl.setAttribute("stroke-linejoin", "round"); + if (hasTerrain) { + textEl.setAttribute("fill", "#ffffff"); + textEl.setAttribute("paint-order", "stroke"); + textEl.setAttribute("stroke", "rgba(0,0,0,0.85)"); + textEl.setAttribute("stroke-width", "2"); + textEl.setAttribute("stroke-linejoin", "round"); + } else { + textEl.setAttribute("fill", "var(--text-muted)"); + } + textEl.setAttribute("pointer-events", "none"); textEl.textContent = `${x},${y}`; svg.appendChild(textEl); }); + this.viewportEl?.addClass("duckmage-svg-labels-active"); this.viewportEl?.appendChild(svg); } diff --git a/src/RandomTableEditorModal.ts b/src/RandomTableEditorModal.ts index d977740..6d89ae9 100644 --- a/src/RandomTableEditorModal.ts +++ b/src/RandomTableEditorModal.ts @@ -39,6 +39,11 @@ export class RandomTableEditorModal extends Modal { let dragSrcIndex = -1; + const autoResize = (el: HTMLTextAreaElement) => { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }; + const renderRows = () => { rowsEl.empty(); if (entries.length === 0) { @@ -57,7 +62,12 @@ export class RandomTableEditorModal extends Modal { resultInput.value = entry.result; resultInput.placeholder = "Result…"; resultInput.rows = 1; - resultInput.addEventListener("input", () => { entries[i].result = resultInput.value; }); + // Size to content immediately, then keep in sync as the user types + requestAnimationFrame(() => autoResize(resultInput)); + resultInput.addEventListener("input", () => { + entries[i].result = resultInput.value; + autoResize(resultInput); + }); const weightInput = row.createEl("input", { type: "number", cls: "duckmage-table-editor-weight" }); weightInput.value = String(entry.weight); diff --git a/src/RandomTableModal.ts b/src/RandomTableModal.ts index 9573b86..9a9d657 100644 --- a/src/RandomTableModal.ts +++ b/src/RandomTableModal.ts @@ -166,6 +166,7 @@ export class RandomTableModal extends Modal { if (ranges) headerRow.createEl("th", { text: `d${table.dice}` }); headerRow.createEl("th", { text: "Result" }); headerRow.createEl("th", { text: "Odds" }); + headerRow.createEl("th", { cls: "duckmage-rt-copy-col-header" }); const tbody = tableEl.createEl("tbody"); const total = table.entries.reduce((s, e) => s + e.weight, 0); @@ -176,6 +177,15 @@ export class RandomTableModal extends Modal { tr.createEl("td", { text: entry.result }); const pct = `${Math.round((entry.weight / total) * 100)}%`; tr.createEl("td", { text: pct, cls: "duckmage-rt-odds-cell" }); + const copyTd = tr.createEl("td", { cls: "duckmage-rt-entry-copy-cell" }); + const copyBtn = copyTd.createEl("button", { text: "⎘", cls: "duckmage-rt-entry-copy-btn" }); + copyBtn.title = "Copy entry"; + copyBtn.addEventListener("click", (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(entry.result); + copyBtn.setText("βœ“"); + setTimeout(() => copyBtn.setText("⎘"), 1200); + }); }); rollBtn.disabled = false; diff --git a/src/RandomTableView.ts b/src/RandomTableView.ts index 2194adb..8ec1489 100644 --- a/src/RandomTableView.ts +++ b/src/RandomTableView.ts @@ -291,6 +291,7 @@ export class RandomTableView extends ItemView { if (ranges) headerRow.createEl("th", { text: `d${table.dice}` }); headerRow.createEl("th", { text: "Result" }); headerRow.createEl("th", { text: "Odds" }); + headerRow.createEl("th", { cls: "duckmage-rt-copy-col-header" }); const tbody = tableEl.createEl("tbody"); table.entries.forEach((entry, i) => { @@ -300,6 +301,15 @@ export class RandomTableView extends ItemView { tr.createEl("td", { text: entry.result }); const pct = `${Math.round((entry.weight / table.entries.reduce((s, e) => s + e.weight, 0)) * 100)}%`; tr.createEl("td", { text: pct, cls: "duckmage-rt-odds-cell" }); + const copyTd = tr.createEl("td", { cls: "duckmage-rt-entry-copy-cell" }); + const copyBtn = copyTd.createEl("button", { text: "⎘", cls: "duckmage-rt-entry-copy-btn" }); + copyBtn.title = "Copy entry"; + copyBtn.addEventListener("click", (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(entry.result); + copyBtn.setText("βœ“"); + setTimeout(() => copyBtn.setText("⎘"), 1200); + }); }); } diff --git a/src/TerrainPickerModal.ts b/src/TerrainPickerModal.ts index a0f801b..986db37 100644 --- a/src/TerrainPickerModal.ts +++ b/src/TerrainPickerModal.ts @@ -185,5 +185,19 @@ export class TerrainPickerModal extends Modal { }; renderTiles(); + + // Debug: one-shot button to fix all hex encounter-table links on the current map + const footer = contentEl.createDiv({ cls: "duckmage-tpe-edit-footer" }); + const refreshBtn = footer.createEl("button", { + cls: "duckmage-tpe-refresh-btn", + text: "Refresh all encounter table links", + title: "Re-links every hex's Encounters Table section to match its current terrain", + }); + refreshBtn.addEventListener("click", async () => { + refreshBtn.disabled = true; + refreshBtn.setText("Refreshing…"); + await this.plugin.refreshAllTerrainEncounterLinks(); + refreshBtn.setText("Done"); + }); } } diff --git a/styles.css b/styles.css index a7e3ef2..a6681cc 100644 --- a/styles.css +++ b/styles.css @@ -27,6 +27,48 @@ pointer-events: auto; } +/* Toolbar panel β€” wraps all controls so they can be hidden together */ +.duckmage-toolbar-panel { + position: absolute; + inset: 0; + pointer-events: none; +} + +.duckmage-toolbar-panel > * { + pointer-events: auto; +} + +.duckmage-toolbar-collapsed .duckmage-toolbar-panel { + display: none; +} + +/* Toggle button β€” always visible, top-right corner */ +.duckmage-toolbar-toggle-btn { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + font-size: 1.1em; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + color: var(--text-muted); + opacity: 0.7; + pointer-events: auto; +} + +.duckmage-toolbar-toggle-btn:hover { + opacity: 1; + color: var(--text-normal); + border-color: var(--interactive-accent); +} + .duckmage-hex-map-viewport { position: absolute; top: 0; @@ -431,6 +473,34 @@ border-color: var(--interactive-accent); } +.duckmage-tpe-edit-footer { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--background-modifier-border); + display: flex; + justify-content: flex-end; +} + +.duckmage-tpe-refresh-btn { + font-size: 0.8em; + padding: 3px 10px; + cursor: pointer; + border-radius: 4px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + color: var(--text-muted); +} + +.duckmage-tpe-refresh-btn:hover:not(:disabled) { + color: var(--text-normal); + border-color: var(--interactive-accent); +} + +.duckmage-tpe-refresh-btn:disabled { + opacity: 0.5; + cursor: default; +} + /* Terrain picker grid β€” capped at 2 rows, scrollable */ .duckmage-terrain-picker { display: flex; @@ -710,7 +780,7 @@ .duckmage-draw-toolbar { position: absolute; top: 8px; - right: 16px; + right: 44px; z-index: 10; display: flex; gap: 4px; @@ -778,6 +848,11 @@ cursor: cell; } +/* When the SVG overlay is active it renders all labels β€” hide the HTML copies */ +.duckmage-hex-map-viewport.duckmage-svg-labels-active .duckmage-hex-label { + visibility: hidden; +} + /* ── Expand buttons ───────────────────────────────────────────────────────── */ .duckmage-expand-btn { @@ -1871,6 +1946,38 @@ opacity: 1; } +/* Per-entry copy button in the table odds grid */ +.duckmage-rt-copy-col-header { + width: 24px; + padding: 0 !important; +} + +.duckmage-rt-entry-copy-cell { + width: 24px; + padding: 0 2px !important; + text-align: center; +} + +.duckmage-rt-entry-copy-btn { + background: none; + border: none; + color: var(--text-faint); + cursor: pointer; + padding: 0 2px; + font-size: 0.9em; + opacity: 0; + transition: opacity 0.1s; +} + +tr:hover .duckmage-rt-entry-copy-btn { + opacity: 0.6; +} + +tr:hover .duckmage-rt-entry-copy-btn:hover { + opacity: 1; + color: var(--text-normal); +} + /* ── Random table modal ────────────────────────────────────────────────── */ .duckmage-roll-modal { @@ -2000,6 +2107,7 @@ flex: 1; resize: vertical; min-height: 28px; + overflow-y: hidden; font-family: inherit; font-size: inherit; } diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index 1c918ee..9aadf1d 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -18,6 +18,7 @@ export class TFolder extends TAbstractFile { } export class App {} -export class Modal { constructor(_app: App) {} } +export class Modal { app: App; contentEl = { empty() {}, addClass() {}, createDiv() { return this; }, createEl() { return this; }, createSpan() { return this; }, setText() { return this; }, style: {} } as any; constructor(app: App) { this.app = app; } } +export class SuggestModal { constructor(_app: App) {} getSuggestions(_q: string): T[] { return []; } renderSuggestion(_v: T, _el: HTMLElement): void {} onChooseSuggestion(_v: T, _e: MouseEvent | KeyboardEvent): void {} } export class Notice { constructor(_msg: string) {} } export class Plugin { constructor(_app: App, _manifest: any) {} } diff --git a/tests/hexEditorModal.test.ts b/tests/hexEditorModal.test.ts new file mode 100644 index 0000000..5fada15 --- /dev/null +++ b/tests/hexEditorModal.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi } from "vitest"; +import { TFile } from "obsidian"; +import { HexEditorModal } from "../src/HexEditorModal"; + +/** Minimal App backed by a map of path β†’ content strings. */ +function makeApp(files: Record) { + const fileObjs = new Map(); + for (const path of Object.keys(files)) { + const f = Object.create(TFile.prototype) as TFile; + f.path = path; + f.name = path.split("/").pop()!; + f.basename = f.name.replace(/\.md$/, ""); + fileObjs.set(path, f); + } + + return { + vault: { + getAbstractFileByPath: (p: string) => fileObjs.get(p) ?? null, + read: vi.fn(async (f: TFile) => files[f.path] ?? ""), + }, + metadataCache: { + getFileCache: vi.fn(() => null), + }, + } as unknown as import("obsidian").App; +} + +/** Minimal plugin stub β€” only what loadData() needs. */ +function makePlugin(hexPathFn: (x: number, y: number) => string) { + return { + hexPath: vi.fn(hexPathFn), + settings: { + terrainPalette: [], + tablesFolder: "tables", + hexEditorTerrainCollapsed: false, + hexEditorFeaturesCollapsed: false, + hexEditorNotesCollapsed: false, + hexEditorStartCollapsed: false, + }, + availableIcons: [], + } as unknown as import("../src/DuckmagePlugin").default; +} + +// ── loadData ────────────────────────────────────────────────────────────────── + +describe("HexEditorModal.loadData", () => { + it("sets hexExists to false when the hex file does not exist", async () => { + const app = makeApp({}); + const plugin = makePlugin(() => "hex/1_1.md"); + const modal = new HexEditorModal(app, plugin, 1, 1, () => {}); + await modal.loadData(); + expect((modal as any).hexExists).toBe(false); + }); + + it("sets hexExists to true when the hex file exists", async () => { + const app = makeApp({ "hex/1_1.md": "---\nterrain: forest\n---\n\n" }); + const plugin = makePlugin(() => "hex/1_1.md"); + const modal = new HexEditorModal(app, plugin, 1, 1, () => {}); + await modal.loadData(); + expect((modal as any).hexExists).toBe(true); + }); + + it("extracts directTerrain from frontmatter", async () => { + const app = makeApp({ "hex/2_3.md": "---\nterrain: desert\n---\n\nBody." }); + const plugin = makePlugin(() => "hex/2_3.md"); + const modal = new HexEditorModal(app, plugin, 2, 3, () => {}); + await modal.loadData(); + expect((modal as any).directTerrain).toBe("desert"); + }); + + it("extracts directIcon from frontmatter", async () => { + const app = makeApp({ "hex/4_5.md": "---\nterrain: forest\nicon: castle.png\n---\n\n" }); + const plugin = makePlugin(() => "hex/4_5.md"); + const modal = new HexEditorModal(app, plugin, 4, 5, () => {}); + await modal.loadData(); + expect((modal as any).directIcon).toBe("castle.png"); + }); + + it("leaves directTerrain null when frontmatter has no terrain field", async () => { + const app = makeApp({ "hex/1_1.md": "---\ntitle: test\n---\n\n" }); + const plugin = makePlugin(() => "hex/1_1.md"); + const modal = new HexEditorModal(app, plugin, 1, 1, () => {}); + await modal.loadData(); + expect((modal as any).directTerrain).toBeNull(); + }); + + it("leaves directIcon null when frontmatter has no icon field", async () => { + const app = makeApp({ "hex/1_1.md": "---\nterrain: forest\n---\n\n" }); + const plugin = makePlugin(() => "hex/1_1.md"); + const modal = new HexEditorModal(app, plugin, 1, 1, () => {}); + await modal.loadData(); + expect((modal as any).directIcon).toBeNull(); + }); + + it("populates allText from sections", async () => { + const app = makeApp({ + "hex/3_3.md": "---\nterrain: grass\n---\n\n### Description\n\nA grassy plain.\n\n### Landmark\n\nA tall oak.\n", + }); + const plugin = makePlugin(() => "hex/3_3.md"); + const modal = new HexEditorModal(app, plugin, 3, 3, () => {}); + await modal.loadData(); + expect((modal as any).allText.get("description")).toBe("A grassy plain."); + expect((modal as any).allText.get("landmark")).toBe("A tall oak."); + }); + + it("populates allLinks with wiki-links from link sections", async () => { + const app = makeApp({ + "hex/5_5.md": "---\nterrain: forest\n---\n\n### Encounters Table\n\n[[tables/terrain/forest - encounters]]\n", + }); + const plugin = makePlugin(() => "hex/5_5.md"); + const modal = new HexEditorModal(app, plugin, 5, 5, () => {}); + await modal.loadData(); + const links = (modal as any).allLinks.get("encounters table") as string[]; + expect(links).toContain("tables/terrain/forest - encounters"); + }); +}); + +// ── Navigation: reload on hex change ───────────────────────────────────────── + +describe("HexEditorModal navigation reload", () => { + it("reloads data for the new hex after x/y are updated", async () => { + const app = makeApp({ + "hex/1_1.md": "---\nterrain: forest\n---\n\n", + "hex/2_2.md": "---\nterrain: desert\n---\n\n", + }); + const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`); + + const modal = new HexEditorModal(app, plugin, 1, 1, () => {}); + await modal.loadData(); + expect((modal as any).directTerrain).toBe("forest"); + + // Simulate navigation to a neighbour hex + (modal as any).x = 2; + (modal as any).y = 2; + await modal.loadData(); + expect((modal as any).directTerrain).toBe("desert"); + }); + + it("clears terrain data when navigating to a hex with no file", async () => { + const app = makeApp({ + "hex/1_1.md": "---\nterrain: forest\n---\n\n", + }); + const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`); + + const modal = new HexEditorModal(app, plugin, 1, 1, () => {}); + await modal.loadData(); + expect((modal as any).hexExists).toBe(true); + + (modal as any).x = 9; + (modal as any).y = 9; + await modal.loadData(); + expect((modal as any).hexExists).toBe(false); + expect((modal as any).directTerrain).toBeNull(); + }); + + it("loads correct icon when navigating between hexes with different icons", async () => { + const app = makeApp({ + "hex/1_1.md": "---\nterrain: forest\nicon: tower.png\n---\n\n", + "hex/2_1.md": "---\nterrain: desert\nicon: oasis.png\n---\n\n", + }); + const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`); + + const modal = new HexEditorModal(app, plugin, 1, 1, () => {}); + await modal.loadData(); + expect((modal as any).directIcon).toBe("tower.png"); + + (modal as any).x = 2; + (modal as any).y = 1; + await modal.loadData(); + expect((modal as any).directIcon).toBe("oasis.png"); + }); +});