add initial gm layer pass

This commit is contained in:
isaprettycoolguy@protonmail.com 2026-05-10 16:22:38 -04:00
parent 31f9bbe0d8
commit d91ea4eb57
11 changed files with 421 additions and 43 deletions

View file

@ -233,7 +233,7 @@ export default class HexmakerPlugin extends Plugin {
async loadSettings() {
// One-time migration: rename settings keys "regions" → "maps" and "defaultRegion" → "defaultMap"
// Must run on raw data BEFORE Object.assign so the migrated key wins over DEFAULT_SETTINGS.
const data = (((await this.loadData()) as Record<string, unknown>) ?? {}) as Record<string, unknown>;
const data = ((await this.loadData()) ?? {}) as Record<string, unknown>;
if (data["regions"] !== undefined && data["maps"] === undefined) {
data["maps"] = data["regions"];
}

View file

@ -4,6 +4,7 @@ export interface Frontmatter {
[key: string]: string | string[] | boolean | undefined;
terrain?: string;
icon?: string;
"gm-icon"?: string;
tags?: string[];
aliases?: string[];
cssclass?: string;
@ -130,3 +131,25 @@ export async function setIconOverrideInFile(
});
return true;
}
export function getGmIconFromFile(app: App, path: string): string | null {
const icon = getFrontMatter(app, path)?.["gm-icon"];
return typeof icon === "string" ? icon : null;
}
export async function setGmIconInFile(
app: App,
path: string,
icon: string | null,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
if (icon === null) {
delete fm["gm-icon"];
} else {
fm["gm-icon"] = icon;
}
});
return true;
}

View file

