mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 14:30:24 +00:00
clean up table editing experience
This commit is contained in:
parent
5fd49558fb
commit
9250bd65b3
10 changed files with 465 additions and 53 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
styles.css
110
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {} }
|
||||
|
|
|
|||
171
tests/hexEditorModal.test.ts
Normal file
171
tests/hexEditorModal.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue