mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
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:
parent
a23a98b982
commit
31d3256192
11 changed files with 642 additions and 26 deletions
|
|
@ -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```";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
135
src/hex-map/MapLinkModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
140
src/hex-map/SubmapPickerModal.ts
Normal file
140
src/hex-map/SubmapPickerModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
53
styles.css
53
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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue