From 31d3256192b031c261e29e9c80d352aba49f8d0a Mon Sep 17 00:00:00 2001 From: "isaprettycoolguy@protonmail.com" <6576516+nyxsys@users.noreply.github.com> Date: Sat, 23 May 2026 12:29:22 -0400 Subject: [PATCH] add submap links: link hex to another map, centre-dot nav, back button, map link insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/HexmakerPlugin.ts | 50 ++++++++++- src/frontmatter.ts | 19 +++++ src/hex-map/HexEditorModal.ts | 22 ++++- src/hex-map/HexMapView.ts | 94 +++++++++++++++++--- src/hex-map/MapLinkModal.ts | 135 +++++++++++++++++++++++++++++ src/hex-map/MapModal.ts | 6 +- src/hex-map/SubmapPickerModal.ts | 140 ++++++++++++++++++++++++++++++ src/types.ts | 6 ++ styles.css | 53 +++++++++++- tests/frontmatter.test.ts | 142 ++++++++++++++++++++++++++++++- tests/hexEditorModal.test.ts | 1 - 11 files changed, 642 insertions(+), 26 deletions(-) create mode 100644 src/hex-map/MapLinkModal.ts create mode 100644 src/hex-map/SubmapPickerModal.ts diff --git a/src/HexmakerPlugin.ts b/src/HexmakerPlugin.ts index c0deb40..f832abd 100644 --- a/src/HexmakerPlugin.ts +++ b/src/HexmakerPlugin.ts @@ -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 { + 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 | 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```"; diff --git a/src/frontmatter.ts b/src/frontmatter.ts index 868c158..4d8f652 100644 --- a/src/frontmatter.ts +++ b/src/frontmatter.ts @@ -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 { + 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; +} diff --git a/src/hex-map/HexEditorModal.ts b/src/hex-map/HexEditorModal.ts index 56fa4f1..ca3c8a1 100644 --- a/src/hex-map/HexEditorModal.ts +++ b/src/hex-map/HexEditorModal.ts @@ -12,6 +12,7 @@ import { setTerrainInFile, setIconOverrideInFile, setGmIconInFile, + getSubmapFromFile, } from "../frontmatter"; import { addLinkToSection, @@ -46,8 +47,6 @@ export class HexEditorModal extends HexmakerModal { terrainOverrides?: Map, iconOverrides?: Map, ) => 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( diff --git a/src/hex-map/HexMapView.ts b/src/hex-map/HexMapView.ts index bde9abb..e2df791 100644 --- a/src/hex-map/HexMapView.ts +++ b/src/hex-map/HexMapView.ts @@ -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 { // 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( - `[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( + `[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") diff --git a/src/hex-map/MapLinkModal.ts b/src/hex-map/MapLinkModal.ts new file mode 100644 index 0000000..3a8b50a --- /dev/null +++ b/src/hex-map/MapLinkModal.ts @@ -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(); + } +} diff --git a/src/hex-map/MapModal.ts b/src/hex-map/MapModal.ts index b5c3975..18f0cc9 100644 --- a/src/hex-map/MapModal.ts +++ b/src/hex-map/MapModal.ts @@ -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(); diff --git a/src/hex-map/SubmapPickerModal.ts b/src/hex-map/SubmapPickerModal.ts new file mode 100644 index 0000000..07cb7db --- /dev/null +++ b/src/hex-map/SubmapPickerModal.ts @@ -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(); + } +} diff --git a/src/types.ts b/src/types.ts index e7e6dd1..b36ab84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; } diff --git a/styles.css b/styles.css index 475a2e2..31b47e2 100644 --- a/styles.css +++ b/styles.css @@ -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 { diff --git a/tests/frontmatter.test.ts b/tests/frontmatter.test.ts index 57c93e4..638eeb7 100644 --- a/tests/frontmatter.test.ts +++ b/tests/frontmatter.test.ts @@ -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) { + const stored: Record = {}; + const tfiles: Record = {}; + + 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) => void) => { + const content = stored[file.path] ?? ""; + const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + const fm: Record = {}; + 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"); + }); +}); diff --git a/tests/hexEditorModal.test.ts b/tests/hexEditorModal.test.ts index 3e549ef..6c7d0ca 100644 --- a/tests/hexEditorModal.test.ts +++ b/tests/hexEditorModal.test.ts @@ -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);