@ -169,7 +169,7 @@ export class FactionPickerModal extends HexmakerModal {
const preview = tile.createDiv({ cls: "duckmage-faction-tile-preview" });
if (color) {
preview.style.backgroundColor = color;
preview.setCssProps({ "--duckmage-tile-color": color });
} else {
preview.addClass("duckmage-faction-tile-preview-empty");
}
@ -196,10 +196,10 @@ export class FactionPickerModal extends HexmakerModal {
(newColor, newBasename) => {
color = newColor;
if (newColor) {
preview.style.backgroundColor = newColor;
preview.setCssProps({ "--duckmage-tile-color": newColor });
preview.removeClass("duckmage-faction-tile-preview-empty");
} else {
preview.style.backgroundColor = "";
preview.setCssProps({ "--duckmage-tile-color": "" });
preview.addClass("duckmage-faction-tile-preview-empty");
}
nameEl.setText(newBasename);

View file

@ -162,7 +162,7 @@ export class GeoRegionPickerModal extends HexmakerModal {
const preview = tile.createDiv({ cls: "duckmage-faction-tile-preview" });
if (color) {
preview.style.backgroundColor = color;
preview.setCssProps({ "--duckmage-tile-color": color });
} else {
preview.addClass("duckmage-faction-tile-preview-empty");
}
@ -187,10 +187,10 @@ export class GeoRegionPickerModal extends HexmakerModal {
(newColor, newBasename) => {
color = newColor;
if (newColor) {
preview.style.backgroundColor = newColor;
preview.setCssProps({ "--duckmage-tile-color": newColor });
preview.removeClass("duckmage-faction-tile-preview-empty");
} else {
preview.style.backgroundColor = "";
preview.setCssProps({ "--duckmage-tile-color": "" });
preview.addClass("duckmage-faction-tile-preview-empty");
}
nameEl.setText(newBasename);

View file

@ -11,6 +11,7 @@ import {
getTerrainFromFile,
setTerrainInFile,
setIconOverrideInFile,
setGmIconInFile,
} from "../frontmatter";
import {
addLinkToSection,
@ -22,7 +23,7 @@ import {
} from "../sections";
import { FileLinkSuggestModal } from "./FileLinkSuggestModal";
import { TEXT_SECTIONS } from "../types";
import type { LinkSection } from "../types";
import type { LinkSection, HexEditorOptions } from "../types";
import { RandomTableModal } from "../random-tables/RandomTableModal";
import { VIEW_TYPE_HEX_MAP, VIEW_TYPE_RANDOM_TABLES } from "../constants";
@ -32,6 +33,7 @@ export class HexEditorModal extends HexmakerModal {
private allLinks = new Map<string, string[]>();
private directTerrain: string | null = null;
private directIcon: string | null = null;
private directGmIcon: string | null = null;
private dataPreloaded = false;
constructor(
@ -46,6 +48,7 @@ export class HexEditorModal extends HexmakerModal {
) => void,
private onNavigate?: (x: number, y: number) => void,
private onModalClose?: () => void,
private options: HexEditorOptions = {},
) {
super(app);
}
@ -57,6 +60,7 @@ export class HexEditorModal extends HexmakerModal {
this.allLinks = new Map();
this.directTerrain = null;
this.directIcon = null;
this.directGmIcon = null;
const path = this.plugin.hexPath(this.x, this.y, this.mapName);
const file = this.app.vault.getAbstractFileByPath(path);
@ -72,6 +76,8 @@ export class HexEditorModal extends HexmakerModal {
if (tm) this.directTerrain = tm[1].trim();
const im = fmMatch[1].match(/^\s*icon:\s*(.+)$/m);
if (im) this.directIcon = im[1].trim();
const gm = fmMatch[1].match(/^\s*gm-icon:\s*(.+)$/m);
if (gm) this.directGmIcon = gm[1].trim();
}
({ text: this.allText, links: this.allLinks } = await getAllSectionData(
@ -176,16 +182,17 @@ export class HexEditorModal extends HexmakerModal {
});
}
}
this.renderTerrainSection(terrainBody, path, directTerrain, directIcon);
this.renderTerrainSection(terrainBody, path, directTerrain, directIcon, this.directGmIcon);
bodyEl.createEl("hr", { cls: "duckmage-editor-divider" });
const { body: notesBody } = this.makeCollapsible(
bodyEl,
"Notes",
s.hexEditorNotesCollapsed ?? false,
!this.options.gmLayerActive && (s.hexEditorNotesCollapsed ?? false),
);
for (const { key, label } of TEXT_SECTIONS) {
if (!this.options.gmLayerActive && (key === "hidden" || key === "secret")) continue;
this.renderTextSection(
notesBody,
path,
@ -363,6 +370,7 @@ export class HexEditorModal extends HexmakerModal {
path: string,
currentTerrain: string | null,
currentIcon: string | null,
currentGmIcon: string | null,
): void {
const palette = this.plugin.getMapPalette(this.mapName);
@ -483,6 +491,54 @@ export class HexEditorModal extends HexmakerModal {
visibility: iconSelect.value ? "visible" : "hidden",
});
});
// GM icon — only visible when GM layer is active
if (this.options.gmLayerActive) {
const gmRow = section.createDiv({ cls: "duckmage-icon-override-row" });
gmRow.createSpan({ text: "GM icon", cls: "duckmage-icon-override-label" });
const gmSelect = gmRow.createEl("select", {
cls: "duckmage-icon-override-select",
});
gmSelect.createEl("option", { value: "", text: "— none —" });
for (const icon of this.plugin.availableIcons) {
const label = icon
.replace(/^bw-/, "")
.replace(/\.png$/, "")
.replace(/-/g, " ");
gmSelect.createEl("option", { value: icon, text: label });
}
gmSelect.value = currentGmIcon ?? "";
const clearGmBtn = gmRow.createEl("button", {
text: "Clear",
cls: "duckmage-clear-btn",
title: "Remove GM icon",
});
clearGmBtn.setCssProps({
visibility: currentGmIcon ? "visible" : "hidden",
});
gmSelect.addEventListener("change", () => {
void (async () => {
await this.ensureHexNote();
await setGmIconInFile(this.app, path, gmSelect.value || null);
this.onChanged();
clearGmBtn.setCssProps({
visibility: gmSelect.value ? "visible" : "hidden",
});
})();
});
clearGmBtn.addEventListener("click", () => {
void (async () => {
await this.ensureHexNote();
await setGmIconInFile(this.app, path, null);
this.onChanged();
gmSelect.value = "";
clearGmBtn.setCssProps({ visibility: "hidden" });
})();
});
}
}
private getFilesForDropdown(
@ -854,7 +910,7 @@ export class HexEditorModal extends HexmakerModal {
initialContent: string,
): void {
const sectionEl = container.createDiv({
cls: "duckmage-editor-text-section",
cls: `duckmage-editor-text-section duckmage-editor-text-section-${section}`,
});
const labelRow = sectionEl.createDiv({
cls: "duckmage-text-section-label-row",

View file

@ -4,6 +4,8 @@ import { normalizeFolder, getIconUrl, createIconEl } from "../utils";
import {
getTerrainFromFile,
getIconOverrideFromFile,
getGmIconFromFile,
setGmIconInFile,
setTerrainInFile,
setIconOverrideInFile,
Frontmatter,
@ -93,6 +95,7 @@ export class HexMapView extends ItemView {
private activePathChain: PathChain | null = null;
private paintTerrainName: string | null = null;
private paintIconName: string | null = null;
private paintIconGmOnly = false;
private terrainPickMode = false;
private paintBrushSize: 1 | 3 | 7 = 1;
private brushHoverHexes: Array<[number, number]> = [];
@ -107,7 +110,11 @@ export class HexMapView extends ItemView {
string,
{ x: number; y: number; icon: string | null }
>();
private flushing = new Set<string>(); // "t:<path>" or "i:<path>"
private pendingGmIconWrites = new Map<
string,
{ x: number; y: number; icon: string | null }
>();
private flushing = new Set<string>(); // "t:<path>", "i:<path>", or "g:<path>"
// Faction links painted but not yet reflected in the metadata cache
private pendingFactionLinks = new Map<string, Set<string>>();
// Faction links erased but cache may still reflect them — excluded from overlay
@ -504,6 +511,7 @@ export class HexMapView extends ItemView {
() => this.getActiveMap(),
(show) => { if (show) this.updateFactionOverlay(); else this.clearFactionOverlay(); },
(show) => { if (show) this.updateRegionOverlay(); else this.clearRegionOverlay(); },
() => { this.updateGmIcons(); },
);
toolsPanel.onBeforeOpen = () => this.overlayPanel?.close();
this.overlayPanel.onBeforeOpen = () => toolsPanel.close();
@ -774,18 +782,25 @@ export class HexMapView extends ItemView {
private handleIconButton(): void {
if (this.drawingMode === "icon") { this.exitIconMode(); return; }
new IconPickerModal(this.app, this.plugin, (iconName: string | null) => {
this.drawingMode = "icon";
this.paintIconName = iconName;
this.paintTerrainName = null;
this.updateToolbarButtonStates();
}).open();
new IconPickerModal(
this.app,
this.plugin,
(iconName: string | null, gmOnly: boolean) => {
this.drawingMode = "icon";
this.paintIconName = iconName;
this.paintIconGmOnly = gmOnly;
this.paintTerrainName = null;
this.updateToolbarButtonStates();
},
this.paintIconGmOnly,
).open();
}
private exitIconMode(): void {
if (this.drawingMode !== "icon") return;
this.drawingMode = null;
this.paintIconName = null;
this.paintIconGmOnly = false;
this.updateBrushHighlight(null, null);
this.updateToolbarButtonStates();
}
@ -989,11 +1004,17 @@ export class HexMapView extends ItemView {
this.pendingTerrainWrites.delete(pathB);
this.pendingIconWrites.delete(pathA);
this.pendingIconWrites.delete(pathB);
this.pendingGmIconWrites.delete(pathA);
this.pendingGmIconWrites.delete(pathB);
// Wait for any already-in-flight flushes on these paths to finish before
// renaming files — a flush that completes after the rename would write
// terrain to the wrong file or recreate a file that was just moved.
const flushKeys = [`t:${pathA}`, `t:${pathB}`, `i:${pathA}`, `i:${pathB}`];
const flushKeys = [
`t:${pathA}`, `t:${pathB}`,
`i:${pathA}`, `i:${pathB}`,
`g:${pathA}`, `g:${pathB}`,
];
const deadline = Date.now() + 2000;
while (
flushKeys.some((k) => this.flushing.has(k)) &&
@ -1320,6 +1341,10 @@ export class HexMapView extends ItemView {
);
}
// Tag hex for GM icon overlay (rendered in path SVG when GM layer is active)
const gmIcon = getGmIconFromFile(this.app, path);
if (gmIcon) hexEl.dataset.gmIcon = gmIcon;
if (this.selectedHex?.x === x && this.selectedHex?.y === y)
hexEl.addClass("is-selected");
@ -1393,6 +1418,7 @@ export class HexMapView extends ItemView {
this.selectedHex = null;
}
},
{ gmLayerActive: this.getActiveMap().showGmLayer ?? true },
);
modal.open();
}
@ -1580,28 +1606,40 @@ export class HexMapView extends ItemView {
if (this.drawingMode !== "icon") return;
const icon = this.paintIconName;
const path = this.plugin.hexPath(x, y, this.activeMapName);
// ── Immediate visual update ────────────────────────────────────────────
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${x}"][data-y="${y}"]`,
);
if (hexEl) {
hexEl.querySelector(".duckmage-hex-icon")?.remove();
if (icon) {
const img = hexEl.createEl("img", { cls: "duckmage-hex-icon" });
img.src = getIconUrl(this.plugin, icon);
img.alt = icon;
hexEl.insertBefore(img, hexEl.querySelector(".duckmage-hex-label"));
hexEl.dataset.iconOverride = icon;
} else {
delete hexEl.dataset.iconOverride;
}
if (icon !== null) hexEl.addClass("duckmage-hex-exists");
}
this.updatePathOverlay();
// ── Queue background file write (coalescing per-hex) ──────────────────
this.scheduleIconWrite(x, y, path, icon);
if (this.paintIconGmOnly) {
// ── GM icon — additive badge, terrain icon untouched ────────────────
if (hexEl) {
if (icon) {
hexEl.dataset.gmIcon = icon;
} else {
delete hexEl.dataset.gmIcon;
}
if (icon !== null) hexEl.addClass("duckmage-hex-exists");
}
this.updateGmIcons();
this.scheduleGmIconWrite(x, y, path, icon);
} else {
// ── Regular icon override — existing behaviour ───────────────────────
if (hexEl) {
hexEl.querySelector(".duckmage-hex-icon")?.remove();
if (icon) {
const img = hexEl.createEl("img", { cls: "duckmage-hex-icon" });
img.src = getIconUrl(this.plugin, icon);
img.alt = icon;
hexEl.insertBefore(img, hexEl.querySelector(".duckmage-hex-label"));
hexEl.dataset.iconOverride = icon;
} else {
delete hexEl.dataset.iconOverride;
}
if (icon !== null) hexEl.addClass("duckmage-hex-exists");
}
this.updatePathOverlay();
this.scheduleIconWrite(x, y, path, icon);
}
}
private async onHexTableLinkClick(x: number, y: number): Promise<void> {
@ -1838,6 +1876,7 @@ export class HexMapView extends ItemView {
const count =
this.pendingTerrainWrites.size +
this.pendingIconWrites.size +
this.pendingGmIconWrites.size +
this.flushing.size;
if (this.savingIndicatorEl) {
if (count > 0) {
@ -1979,6 +2018,69 @@ export class HexMapView extends ItemView {
}
}
private scheduleGmIconWrite(
x: number,
y: number,
path: string,
icon: string | null,
): void {
this.pendingGmIconWrites.set(path, { x, y, icon });
this.updateSavingIndicator();
if (!this.flushing.has(`g:${path}`)) void this.flushGmIconWrites(path);
}
private async flushGmIconWrites(path: string): Promise<void> {
const key = `g:${path}`;
this.flushing.add(key);
this.updateSavingIndicator();
try {
while (this.pendingGmIconWrites.has(path)) {
const { x, y, icon } = this.pendingGmIconWrites.get(path)!;
this.pendingGmIconWrites.delete(path);
let attempt = 0;
while (true) {
if (attempt > 0)
await new Promise<void>((r) =>
setTimeout(r, Math.min(200 * (1 << (attempt - 1)), 2000)),
);
try {
const onDisk = !!this.app.vault.getAbstractFileByPath(path);
if (icon === null) {
if (onDisk) await setGmIconInFile(this.app, path, null);
} else {
if (!onDisk) {
if (
!(await this.plugin.createHexNote(
x,
y,
this.activeMapName,
))
) {
this.renderGrid();
return;
}
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${x}"][data-y="${y}"]`)
?.addClass("duckmage-hex-exists");
}
await setGmIconInFile(this.app, path, icon);
}
break; // success
} catch (err) {
attempt++;
console.warn(
`[duckmage] gm-icon write attempt ${attempt} failed for ${path}:`,
err,
);
}
}
}
} finally {
this.flushing.delete(key);
this.updateSavingIndicator();
}
}
private handlePathButton(): void {
if (this.drawingMode === "path") {
this.exitPathMode();
@ -2145,9 +2247,11 @@ export class HexMapView extends ItemView {
});
const region = this.getActiveMap();
const gmLayerActive = region.showGmLayer ?? true;
const hasContent =
region.pathChains.some((c) => c.hexes.length > 0) ||
this.activePathEnd !== null;
this.activePathEnd !== null ||
(gmLayerActive && gridContainer.querySelector("[data-gm-icon]") !== null);
if (!hasContent) return;
// Build hex center map — offsetLeft/offsetTop are unaffected by CSS transform
@ -2315,6 +2419,29 @@ export class HexMapView extends ItemView {
svg.appendChild(textEl);
});
// GM layer icons — small badge in the top-right quadrant of the hex.
// Rendered above coord labels so they're always visible.
// The terrain icon is NOT hidden — gm-icon is purely additive.
if (gmLayerActive) {
gridContainer
.querySelectorAll<HTMLElement>("[data-gm-icon]")
.forEach((hexEl) => {
const gmIconName = hexEl.dataset.gmIcon!;
const key = `${hexEl.dataset.x!}_${hexEl.dataset.y!}`;
const pos = centerMap.get(key);
if (!pos) return;
const size = Math.round(hexEl.offsetWidth * 0.38);
const imgEl = document.createElementNS(svgNS, "image");
imgEl.setAttribute("x", String(pos.cx + hexEl.offsetWidth * 0.12));
imgEl.setAttribute("y", String(pos.cy - hexEl.offsetHeight * 0.46));
imgEl.setAttribute("width", String(size));
imgEl.setAttribute("height", String(size));
imgEl.setAttribute("href", getIconUrl(this.plugin, gmIconName));
imgEl.setAttribute("class", "duckmage-svg-gm-icon");
svg.appendChild(imgEl);
});
}
this.viewportEl?.addClass("duckmage-svg-labels-active");
this.viewportEl?.appendChild(svg);
}
@ -2330,6 +2457,16 @@ export class HexMapView extends ItemView {
this.renderPathOverlay(gridContainer);
}
// GM icons live inside the path SVG. Re-render that SVG only if the grid
// already exists — never fall back to renderGrid (would cause infinite loop
// when called from syncToRegion inside an in-progress renderGrid).
private updateGmIcons(): void {
const gridContainer = this.viewportEl?.querySelector<HTMLElement>(
".duckmage-hex-map-grid",
);
if (gridContainer) this.renderPathOverlay(gridContainer);
}
private updateFactionOverlay(): void {
const gridContainer = this.viewportEl?.querySelector<HTMLElement>(
".duckmage-hex-map-grid",

View file

@ -98,9 +98,11 @@ export class OverlayPanel extends HexSidePanel {
private getActiveMap: () => MapData;
private onFactionOverlayChange: (show: boolean) => void;
private onRegionOverlayChange: (show: boolean) => void;
private onGmLayerChange: (show: boolean) => void;
private checkboxes = new Map<OverlayKey, HTMLInputElement>();
private factionOverlayCb: HTMLInputElement | null = null;
private regionOverlayCb: HTMLInputElement | null = null;
private gmLayerCb: HTMLInputElement | null = null;
constructor(
container: HTMLElement,
@ -109,6 +111,7 @@ export class OverlayPanel extends HexSidePanel {
getActiveMap: () => MapData,
onFactionOverlayChange: (show: boolean) => void,
onRegionOverlayChange: (show: boolean) => void,
onGmLayerChange: (show: boolean) => void,
) {
super(container, "layers", 44, "Map overlays");
this.plugin = plugin;
@ -116,6 +119,7 @@ export class OverlayPanel extends HexSidePanel {
this.getActiveMap = getActiveMap;
this.onFactionOverlayChange = onFactionOverlayChange;
this.onRegionOverlayChange = onRegionOverlayChange;
this.onGmLayerChange = onGmLayerChange;
this.buildPanel(this.panelEl);
}
@ -196,6 +200,32 @@ export class OverlayPanel extends HexSidePanel {
regionCb.checked = !regionCb.checked;
applyRegion();
});
// GM layer — default on (unlike the opt-in overlays above)
const gmRow = panel.createDiv({ cls: "duckmage-overlay-row" });
const gmCb = document.createElement("input");
gmCb.type = "checkbox";
gmCb.checked = true;
gmRow.appendChild(gmCb);
this.gmLayerCb = gmCb;
const gmLabel = gmRow.createSpan({
text: "GM layer",
cls: "duckmage-overlay-label",
});
const applyGm = () => {
const map = this.getActiveMap();
map.showGmLayer = gmCb.checked;
void this.plugin.saveSettings();
this.onGmLayerChange(gmCb.checked);
};
gmCb.addEventListener("change", applyGm);
gmLabel.addEventListener("click", () => {
gmCb.checked = !gmCb.checked;
applyGm();
});
}
/** Read the current map's saved state and apply it to the viewport + checkboxes. */
@ -221,6 +251,12 @@ export class OverlayPanel extends HexSidePanel {
this.regionOverlayCb.checked = show;
this.onRegionOverlayChange(show);
}
// GM layer — undefined → true (on by default)
if (this.gmLayerCb) {
const show = map.showGmLayer ?? true;
this.gmLayerCb.checked = show;
this.onGmLayerChange(show);
}
}
private applyClass(opt: OverlayOption, show: boolean): void {

View file

@ -5,13 +5,16 @@ import { getIconUrl, normalizeFolder } from "../utils";
export class IconPickerModal extends HexmakerModal {
private manageMode = false;
private gmOnly: boolean;
constructor(
app: App,
private plugin: HexmakerPlugin,
private onSelect: (iconName: string | null) => void,
private onSelect: (iconName: string | null, gmOnly: boolean) => void,
initialGmOnly = false,
) {
super(app);
this.gmOnly = initialGmOnly;
}
onOpen(): void {
@ -31,7 +34,24 @@ export class IconPickerModal extends HexmakerModal {
// ── Header ────────────────────────────────────────────────────────────
const header = contentEl.createDiv({ cls: "duckmage-icon-picker-header" });
header.createEl("h2", { text: "Paint icon" });
const manageBtn = header.createEl("button", {
const headerRight = header.createDiv({ cls: "duckmage-icon-picker-header-right" });
// GM layer only toggle
const gmRow = headerRight.createDiv({ cls: "duckmage-icon-gm-toggle-row" });
const gmCb = gmRow.createEl("input", { type: "checkbox" } as DomElementInfo);
(gmCb as HTMLInputElement).checked = this.gmOnly;
const gmLabel = gmRow.createSpan({ text: "GM layer only", cls: "duckmage-icon-gm-toggle-label" });
const applyGm = () => {
this.gmOnly = (gmCb as HTMLInputElement).checked;
};
gmCb.addEventListener("change", applyGm);
gmLabel.addEventListener("click", () => {
(gmCb as HTMLInputElement).checked = !(gmCb as HTMLInputElement).checked;
applyGm();
});
const manageBtn = headerRight.createEl("button", {
cls: "duckmage-icon-manage-btn",
text: this.manageMode ? "← pick icons" : "Manage icons",
});
@ -59,7 +79,7 @@ export class IconPickerModal extends HexmakerModal {
});
clearBtn.createSpan({ text: "Remove", cls: "duckmage-icon-option-name" });
clearBtn.addEventListener("click", () => {
this.onSelect(null);
this.onSelect(null, this.gmOnly);
this.close();
});
}
@ -100,7 +120,7 @@ export class IconPickerModal extends HexmakerModal {
});
} else {
btn.addEventListener("click", () => {
this.onSelect(icon);
this.onSelect(icon, this.gmOnly);
this.close();
});
}

View file

@ -25,6 +25,7 @@ export interface MapData {
showIconOverrides?: boolean; // undefined = true
showFactionOverlay?: boolean; // undefined = false (opt-in)
showRegionOverlay?: boolean; // undefined = false (opt-in)
showGmLayer?: boolean; // undefined = true (on by default)
}
export interface TerrainPalette {
@ -79,3 +80,13 @@ export const TEXT_SECTIONS = [
{ key: "hidden", label: "Hidden" },
{ key: "secret", label: "Secret" },
] as const;
/**
* Session-only flags passed from HexMapView into HexEditorModal.
* All fields are optional modal defaults missing keys to false.
* Add new session-layer flags here; no constructor signature changes needed.
*/
export interface HexEditorOptions {
/** GM layer is active: force Notes open, highlight Hidden/Secret sections. */
gmLayerActive?: boolean;
}

View file

@ -1314,8 +1314,28 @@ div.duckmage-terrain-preview-icon {
margin: 0;
}
.duckmage-icon-manage-btn {
.duckmage-icon-picker-header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.duckmage-icon-gm-toggle-row {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.82em;
color: var(--text-muted);
}
.duckmage-icon-gm-toggle-label {
cursor: pointer;
user-select: none;
}
.duckmage-icon-manage-btn {
padding: 3px 10px;
font-size: 0.8em;
border-radius: 4px;
@ -1883,6 +1903,11 @@ div.duckmage-terrain-preview-icon {
color: var(--interactive-accent);
}
/* GM icon SVG overlay — small badge rendered in top-right quadrant of hex */
.duckmage-svg-gm-icon {
opacity: 0.9;
}
/* Help modal content */
.duckmage-help-modal {
max-width: 560px;
@ -3258,6 +3283,7 @@ tr:hover .duckmage-rt-entry-copy-btn:hover {
height: 48px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background-color: var(--duckmage-tile-color);
}
/* Empty / no-color state: diagonal stripe */

View file

@ -115,6 +115,75 @@ describe("HexEditorModal.loadData", () => {
});
});
// ── GM icon (gm-icon frontmatter) ────────────────────────────────────────────
describe("HexEditorModal.loadData — gm-icon", () => {
it("extracts directGmIcon from frontmatter", async () => {
const app = makeApp({ "hex/1_1.md": "---\nterrain: forest\ngm-icon: skull.png\n---\n\n" });
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directGmIcon).toBe("skull.png");
});
it("leaves directGmIcon null when frontmatter has no gm-icon field", async () => {
const app = makeApp({ "hex/1_1.md": "---\nterrain: forest\n---\n\n" });
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directGmIcon).toBeNull();
});
it("resets directGmIcon to null when navigating to a hex with no gm-icon", async () => {
const app = makeApp({
"hex/1_1.md": "---\nterrain: forest\ngm-icon: skull.png\n---\n\n",
"hex/2_1.md": "---\nterrain: desert\n---\n\n",
});
const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`);
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directGmIcon).toBe("skull.png");
(modal as any).x = 2;
(modal as any).y = 1;
await modal.loadData();
expect((modal as any).directGmIcon).toBeNull();
});
it("does not confuse icon and gm-icon when both are present", async () => {
const app = makeApp({
"hex/3_3.md": "---\nterrain: forest\nicon: tower.png\ngm-icon: skull.png\n---\n\n",
});
const plugin = makePlugin(() => "hex/3_3.md");
const modal = new HexEditorModal(app, plugin, 3, 3, "default", () => {});
await modal.loadData();
expect((modal as any).directIcon).toBe("tower.png");
expect((modal as any).directGmIcon).toBe("skull.png");
});
});
// ── HexEditorOptions ──────────────────────────────────────────────────────────
describe("HexEditorModal — HexEditorOptions", () => {
it("stores options.gmLayerActive when provided", () => {
const app = makeApp({});
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);
});
it("defaults options.gmLayerActive to undefined when no options passed", () => {
const app = makeApp({});
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
expect((modal as any).options.gmLayerActive).toBeUndefined();
});
});
// ── Navigation: reload on hex change ─────────────────────────────────────────
describe("HexEditorModal navigation reload", () => {