sbuffkin_hexmaker/src/hex-map/MapModal.ts
isaprettycoolguy@protonmail.com 1fa2153bf1 chore: address obsidianmd lint and popout-window compat (1.2.1)
- Sentence-case: rephrase "GM" → "Game master"; remove disable comments;
  autofixer lowercases "🎲 Open tables view" / "← Back" to comply
- no-static-styles-assignment: dynamic colors now use CSS custom properties
  (--duckmage-bg / --duckmage-border-color / --duckmage-fg) consumed by
  existing classes; static positions moved to small modifier classes
  (duckmage-rt-roll-btn-spaced, duckmage-wf-steps-list, etc.); terrain
  toolbar uses is-terrain-preview / is-eyedropper-active toggles;
  createIconEl uses new duckmage-masked-icon class
- Popout-window compat: setTimeout / clearTimeout / requestAnimationFrame
  → window.X; document.X → activeDocument.X across all view/modal files
- Unnecessary `as ArrayBuffer`: replace `data.buffer.slice(...) as ArrayBuffer`
  with `new ArrayBuffer + Uint8Array.set` — gets a real ArrayBuffer without
  the assertion (also avoids ArrayBufferLike narrowing)
- Deprecated SettingTab.display(): extract body into private renderSettings();
  display() and post-action refresh both call it
- eslint.config: add activeDocument + activeWindow to globals so the renamed
  identifiers don't trip no-undef

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 23:04:11 -04:00

