mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
add initial gm layer pass
This commit is contained in:
parent
31f9bbe0d8
commit
d91ea4eb57
11 changed files with 421 additions and 43 deletions
|
|
@ -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"];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
11
src/types.ts
11
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;
|
||||
}
|
||||
|
|
|
|||
28
styles.css
28
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 */
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue