add submap links: link hex to another map, centre-dot nav, back button, map link insert

- Right-click hex → "Link submap" writes duckmage-submap frontmatter
- Hex flower shows centre dot (hidden when no link) that navigates into the submap
- Centre dot centering is orientation-aware (flat-top top:24px, pointy-top top:20px)
- ← Back button appears next to map dropdown (via flex nav group) after submap drill-in
- "Insert map link" editor context menu inserts obsidian://duckmage-openmap URI
- Map rename updates all duckmage-submap references via updateSubmapReferences()
- HexEditorOptions refactored: onNavigate/onModalClose/onSwitchMap in options object
- 10 new tests for submap frontmatter helpers and rename propagation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isaprettycoolguy@protonmail.com 2026-05-23 12:29:22 -04:00
parent a23a98b982
commit 31d3256192
11 changed files with 642 additions and 26 deletions

View file

@ -5,6 +5,7 @@ import { RandomTableView } from "./random-tables/RandomTableView";
import { HexmakerSettingTab } from "./HexmakerSettingTab";
import { registerRollerBlock } from "./random-tables/RollerBlock";
import { FileLinkSuggestModal } from "./hex-map/FileLinkSuggestModal";
import { MapLinkModal } from "./hex-map/MapLinkModal";
import {
DEFAULT_PALETTE_NAME,
DEFAULT_SETTINGS,
@ -24,7 +25,7 @@ import type {
TerrainPalette,
} from "./types";
import DEFAULT_HEX_TEMPLATE from "./defaultHexTemplate.md";
import { getTerrainFromFile, setTerrainInFile } from "./frontmatter";
import { getTerrainFromFile, setTerrainInFile, setSubmapInFile } from "./frontmatter";
import {
addLinkToSection,
getLinksInSection,
@ -115,6 +116,21 @@ export default class HexmakerPlugin extends Plugin {
).open();
}),
);
menu.addItem((item) =>
item
.setTitle("Insert map link")
.setIcon("map")
.setSection("insert")
.onClick(() => {
new MapLinkModal(this.app, this, (mapName, linkText) => {
const vault = encodeURIComponent(this.app.vault.getName());
const map = encodeURIComponent(mapName);
editor.replaceSelection(
`[${linkText}](obsidian://duckmage-openmap?vault=${vault}&map=${map})`,
);
}).open();
}),
);
},
),
);
@ -161,6 +177,25 @@ export default class HexmakerPlugin extends Plugin {
}
});
this.registerObsidianProtocolHandler("duckmage-openmap", (params) => {
const mapName = params["map"];
if (!mapName) return;
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_HEX_MAP);
if (leaves.length > 0) {
void this.app.workspace.revealLeaf(leaves[0]);
(leaves[0].view as HexMapView).switchToMap(mapName);
} else {
void this.app.workspace.getLeaf("tab")
.setViewState({ type: VIEW_TYPE_HEX_MAP })
.then(() => {
const newLeaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_HEX_MAP);
if (newLeaves.length > 0) {
(newLeaves[0].view as HexMapView).switchToMap(mapName);
}
});
}
});
// Keep linkedFolder frontmatter in sync when a folder is renamed
this.registerEvent(
this.app.vault.on("rename", async (abstractFile, oldPath) => {
@ -449,6 +484,19 @@ export default class HexmakerPlugin extends Plugin {
: `${mapName}/${x}_${y}.md`;
}
/** Update duckmage-submap frontmatter in all hex notes when a map is renamed. */
async updateSubmapReferences(oldName: string, newName: string): Promise<void> {
const hexFolder = normalizeFolder(this.settings.hexFolder);
const files = this.app.vault.getMarkdownFiles().filter(
(f) => !hexFolder || f.path.startsWith(hexFolder + "/"),
);
for (const file of files) {
const submap = (this.app.metadataCache.getFileCache(file)?.frontmatter as Record<string, unknown> | undefined)?.["duckmage-submap"];
if (submap !== oldName) continue;
await setSubmapInFile(this.app, file.path, newName);
}
}
/** Build an embedded roller code block (path-less — reads the current file). */
buildRollerLink(): string {
return "```duckmage-roller\n```";

View file

@ -284,3 +284,22 @@ export async function applyTokenFrontmatter(
}
});
}
export function getSubmapFromFile(app: App, path: string): string | undefined {
const val = getFrontMatter(app, path)?.["duckmage-submap"];
return typeof val === "string" ? val : undefined;
}
export async function setSubmapInFile(
app: App,
path: string,
mapName: string | null,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
if (mapName === null) delete fm["duckmage-submap"];
else fm["duckmage-submap"] = mapName;
});
return true;
}

View file

@ -12,6 +12,7 @@ import {
setTerrainInFile,
setIconOverrideInFile,
setGmIconInFile,
getSubmapFromFile,
} from "../frontmatter";
import {
addLinkToSection,
@ -46,8 +47,6 @@ export class HexEditorModal extends HexmakerModal {
terrainOverrides?: Map<string, string | null>,
iconOverrides?: Map<string, string | null>,
) => void,
private onNavigate?: (x: number, y: number) => void,
private onModalClose?: () => void,
private options: HexEditorOptions = {},
) {
super(app);
@ -260,7 +259,7 @@ export class HexEditorModal extends HexmakerModal {
}
onClose() {
this.onModalClose?.();
this.options.onModalClose?.();
this.contentEl.empty();
}
@ -321,7 +320,7 @@ export class HexEditorModal extends HexmakerModal {
tile.addEventListener("click", () => {
this.x = nx;
this.y = ny;
this.onNavigate?.(nx, ny);
this.options.onNavigate?.(nx, ny);
void this.loadData().then(() => {
this.dataPreloaded = true;
this.onOpen();
@ -332,6 +331,21 @@ export class HexEditorModal extends HexmakerModal {
tile.title = "Off map";
}
}
// Centre dot — only shown when a submap is linked
const hexPath = this.plugin.hexPath(this.x, this.y, this.mapName);
const submap = getSubmapFromFile(this.app, hexPath);
if (submap) {
const centre = widget.createDiv({ cls: "duckmage-neighbor-centre duckmage-neighbor-centre--linked" });
// Flat-top flower center: (30,30); pointy-top: (30,26). Dot is 12×12.
const cy = isFlat ? 24 : 20;
centre.setCssProps({ left: "24px", top: `${cy}px` });
centre.title = `Submap: ${submap}`;
centre.addEventListener("click", () => {
this.close();
this.options.onSwitchMap?.(submap);
});
}
}
private makeCollapsible(

View file

@ -14,7 +14,7 @@ import { HexEditorModal } from "./HexEditorModal";
import { TerrainPickerModal } from "./TerrainPickerModal";
import { IconPickerModal } from "./IconPickerModal";
import { addLinkToSection, getLinksInSection, removeLinkFromSection } from "../sections";
import { getFactionColorFromFile, getRegionColorFromFile, setHexRegionInFile } from "../frontmatter";
import { getFactionColorFromFile, getRegionColorFromFile, setHexRegionInFile, getSubmapFromFile, setSubmapInFile } from "../frontmatter";
import {
VIEW_TYPE_HEX_MAP,
VIEW_TYPE_HEX_TABLE,
@ -37,6 +37,7 @@ import { FactionPickerModal } from "./FactionPickerModal";
import { GeoRegionPickerModal } from "./GeoRegionPickerModal";
import { DrawingToolPanel, OverlayPanel } from "./HexSidePanel";
import { TokenModal } from "./TokenModal";
import { SubmapPickerModal } from "./SubmapPickerModal";
import { TokenInfoModal } from "./TokenInfoModal";
import {
getTokenDataFromCache,
@ -146,7 +147,9 @@ export class HexMapView extends ItemView {
private undoBtn: HTMLButtonElement | null = null;
private redoBtn: HTMLButtonElement | null = null;
activeMapName = "default";
private mapHistory: string[] = [];
private mapBtn: HTMLButtonElement | null = null;
private backBtn: HTMLButtonElement | null = null;
private tokenEntries: TokenEntry[] = [];
private pendingTokenNotePath: string | null = null;
private pendingTokenPlaceData: { icon?: string; shape: import("../types").TokenShape; size: import("../types").TokenSize; color?: string; border?: string; description?: string } | null = null;
@ -172,6 +175,31 @@ export class HexMapView extends ItemView {
this.mapBtn?.setText(`${this.activeMapName}`);
}
switchToMap(name: string): void {
this.activeMapName = name;
this.updateMapBtnLabel();
interface WithUpdateHeader { updateHeader?(): void; }
(this.leaf as unknown as WithUpdateHeader).updateHeader?.();
this.renderGrid();
}
navigateToMap(name: string): void {
this.mapHistory.push(this.activeMapName);
this.switchToMap(name);
this.refreshBackBtn();
}
private navigateBack(): void {
const prev = this.mapHistory.pop();
if (prev) this.switchToMap(prev);
this.refreshBackBtn();
}
private refreshBackBtn(): void {
if (this.mapHistory.length > 0) this.backBtn?.show();
else this.backBtn?.hide();
}
onOpen(): Promise<void> {
// Initialise to the configured default map (falls back to first map or "default")
this.activeMapName =
@ -472,7 +500,9 @@ export class HexMapView extends ItemView {
void this.app.workspace.revealLeaf(this.leaf);
});
this.mapBtn = controlsEl.createEl("button", {
const mapNavGroup = controlsEl.createDiv({ cls: "duckmage-map-nav-group" });
this.mapBtn = mapNavGroup.createEl("button", {
cls: "duckmage-region-btn",
title: "Manage maps",
});
@ -493,6 +523,14 @@ export class HexMapView extends ItemView {
}).open(),
);
this.backBtn = mapNavGroup.createEl("button", {
cls: "duckmage-map-back-btn",
text: "← Back", // eslint-disable-line obsidianmd/ui/sentence-case
title: "Back to previous map",
});
this.backBtn.hide();
this.backBtn.addEventListener("click", () => this.navigateBack());
this.undoBtn = controlsEl.createEl("button", {
cls: "duckmage-undo-btn-map",
text: "↩",
@ -1616,18 +1654,21 @@ export class HexMapView extends ItemView {
setTimeout(() => this.renderGrid(), 300);
}
},
(nx, ny) => this.setSelectedHex(nx, ny),
() => {
if (this.selectedHex) {
this.viewportEl
?.querySelector<HTMLElement>(
`[data-x="${this.selectedHex.x}"][data-y="${this.selectedHex.y}"]`,
)
?.removeClass("is-selected");
this.selectedHex = null;
}
{
gmLayerActive: this.getActiveMap().showGmLayer ?? true,
onNavigate: (nx: number, ny: number) => this.setSelectedHex(nx, ny),
onModalClose: () => {
if (this.selectedHex) {
this.viewportEl
?.querySelector<HTMLElement>(
`[data-x="${this.selectedHex.x}"][data-y="${this.selectedHex.y}"]`,
)
?.removeClass("is-selected");
this.selectedHex = null;
}
},
onSwitchMap: (name: string) => this.navigateToMap(name),
},
{ gmLayerActive: this.getActiveMap().showGmLayer ?? true },
);
modal.open();
}
@ -1681,6 +1722,33 @@ export class HexMapView extends ItemView {
}),
);
menu.addItem((item) =>
item
.setTitle("Link submap")
.setIcon("map-pin")
.onClick(() => {
const hexPath = this.plugin.hexPath(x, y, this.activeMapName);
const current = getSubmapFromFile(this.app, hexPath);
new SubmapPickerModal(
this.app,
this.plugin,
current,
(mapName) => {
void (async () => {
if (!this.app.vault.getAbstractFileByPath(hexPath)) {
await this.plugin.createHexNote(x, y, this.activeMapName);
}
await setSubmapInFile(this.app, hexPath, mapName);
this.renderGrid();
})();
},
() => {
void setSubmapInFile(this.app, hexPath, null).then(() => this.renderGrid());
},
).open();
}),
);
menu.addItem((item) =>
item
.setTitle("Swap hex")

135
src/hex-map/MapLinkModal.ts Normal file
View file

@ -0,0 +1,135 @@
import { App, Setting } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
export class MapLinkModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private onInsert: (mapName: string, linkText: string) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
this.makeDraggable();
contentEl.createEl("h2", { text: "Insert map link" });
const maps = this.plugin.settings.maps;
if (maps.length === 0) {
contentEl.createEl("p", {
text: "No maps found. Create a map first.",
cls: "duckmage-maplinkmodal-empty",
});
contentEl.createEl("button", { text: "Cancel" })
.addEventListener("click", () => this.close());
return;
}
let selectedMap = maps[0].name;
let linkTextDirty = false;
let linkTextInput!: HTMLInputElement;
// ── Map combo ─────────────────────────────────────────────────────────
const mapLabel = contentEl.createDiv({ cls: "setting-item" });
mapLabel.createDiv({ cls: "setting-item-info" }).createDiv({
cls: "setting-item-name",
text: "Map",
});
const controlEl = mapLabel.createDiv({ cls: "setting-item-control" });
const comboWrap = controlEl.createDiv({ cls: "duckmage-link-combo" });
const mapInput = comboWrap.createEl("input", {
type: "text",
cls: "duckmage-link-combo-input",
attr: { placeholder: "Filter maps…" },
});
mapInput.value = selectedMap;
comboWrap.createEl("button", {
text: "▾",
cls: "duckmage-link-combo-arrow",
attr: { title: "Show all" },
}).addEventListener("mousedown", (e) => {
e.preventDefault();
if (dropdown.isShown()) closeDropdown();
else openDropdown(mapInput.value);
});
const dropdown = comboWrap.createDiv({ cls: "duckmage-link-combo-dropdown" });
dropdown.hide();
let isOpen = false;
const populateDropdown = (query: string) => {
dropdown.empty();
const q = query.trim().toLowerCase();
const matches = maps.filter((m) => !q || m.name.toLowerCase().includes(q));
if (matches.length === 0) {
dropdown.createDiv({ cls: "duckmage-link-combo-empty", text: "No matching maps" });
return;
}
for (const m of matches) {
const item = dropdown.createDiv({ cls: "duckmage-link-combo-item" });
item.textContent = m.name;
item.addEventListener("mousedown", (e) => {
e.preventDefault();
selectedMap = m.name;
mapInput.value = m.name;
if (!linkTextDirty) linkTextInput.value = m.name;
closeDropdown();
});
}
};
const openDropdown = (query: string) => {
isOpen = true;
populateDropdown(query);
dropdown.show();
};
const closeDropdown = () => {
isOpen = false;
dropdown.hide();
};
mapInput.addEventListener("focus", () => openDropdown(mapInput.value));
mapInput.addEventListener("blur", () => setTimeout(() => closeDropdown(), 150));
mapInput.addEventListener("input", () => {
if (!isOpen) openDropdown(mapInput.value);
else populateDropdown(mapInput.value);
});
mapInput.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") closeDropdown();
});
// ── Link text ─────────────────────────────────────────────────────────
new Setting(contentEl)
.setName("Link text")
.addText((t) => {
linkTextInput = t.inputEl;
t.setValue(selectedMap)
.onChange(() => { linkTextDirty = true; });
});
// ── Buttons ───────────────────────────────────────────────────────────
const btnRow = contentEl.createDiv({ cls: "duckmage-token-modal-buttons" });
btnRow.createEl("button", { text: "Insert", cls: "mod-cta" })
.addEventListener("click", () => {
const map = mapInput.value.trim() || selectedMap;
const text = linkTextInput.value.trim() || map;
this.close();
this.onInsert(map, text);
});
btnRow.createEl("button", { text: "Cancel" })
.addEventListener("click", () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -238,12 +238,14 @@ export class MapModal extends HexmakerModal {
return;
}
}
const map = this.plugin.getMap(this.view.activeMapName);
const oldName = this.view.activeMapName;
const map = this.plugin.getMap(oldName);
if (map) map.name = newName;
if (this.plugin.settings.defaultMap === this.view.activeMapName) {
if (this.plugin.settings.defaultMap === oldName) {
this.plugin.settings.defaultMap = newName;
}
this.view.activeMapName = newName;
await this.plugin.updateSubmapReferences(oldName, newName);
await this.plugin.saveSettings();
this.onChanged();
this.render();

View file

@ -0,0 +1,140 @@
import { App, Setting } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
export class SubmapPickerModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private current: string | undefined,
private onLink: (mapName: string) => void,
private onUnlink: () => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
this.makeDraggable();
contentEl.createEl("h2", { text: "Link submap" });
const maps = this.plugin.settings.maps;
if (maps.length === 0) {
contentEl.createEl("p", {
text: "No maps found. Create a map first.",
cls: "duckmage-maplinkmodal-empty",
});
contentEl.createEl("button", { text: "Cancel" })
.addEventListener("click", () => this.close());
return;
}
let selectedMap = this.current ?? maps[0].name;
let mapInput!: HTMLInputElement;
// ── Map combo ─────────────────────────────────────────────────────────
const mapLabel = contentEl.createDiv({ cls: "setting-item" });
mapLabel.createDiv({ cls: "setting-item-info" }).createDiv({
cls: "setting-item-name",
text: "Map",
});
const controlEl = mapLabel.createDiv({ cls: "setting-item-control" });
const comboWrap = controlEl.createDiv({ cls: "duckmage-link-combo" });
mapInput = comboWrap.createEl("input", {
type: "text",
cls: "duckmage-link-combo-input",
attr: { placeholder: "Filter maps…" },
});
// Start blank — selectedMap is just the fallback for the Link action
mapInput.value = "";
comboWrap.createEl("button", {
text: "▾",
cls: "duckmage-link-combo-arrow",
attr: { title: "Show all" },
}).addEventListener("mousedown", (e) => {
e.preventDefault();
if (dropdown.isShown()) closeDropdown();
else openDropdown(mapInput.value);
});
const dropdown = comboWrap.createDiv({ cls: "duckmage-link-combo-dropdown" });
dropdown.hide();
let isOpen = false;
const populateDropdown = (query: string) => {
dropdown.empty();
const q = query.trim().toLowerCase();
const matches = maps.filter((m) => !q || m.name.toLowerCase().includes(q));
if (matches.length === 0) {
dropdown.createDiv({ cls: "duckmage-link-combo-empty", text: "No matching maps" });
return;
}
for (const m of matches) {
const item = dropdown.createDiv({ cls: "duckmage-link-combo-item" });
item.textContent = m.name;
item.addEventListener("mousedown", (e) => {
e.preventDefault();
selectedMap = m.name;
mapInput.value = m.name;
closeDropdown();
});
}
};
const openDropdown = (query: string) => {
isOpen = true;
populateDropdown(query);
dropdown.show();
};
const closeDropdown = () => {
isOpen = false;
dropdown.hide();
};
mapInput.addEventListener("focus", () => openDropdown(""));
mapInput.addEventListener("blur", () => setTimeout(() => closeDropdown(), 150));
mapInput.addEventListener("input", () => {
if (!isOpen) openDropdown(mapInput.value);
else populateDropdown(mapInput.value);
});
mapInput.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") closeDropdown();
});
// ── Current submap indicator ──────────────────────────────────────────
if (this.current) {
new Setting(contentEl)
.setName("Current")
.setDesc(this.current);
}
// ── Buttons ───────────────────────────────────────────────────────────
const btnRow = contentEl.createDiv({ cls: "duckmage-token-modal-buttons" });
if (this.current) {
btnRow.createEl("button", { text: "Remove link", cls: "mod-warning" })
.addEventListener("click", () => { this.close(); this.onUnlink(); });
}
btnRow.createEl("button", { text: "Link", cls: "mod-cta" })
.addEventListener("click", () => {
const map = mapInput.value.trim() || selectedMap;
this.close();
this.onLink(map);
});
btnRow.createEl("button", { text: "Cancel" })
.addEventListener("click", () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -111,4 +111,10 @@ export const TEXT_SECTIONS = [
export interface HexEditorOptions {
/** GM layer is active: force Notes open, highlight Hidden/Secret sections. */
gmLayerActive?: boolean;
/** Called when the user clicks a neighbour tile to navigate to an adjacent hex. */
onNavigate?: (x: number, y: number) => void;
/** Called when the modal closes (e.g. to clear the selected-hex highlight). */
onModalClose?: () => void;
/** Called when the user clicks the submap centre-dot to drill into another map. */
onSwitchMap?: (mapName: string) => void;
}

View file

@ -361,7 +361,9 @@ div.duckmage-hex-icon {
font-size: 0.9em;
font-weight: 600;
}
.duckmage-hex-editor .modal-content {
padding:0.5em;
}
.duckmage-editor-title-row {
display: flex;
align-items: flex-start;
@ -409,10 +411,28 @@ div.duckmage-hex-icon {
cursor: default;
pointer-events: none;
}
.duckmage-neighbor-centre {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--background-modifier-border);
box-sizing: border-box;
pointer-events: none;
}
.duckmage-neighbor-centre--linked {
background: #1a1a1a;
border: 1px solid color-mix(in srgb, #fff 30%, transparent);
cursor: pointer;
pointer-events: auto;
transition: transform 0.1s;
}
.duckmage-neighbor-centre--linked:hover {
transform: scale(1.3);
}
/* ── Draggable / resizable modal ───────────────────────────────────── */
.modal.duckmage-editor-modal-drag {
overflow: hidden;
width: min(620px, 90vw);
max-height: min(90vh, 860px);
max-width: none;
@ -421,7 +441,6 @@ div.duckmage-hex-icon {
}
.duckmage-editor-modal-drag .modal-content {
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
@ -2894,11 +2913,20 @@ tr:hover .duckmage-rt-entry-copy-btn:hover {
/* ── Region button & modal ────────────────────────────────────────────────── */
/* Region selector button — always-visible top-left area, after ⊞ and 🎲 */
.duckmage-region-btn {
.duckmage-map-nav-group {
position: absolute;
top: 8px;
left: 92px;
z-index: 10;
display: flex;
align-items: center;
gap: 4px;
pointer-events: none;
}
.duckmage-map-nav-group > * {
pointer-events: auto;
}
.duckmage-region-btn {
height: 26px;
padding: 0 8px;
max-width: 140px;
@ -2922,6 +2950,23 @@ tr:hover .duckmage-rt-entry-copy-btn:hover {
opacity: 1;
color: var(--text-normal);
}
.duckmage-map-back-btn {
height: 26px;
font-size: 0.78em;
padding: 0 8px;
opacity: 0.75;
background: transparent;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
white-space: nowrap;
}
.duckmage-map-back-btn:hover {
opacity: 1;
color: var(--text-normal);
border-color: var(--interactive-accent);
}
/* Region modal */
.duckmage-region-modal h4 {

View file

@ -1,7 +1,7 @@
import { describe, it, mock } from "node:test";
import expect from "expect";
import { TFile } from "obsidian";
import { getTerrainFromFile, setTerrainInFile, getIconOverrideFromFile, setIconOverrideInFile } from "../src/frontmatter";
import { getTerrainFromFile, setTerrainInFile, getIconOverrideFromFile, setIconOverrideInFile, getSubmapFromFile, setSubmapInFile } from "../src/frontmatter";
/** Build a minimal mock App backed by an in-memory string. */
function makeApp(filePath: string, initialContent: string) {
@ -219,3 +219,143 @@ describe("getIconOverrideFromFile", () => {
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
});
// ── setSubmapInFile ───────────────────────────────────────────────────────────
describe("setSubmapInFile", () => {
it("returns false when the file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const result = await setSubmapInFile(app, "missing.md", "the-coast");
expect(result).toBe(false);
});
it("writes duckmage-submap to a file with no existing frontmatter", async () => {
const { app, getContent } = makeApp("hex.md", "Body text.");
await setSubmapInFile(app, "hex.md", "the-coast");
expect(getContent()).toContain("duckmage-submap: the-coast");
});
it("updates an existing duckmage-submap value", async () => {
const { app, getContent } = makeApp("hex.md", "---\nduckmage-submap: old-map\n---\n");
await setSubmapInFile(app, "hex.md", "new-map");
expect(getContent()).toContain("duckmage-submap: new-map");
expect(getContent()).not.toContain("old-map");
});
it("removes duckmage-submap when passed null", async () => {
const { app, getContent } = makeApp("hex.md", "---\nduckmage-submap: the-coast\nterrain: plains\n---\n");
await setSubmapInFile(app, "hex.md", null);
expect(getContent()).not.toContain("duckmage-submap");
expect(getContent()).toContain("terrain: plains");
});
});
// ── getSubmapFromFile ─────────────────────────────────────────────────────────
describe("getSubmapFromFile", () => {
it("returns the submap name when present", () => {
const { app } = makeAppWithCache("hex.md", { "duckmage-submap": "the-coast" });
expect(getSubmapFromFile(app, "hex.md")).toBe("the-coast");
});
it("returns undefined when the key is absent", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "plains" });
expect(getSubmapFromFile(app, "hex.md")).toBeUndefined();
});
it("returns undefined when the file does not exist", () => {
const { app } = makeAppWithCache("hex.md", null);
expect(getSubmapFromFile(app, "missing.md")).toBeUndefined();
});
it("returns undefined when the value is not a string", () => {
const { app } = makeAppWithCache("hex.md", { "duckmage-submap": 42 });
expect(getSubmapFromFile(app, "hex.md")).toBeUndefined();
});
});
// ── updateSubmapReferences ────────────────────────────────────────────────────
describe("updateSubmapReferences", () => {
function makeMultiFileApp(files: Record<string, { content: string; submap?: string }>) {
const stored: Record<string, string> = {};
const tfiles: Record<string, TFile> = {};
for (const [path, { content }] of Object.entries(files)) {
stored[path] = content;
const f = Object.create(TFile.prototype) as TFile;
f.path = path;
tfiles[path] = f;
}
const app = {
vault: {
getAbstractFileByPath: (p: string) => tfiles[p] ?? null,
getMarkdownFiles: () => Object.values(tfiles),
},
fileManager: {
processFrontMatter: mock.fn(async (file: TFile, fn: (fm: Record<string, unknown>) => void) => {
const content = stored[file.path] ?? "";
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
const fm: Record<string, unknown> = {};
if (fmMatch) {
for (const line of fmMatch[1].split("\n")) {
const m = line.match(/^([\w-]+):\s*(.+)$/);
if (m) fm[m[1]] = m[2].trim();
}
}
const rest = fmMatch ? content.slice(fmMatch[0].length) : content;
fn(fm);
const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join("\n");
stored[file.path] = fmLines ? `---\n${fmLines}\n---\n${rest}` : rest;
}),
},
metadataCache: {
getFileCache: mock.fn((file: TFile) => {
const submap = files[file.path]?.submap;
return submap ? { frontmatter: { "duckmage-submap": submap } } : null;
}),
},
} as unknown as import("obsidian").App;
return { app, getContent: (p: string) => stored[p] ?? "" };
}
it("updates duckmage-submap in all matching hex notes", async () => {
const { app, getContent } = makeMultiFileApp({
"world/hexes/map-a/1_1.md": { content: "---\nduckmage-submap: old-map\n---\n", submap: "old-map" },
"world/hexes/map-a/2_2.md": { content: "---\nduckmage-submap: old-map\n---\n", submap: "old-map" },
"world/hexes/map-a/3_3.md": { content: "---\nterrain: plains\n---\n" },
});
// Simulate updateSubmapReferences logic directly
const hexFolder = "world/hexes";
const files = app.vault.getMarkdownFiles().filter(
(f: TFile) => f.path.startsWith(hexFolder + "/"),
);
for (const file of files) {
const submap = app.metadataCache.getFileCache(file)?.frontmatter?.["duckmage-submap"];
if (submap !== "old-map") continue;
await setSubmapInFile(app, file.path, "new-map");
}
expect(getContent("world/hexes/map-a/1_1.md")).toContain("duckmage-submap: new-map");
expect(getContent("world/hexes/map-a/2_2.md")).toContain("duckmage-submap: new-map");
expect(getContent("world/hexes/map-a/3_3.md")).not.toContain("duckmage-submap");
});
it("does not touch notes whose submap points to a different map", async () => {
const { app, getContent } = makeMultiFileApp({
"world/hexes/map-a/1_1.md": { content: "---\nduckmage-submap: other-map\n---\n", submap: "other-map" },
});
const files = app.vault.getMarkdownFiles();
for (const file of files) {
const submap = app.metadataCache.getFileCache(file)?.frontmatter?.["duckmage-submap"];
if (submap !== "old-map") continue;
await setSubmapInFile(app, file.path, "new-map");
}
expect(getContent("world/hexes/map-a/1_1.md")).toContain("duckmage-submap: other-map");
});
});

View file

@ -170,7 +170,6 @@ describe("HexEditorModal — HexEditorOptions", () => {
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(
app, plugin, 1, 1, "default", () => {},
undefined, undefined,
{ gmLayerActive: true },
);
expect((modal as any).options.gmLayerActive).toBe(true);