From d91ea4eb5786b218f1543eae08e4feb17fbb10b4 Mon Sep 17 00:00:00 2001 From: "isaprettycoolguy@protonmail.com" <6576516+nyxsys@users.noreply.github.com> Date: Sun, 10 May 2026 16:22:38 -0400 Subject: [PATCH] add initial gm layer pass --- src/HexmakerPlugin.ts | 2 +- src/frontmatter.ts | 23 ++++ src/hex-map/FactionPickerModal.ts | 6 +- src/hex-map/GeoRegionPickerModal.ts | 6 +- src/hex-map/HexEditorModal.ts | 64 +++++++++- src/hex-map/HexMapView.ts | 191 ++++++++++++++++++++++++---- src/hex-map/HexSidePanel.ts | 36 ++++++ src/hex-map/IconPickerModal.ts | 28 +++- src/types.ts | 11 ++ styles.css | 28 +++- tests/hexEditorModal.test.ts | 69 ++++++++++ 11 files changed, 421 insertions(+), 43 deletions(-) diff --git a/src/HexmakerPlugin.ts b/src/HexmakerPlugin.ts index af50b91..e060971 100644 --- a/src/HexmakerPlugin.ts +++ b/src/HexmakerPlugin.ts @@ -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) ?? {}) as Record; + const data = ((await this.loadData()) ?? {}) as Record; if (data["regions"] !== undefined && data["maps"] === undefined) { data["maps"] = data["regions"]; } diff --git a/src/frontmatter.ts b/src/frontmatter.ts index 397631a..8c8d0c2 100644 --- a/src/frontmatter.ts +++ b/src/frontmatter.ts @@ -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 { + 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; +} diff --git a/src/hex-map/FactionPickerModal.ts b/src/hex-map/FactionPickerModal.ts index 5e7e730..372b9b5 100644 --- a/src/hex-map/FactionPickerModal.ts +++ b/src/hex-map/FactionPickerModal.ts @@ -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); diff --git a/src/hex-map/GeoRegionPickerModal.ts b/src/hex-map/GeoRegionPickerModal.ts index 48c7220..5a5c014 100644 --- a/src/hex-map/GeoRegionPickerModal.ts +++ b/src/hex-map/GeoRegionPickerModal.ts @@ -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); diff --git a/src/hex-map/HexEditorModal.ts b/src/hex-map/HexEditorModal.ts index e9531a5..11ffc6a 100644 --- a/src/hex-map/HexEditorModal.ts +++ b/src/hex-map/HexEditorModal.ts @@ -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(); 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", diff --git a/src/hex-map/HexMapView.ts b/src/hex-map/HexMapView.ts index f9a5de4..de12f21 100644 --- a/src/hex-map/HexMapView.ts +++ b/src/hex-map/HexMapView.ts @@ -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(); // "t:" or "i:" + private pendingGmIconWrites = new Map< + string, + { x: number; y: number; icon: string | null } + >(); + private flushing = new Set(); // "t:", "i:", or "g:" // Faction links painted but not yet reflected in the metadata cache private pendingFactionLinks = new Map>(); // 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( `[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 { @@ -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 { + 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((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(`[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("[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( + ".duckmage-hex-map-grid", + ); + if (gridContainer) this.renderPathOverlay(gridContainer); + } + private updateFactionOverlay(): void { const gridContainer = this.viewportEl?.querySelector( ".duckmage-hex-map-grid", diff --git a/src/hex-map/HexSidePanel.ts b/src/hex-map/HexSidePanel.ts index db9088f..6696e2f 100644 --- a/src/hex-map/HexSidePanel.ts +++ b/src/hex-map/HexSidePanel.ts @@ -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(); 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 { diff --git a/src/hex-map/IconPickerModal.ts b/src/hex-map/IconPickerModal.ts index ce77628..f1a7c50 100644 --- a/src/hex-map/IconPickerModal.ts +++ b/src/hex-map/IconPickerModal.ts @@ -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(); }); } diff --git a/src/types.ts b/src/types.ts index 199f42d..801c5a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +} diff --git a/styles.css b/styles.css index fe407bf..3ead2cd 100644 --- a/styles.css +++ b/styles.css @@ -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 */ diff --git a/tests/hexEditorModal.test.ts b/tests/hexEditorModal.test.ts index 8db11b0..3e549ef 100644 --- a/tests/hexEditorModal.test.ts +++ b/tests/hexEditorModal.test.ts @@ -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", () => {