603 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, Notice, TFolder } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { HexMapView } from "./HexMapView";
import { normalizeFolder, slugify, getIconUrl, createIconEl } from "../utils";
import { getSubmapFromFile, setSubmapInFile } from "../frontmatter";
import { exportMapAsPng } from "../export/mapPngRenderer";
import { exportMapAsPdf } from "../export/exporters/mapWithTable";
type ModalTab = "Maps" | "Properties" | "New map" | "Export";
function makeCheckbox(
parent: HTMLElement,
labelText: string,
initial: boolean,
): HTMLInputElement {
const row = parent.createDiv({ cls: "duckmage-export-tab-row" });
const cb = row.createEl("input", {
type: "checkbox",
cls: "duckmage-export-tab-checkbox",
});
cb.checked = initial;
row.createEl("label", { text: labelText, cls: "duckmage-export-tab-label" });
// Make the label clickable to toggle the box.
row.addEventListener("click", (e) => {
if (e.target instanceof HTMLInputElement) return;
cb.checked = !cb.checked;
});
return cb;
}
function clampInt(v: number, lo: number, hi: number, fallback: number): number {
if (Number.isNaN(v)) return fallback;
return Math.max(lo, Math.min(hi, v));
}
export class MapModal extends HexmakerModal {
private confirmingDelete: string | null = null;
private activeTab: ModalTab = "Maps";
constructor(
app: App,
private plugin: HexmakerPlugin,
private view: HexMapView,
private onChanged: () => void,
) {
super(app);
}
onOpen(): void {
this.titleEl.setText("Maps");
this.makeDraggable();
this.render();
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-region-modal");
// Tab bar
const tabBar = contentEl.createDiv({ cls: "duckmage-rt-mode-tabs duckmage-map-modal-tabs" });
const tabNames: ModalTab[] = ["Maps", "Properties", "New map", "Export"];
const contentDivs = new Map<ModalTab, HTMLElement>();
for (const tab of tabNames) {
const div = contentEl.createDiv({ cls: "duckmage-map-modal-tab-content" });
contentDivs.set(tab, div);
if (tab !== this.activeTab) div.hide();
}
const tabBtns = new Map<ModalTab, HTMLButtonElement>();
for (const tab of tabNames) {
const btn = tabBar.createEl("button", {
text: tab,
cls: "duckmage-rt-mode-tab" + (tab === this.activeTab ? " is-active" : ""),
});
tabBtns.set(tab, btn);
btn.addEventListener("click", () => {
this.activeTab = tab;
for (const [t, div] of contentDivs) {
if (t === tab) div.show(); else div.hide();
}
for (const [t, b] of tabBtns) {
if (t === tab) b.addClass("is-active"); else b.removeClass("is-active");
}
});
}
this.renderMapsTab(contentDivs.get("Maps")!);
this.renderPropertiesTab(contentDivs.get("Properties")!);
this.renderNewMapTab(contentDivs.get("New map")!);
this.renderExportTab(contentDivs.get("Export")!);
}
// ── Export tab ────────────────────────────────────────────────────────────
private renderExportTab(el: HTMLElement): void {
el.empty();
el.addClass("duckmage-export-tab");
const mapName = this.view.activeMapName;
el.createEl("p", {
cls: "duckmage-export-tab-hint",
text: `Export the active map "${mapName}" as a PNG. The file is written to the configured export folder and opened in a new tab.`,
});
const optsForm = el.createDiv({ cls: "duckmage-export-tab-options" });
// File name override — defaults to the map name; suffixes are auto-appended
// based on overlay checkboxes so the user can do successive variants without
// hand-renaming each export.
const nameRow = optsForm.createDiv({ cls: "duckmage-export-tab-row" });
nameRow.createEl("label", {
text: "File name",
cls: "duckmage-export-tab-label",
});
const nameInput = nameRow.createEl("input", {
type: "text",
cls: "duckmage-export-tab-text",
attr: { placeholder: mapName },
});
nameInput.value = mapName;
const showCoords = makeCheckbox(
optsForm,
"Show coordinate labels",
true,
);
const showIcons = makeCheckbox(
optsForm,
"Show terrain / override icons",
true,
);
const showPaths = makeCheckbox(
optsForm,
"Include paths (roads, rivers, etc.)",
true,
);
const showFactionOverlay = makeCheckbox(
optsForm,
"Include faction overlay",
false,
);
const showRegionOverlay = makeCheckbox(
optsForm,
"Include region overlay",
false,
);
// Output size: a dropdown of presets that map to a hex-radius value.
// The actual PNG dimensions depend on grid size too, so we phrase the
// presets by hex pixel size + approximate use-case.
const sizeRow = optsForm.createDiv({ cls: "duckmage-export-tab-row" });
sizeRow.createEl("label", {
text: "Output size",
cls: "duckmage-export-tab-label",
});
const sizeSelect = sizeRow.createEl("select", {
cls: "duckmage-export-tab-select",
});
const sizePresets: { label: string; radius: number }[] = [
{ label: "Small (30px hexes — quick preview)", radius: 30 },
{ label: "Medium (50px hexes — standard)", radius: 50 },
{ label: "Large (80px hexes — print quality)", radius: 80 },
{ label: "Huge (120px hexes — max detail)", radius: 120 },
];
for (const preset of sizePresets) {
const opt = sizeSelect.createEl("option", {
text: preset.label,
value: String(preset.radius),
});
if (preset.radius === 50) opt.selected = true;
}
// Live filename preview combines the user's base name with any overlay
// suffixes. Both suffixes attach in the order faction → region so multiple
// exports of the same map produce a predictable filename family. The
// preview shows the stem only — each export button adds its own extension.
const preview = el.createDiv({ cls: "duckmage-export-tab-preview" });
const buildStem = (): string => {
const base = nameInput.value.trim() || mapName;
let suffix = "";
if (showFactionOverlay.checked) suffix += "-faction";
if (showRegionOverlay.checked) suffix += "-region";
return base + suffix;
};
const updatePreview = () => {
preview.setText(`Output: ${buildStem()}.png / ${buildStem()}.pdf`);
};
nameInput.addEventListener("input", updatePreview);
showFactionOverlay.addEventListener("change", updatePreview);
showRegionOverlay.addEventListener("change", updatePreview);
updatePreview();
const collectOpts = () => ({
outputName: buildStem(),
hexRadius: clampInt(parseInt(sizeSelect.value, 10), 10, 200, 50),
showCoords: showCoords.checked,
showIcons: showIcons.checked,
showPaths: showPaths.checked,
showFactionOverlay: showFactionOverlay.checked,
showRegionOverlay: showRegionOverlay.checked,
});
const actions = el.createDiv({ cls: "duckmage-export-tab-actions" });
const exportPngBtn = actions.createEl("button", {
cls: "mod-cta",
text: "Export PNG",
});
exportPngBtn.addEventListener("click", () => {
void exportMapAsPng(this.plugin, mapName, collectOpts());
this.close();
});
const exportPdfBtn = actions.createEl("button", {
cls: "mod-cta",
text: "Export PDF with reference table",
});
exportPdfBtn.addEventListener("click", () => {
void exportMapAsPdf(this.plugin, mapName, collectOpts());
this.close();
});
}
// ── Maps tab ──────────────────────────────────────────────────────────────
private renderMapsTab(el: HTMLElement): void {
const list = el.createEl("ul", { cls: "duckmage-region-list" });
const canDelete = this.plugin.settings.maps.length > 1;
for (const map of this.plugin.settings.maps) {
const isActive = map.name === this.view.activeMapName;
const isConfirming = this.confirmingDelete === map.name;
const li = list.createEl("li", {
cls: "duckmage-region-item duckmage-map-list-item" + (isActive ? " is-active" : ""),
});
if (isConfirming) {
li.addClass("duckmage-map-item-confirming");
li.createSpan({
cls: "duckmage-map-delete-warning",
text: `Delete "${map.name}"? This will trash all its hex notes.`,
});
const confirmBtn = li.createEl("button", {
text: "Delete",
cls: "mod-warning duckmage-map-confirm-btn",
});
confirmBtn.addEventListener("click", (e) => {
e.stopPropagation();
void this.deleteMap(map.name);
});
const cancelBtn = li.createEl("button", {
text: "Cancel",
cls: "duckmage-map-cancel-btn",
});
cancelBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.confirmingDelete = null;
this.render();
});
} else {
const terrainEntry = map.terrainType
? this.plugin.getMapPalette(map.name).find((t) => t.name === map.terrainType)
: undefined;
const swatch = li.createSpan({ cls: "duckmage-map-terrain-swatch" });
if (terrainEntry?.color) {
swatch.style.backgroundColor = terrainEntry.color;
swatch.addClass("duckmage-map-terrain-swatch--set");
}
const nameSpan = li.createSpan({ text: map.name, cls: "duckmage-map-list-name" });
nameSpan.addEventListener("click", () => {
this.view.switchMapFromModal(map.name);
this.close();
});
li.createSpan({ cls: "duckmage-region-palette-badge", text: map.paletteName });
if (canDelete) {
const deleteBtn = li.createEl("button", {
text: "✕",
cls: "duckmage-map-delete-btn",
});
deleteBtn.setAttribute("aria-label", "Delete map");
deleteBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.confirmingDelete = map.name;
this.render();
});
}
}
}
}
// ── Properties tab ────────────────────────────────────────────────────────
private renderPropertiesTab(el: HTMLElement): void {
const currentMap = this.plugin.getMap(this.view.activeMapName);
// Rename
el.createEl("h4", { text: "Rename" });
const renameRow = el.createDiv({ cls: "duckmage-region-row" });
const renameInput = renameRow.createEl("input", {
type: "text",
value: this.view.activeMapName,
});
const renameBtn = renameRow.createEl("button", { text: "Rename", cls: "mod-cta" });
renameBtn.addEventListener("click", () =>
void this.renameMap(renameInput.value.trim(), renameBtn, renameInput),
);
renameInput.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter")
void this.renameMap(renameInput.value.trim(), renameBtn, renameInput);
});
// Stagger offset
el.createEl("h4", { text: "Stagger offset" });
const staggerRow = el.createDiv({ cls: "duckmage-region-row" });
let propStaggerVal: "odd" | "even" = currentMap?.staggerOffset ?? this.plugin.settings.staggerOffset ?? "odd";
const propStaggerBtn = staggerRow.createEl("button", { cls: "duckmage-stagger-toggle" });
propStaggerBtn.setText(propStaggerVal === "odd" ? "Odd" : "Even");
propStaggerBtn.toggleClass("is-even", propStaggerVal === "even");
propStaggerBtn.addEventListener("click", () => {
if (!currentMap) return;
propStaggerVal = propStaggerVal === "odd" ? "even" : "odd";
propStaggerBtn.setText(propStaggerVal === "odd" ? "Odd" : "Even");
propStaggerBtn.toggleClass("is-even", propStaggerVal === "even");
currentMap.staggerOffset = propStaggerVal;
void this.plugin.saveSettings().then(() => this.onChanged());
});
// Terrain theme
el.createEl("h4", { text: "Terrain theme" });
const terrainPalette = this.plugin.getMapPalette(this.view.activeMapName);
const terrainGrid = el.createDiv({ cls: "duckmage-terrain-picker" });
const clearTile = terrainGrid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-clear" +
(!currentMap?.terrainType ? " is-selected" : ""),
});
clearTile.createDiv({ cls: "duckmage-terrain-preview duckmage-terrain-preview-clear" });
clearTile.createSpan({ text: "None", cls: "duckmage-terrain-option-name" });
clearTile.addEventListener("click", () => {
const map = this.plugin.getMap(this.view.activeMapName);
if (map) {
map.terrainType = undefined;
void this.plugin.saveSettings().then(() => this.render());
}
});
for (const t of terrainPalette) {
const tile = terrainGrid.createDiv({
cls: "duckmage-terrain-option" +
(currentMap?.terrainType === t.name ? " is-selected" : ""),
});
const preview = tile.createDiv({ cls: "duckmage-terrain-preview" });
preview.setCssProps({ "--duckmage-bg": t.color });
if (t.icon) {
createIconEl(
preview,
getIconUrl(this.plugin, t.icon),
t.name,
t.iconColor,
"duckmage-terrain-preview-icon",
);
}
tile.createSpan({ text: t.name, cls: "duckmage-terrain-option-name" });
tile.addEventListener("click", () => {
const map = this.plugin.getMap(this.view.activeMapName);
if (map) {
map.terrainType = t.name;
void this.plugin.saveSettings().then(() => this.render());
}
});
}
}
// ── New map tab ───────────────────────────────────────────────────────────
private renderNewMapTab(el: HTMLElement): void {
// Name
el.createEl("label", { text: "Name", cls: "duckmage-map-field-label" });
const nameRow = el.createDiv({ cls: "duckmage-region-row" });
const nameInput = nameRow.createEl("input", {
type: "text",
placeholder: "map-name",
cls: "duckmage-map-new-name-input",
});
// Size
el.createEl("label", { text: "Size", cls: "duckmage-map-field-label" });
const sizeRow = el.createDiv({ cls: "duckmage-region-row" });
const colsInput = sizeRow.createEl("input", {
type: "number",
value: String(this.plugin.settings.defaultNewMapCols ?? 20),
});
colsInput.setCssProps({ width: "65px" });
sizeRow.createSpan({ text: "cols ×", cls: "duckmage-map-size-sep" });
const rowsInput = sizeRow.createEl("input", {
type: "number",
value: String(this.plugin.settings.defaultNewMapRows ?? 16),
});
rowsInput.setCssProps({ width: "65px" });
sizeRow.createSpan({ text: "rows", cls: "duckmage-map-size-sep" });
// Presets
el.createEl("label", { text: "Presets", cls: "duckmage-map-field-label" });
const presetsRow = el.createDiv({ cls: "duckmage-region-row duckmage-map-presets-row" });
const presets: [string, number, number][] = [
["Small", 5, 5],
["Medium", 15, 15],
["Large", 25, 20],
];
for (const [label, cols, rows] of presets) {
const btn = presetsRow.createEl("button", { text: `${label} (${cols}×${rows})` });
btn.addEventListener("click", () => {
colsInput.value = String(cols);
rowsInput.value = String(rows);
});
}
// Palette
el.createEl("label", { text: "Palette", cls: "duckmage-map-field-label" });
const paletteRow = el.createDiv({ cls: "duckmage-region-row" });
const paletteSelect = paletteRow.createEl("select", { cls: "duckmage-map-new-palette-select" });
for (const pal of this.plugin.settings.terrainPalettes) {
paletteSelect.createEl("option", { value: pal.name, text: pal.name });
}
// Stagger offset
el.createEl("label", { text: "Stagger offset", cls: "duckmage-map-field-label" });
const staggerRow = el.createDiv({ cls: "duckmage-region-row" });
let staggerVal: "odd" | "even" = this.plugin.settings.staggerOffset ?? "odd";
const staggerBtn = staggerRow.createEl("button", { cls: "duckmage-stagger-toggle" });
staggerBtn.setText(staggerVal === "odd" ? "Odd" : "Even");
staggerBtn.toggleClass("is-even", staggerVal === "even");
staggerBtn.addEventListener("click", () => {
staggerVal = staggerVal === "odd" ? "even" : "odd";
staggerBtn.setText(staggerVal === "odd" ? "Odd" : "Even");
staggerBtn.toggleClass("is-even", staggerVal === "even");
});
// Starting coordinates
el.createEl("label", { text: "Starting coordinates", cls: "duckmage-map-field-label" });
el.createEl("p", {
text: "Hex labels and filenames start from these values instead of 0,0.",
cls: "duckmage-map-origin-desc",
});
const originRow = el.createDiv({ cls: "duckmage-region-row" });
originRow.createSpan({ text: "X", cls: "duckmage-map-origin-label" });
const originXInput = originRow.createEl("input", { type: "number", value: "0" });
originXInput.setCssProps({ width: "70px" });
originRow.createSpan({ text: "Y", cls: "duckmage-map-origin-label" });
const originYInput = originRow.createEl("input", { type: "number", value: "0" });
originYInput.setCssProps({ width: "70px" });
// Create button
const createRow = el.createDiv({ cls: "duckmage-region-row duckmage-map-create-row" });
const createBtn = createRow.createEl("button", { text: "Create", cls: "mod-cta" });
const allInputs: (HTMLInputElement | HTMLSelectElement)[] = [
nameInput, colsInput, rowsInput, paletteSelect, originXInput, originYInput,
];
const doCreate = () =>
void this.handleCreate(
nameInput.value.trim(),
Number(colsInput.value) || 20,
Number(rowsInput.value) || 16,
paletteSelect.value,
Number(originXInput.value) || 0,
Number(originYInput.value) || 0,
staggerVal,
createBtn,
allInputs,
);
createBtn.addEventListener("click", doCreate);
nameInput.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter") doCreate();
});
}
// ── Delete ────────────────────────────────────────────────────────────────
private async deleteMap(name: string): Promise<void> {
const hexFolder = normalizeFolder(this.plugin.settings.hexFolder);
const folderPath = hexFolder ? `${hexFolder}/${name}` : name;
const folder = this.app.vault.getAbstractFileByPath(folderPath);
if (folder instanceof TFolder) {
try {
await this.app.fileManager.trashFile(folder);
} catch (e) {
new Notice(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
this.confirmingDelete = null;
this.render();
return;
}
}
this.plugin.settings.maps = this.plugin.settings.maps.filter((m) => m.name !== name);
if (this.plugin.settings.defaultMap === name) {
this.plugin.settings.defaultMap = this.plugin.settings.maps[0]?.name ?? "";
}
if (this.view.activeMapName === name) {
this.view.activeMapName = this.plugin.settings.maps[0]?.name ?? "";
}
const submapRefs = this.app.vault.getMarkdownFiles().filter(
(f) => getSubmapFromFile(this.app, f.path) === name,
);
await Promise.all(submapRefs.map((f) => setSubmapInFile(this.app, f.path, null)));
this.confirmingDelete = null;
await this.plugin.saveSettings();
this.onChanged();
this.render();
new Notice(`Map "${name}" deleted.`);
}
// ── Rename ────────────────────────────────────────────────────────────────
private async renameMap(
raw: string,
btn: HTMLButtonElement,
input: HTMLInputElement,
): Promise<void> {
const newName = slugify(raw);
if (!newName || newName === this.view.activeMapName) return;
if (this.plugin.settings.maps.some((r) => r.name === newName)) {
new Notice(`Map "${newName}" already exists.`);
return;
}
btn.setText("Renaming…");
btn.disabled = true;
input.disabled = true;
const hexFolder = normalizeFolder(this.plugin.settings.hexFolder);
const oldPath = hexFolder
? `${hexFolder}/${this.view.activeMapName}`
: this.view.activeMapName;
const newPath = hexFolder ? `${hexFolder}/${newName}` : newName;
const oldFolder = this.app.vault.getAbstractFileByPath(oldPath);
if (oldFolder instanceof TFolder) {
try {
await this.app.fileManager.renameFile(oldFolder, newPath);
} catch (e) {
new Notice(`Rename failed: ${e instanceof Error ? e.message : String(e)}`);
btn.setText("Rename");
btn.disabled = false;
input.disabled = false;
return;
}
}
const oldName = this.view.activeMapName;
const map = this.plugin.getMap(oldName);
if (map) map.name = newName;
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();
}
// ── Create ────────────────────────────────────────────────────────────────
private async handleCreate(
raw: string,
cols: number,
rows: number,
paletteName: string,
initialX: number,
initialY: number,
staggerOffset: "odd" | "even",
btn: HTMLButtonElement,
inputs: (HTMLInputElement | HTMLSelectElement)[],
): Promise<void> {
btn.setText("Generating…");
btn.disabled = true;
for (const input of inputs) input.disabled = true;
const result = await this.plugin.createNewMap(
raw, cols, rows, paletteName, initialX, initialY, staggerOffset,
(done, total) => btn.setText(`Generating ${done} / ${total}`),
);
if ("error" in result) {
new Notice(result.error);
btn.setText("Create");
btn.disabled = false;
for (const input of inputs) input.disabled = false;
return;
}
this.view.switchMapFromModal(result.name);
this.close();
}
onClose(): void {
this.contentEl.empty();
}
}