clean up table editing experience

This commit is contained in:
isaprettycoolguy@protonmail.com 2026-03-13 13:38:03 -04:00
parent 5fd49558fb
commit 9250bd65b3
10 changed files with 465 additions and 53 deletions

View file

@ -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<void> {
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<void> {
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.

View file

@ -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<string, string>();
private allLinks = new Map<string, string[]>();
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<void> {
// 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<string, string>();
let allLinks = new Map<string, string[]>();
// 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();
});

View file

@ -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<void> {
@ -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<HTMLElement>(".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<HTMLElement>("[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<HTMLElement>(".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<HTMLElement>(".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);
}

View file

@ -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);

View file

@ -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;

View file

@ -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);
});
});
}

View file

@ -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");
});
}
}

View file

@ -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;
}

View file

@ -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<T> { 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) {} }

View file

@ -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<string, string>) {
const fileObjs = new Map<string, TFile>();
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");
});
});