diff --git a/.claude/worktrees/agent-ad301d84/src/DuckmageSettingTab.ts b/.claude/worktrees/agent-ad301d84/src/DuckmageSettingTab.ts index cc1dc5d..1b5cfe7 100644 --- a/.claude/worktrees/agent-ad301d84/src/DuckmageSettingTab.ts +++ b/.claude/worktrees/agent-ad301d84/src/DuckmageSettingTab.ts @@ -1,452 +1,543 @@ -import { App, Notice, PluginSettingTab, Setting, TFile } from "obsidian"; -import type DuckmagePlugin from "./DuckmagePlugin"; -import { normalizeFolder } from "./utils"; - -export class DuckmageSettingTab extends PluginSettingTab { - plugin: DuckmagePlugin; - - constructor(app: App, plugin: DuckmagePlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const { containerEl } = this; - containerEl.empty(); - - new Setting(containerEl) - .setName("World notes folder") - .setDesc("Vault-relative path. Scopes the file search when adding links to hexes.") - .addText(text => - text - .setPlaceholder("world") - .setValue(this.plugin.settings.worldFolder) - .onChange(async value => { - this.plugin.settings.worldFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Set up folders") - .setDesc("Populates any blank folder settings below with defaults under the world folder, then creates those folders in your vault. Only blank fields are affected — manually set values are left untouched.") - .addButton(btn => - btn.setButtonText("Generate folders").setCta().onClick(async () => { - const world = normalizeFolder(this.plugin.settings.worldFolder) || "world"; - const defaults: [keyof typeof this.plugin.settings, string][] = [ - ["hexFolder", `${world}/hexes`], - ["townsFolder", `${world}/towns`], - ["dungeonsFolder", `${world}/dungeons`], - ["questsFolder", `${world}/quests`], - ["featuresFolder", `${world}/features`], - ["factionsFolder", `${world}/factions`], - ["tablesFolder", `${world}/tables`], - ]; - for (const [key, path] of defaults) { - if (!this.plugin.settings[key]) { - (this.plugin.settings as unknown as Record)[key] = path; - try { - if (!this.app.vault.getAbstractFileByPath(path)) { - await this.app.vault.createFolder(path); - } - } catch { /* folder already exists */ } - } - } - await this.plugin.saveSettings(); - new Notice("Folders generated."); - this.display(); - }), - ); - - new Setting(containerEl) - .setName("Hex notes folder") - .setDesc("Vault-relative path where hex notes (x_y.md) are stored.") - .addText(text => - text - .setPlaceholder("world/hexes") - .setValue(this.plugin.settings.hexFolder) - .onChange(async value => { - this.plugin.settings.hexFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Towns folder") - .setDesc("Vault-relative folder to populate the Towns dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/towns") - .setValue(this.plugin.settings.townsFolder) - .onChange(async value => { - this.plugin.settings.townsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Dungeons folder") - .setDesc("Vault-relative folder to populate the Dungeons dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/dungeons") - .setValue(this.plugin.settings.dungeonsFolder) - .onChange(async value => { - this.plugin.settings.dungeonsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Quests folder") - .setDesc("Vault-relative folder to populate the Quests dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/quests") - .setValue(this.plugin.settings.questsFolder) - .onChange(async value => { - this.plugin.settings.questsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Features folder") - .setDesc("Vault-relative folder to populate the Features dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/features") - .setValue(this.plugin.settings.featuresFolder) - .onChange(async value => { - this.plugin.settings.featuresFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Factions folder") - .setDesc("Vault-relative folder to populate the Factions dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/factions") - .setValue(this.plugin.settings.factionsFolder) - .onChange(async value => { - this.plugin.settings.factionsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Tables folder") - .setDesc("Vault-relative folder for random table notes. Used by the Encounters Table section and the Random Tables view.") - .addText(text => - text - .setPlaceholder("world/tables") - .setValue(this.plugin.settings.tablesFolder) - .onChange(async value => { - this.plugin.settings.tablesFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Default die for new tables") - .setDesc("Die size used when creating new random table notes (d6, d20, d100, etc.).") - .addDropdown(dropdown => - dropdown - .addOption("4", "d4") - .addOption("6", "d6") - .addOption("8", "d8") - .addOption("10", "d10") - .addOption("12", "d12") - .addOption("20", "d20") - .addOption("100", "d100") - .addOption("200", "d200") - .addOption("500", "d500") - .addOption("1000", "d1000") - .setValue(String(this.plugin.settings.defaultTableDice ?? 100)) - .onChange(async value => { - this.plugin.settings.defaultTableDice = parseInt(value, 10); - await this.plugin.saveSettings(); - }), - ); - - containerEl.createEl("h3", { text: "Generate world data" }); - containerEl.createEl("p", { - cls: "setting-item-description duckmage-generate-warning", - text: "⚠️ Configure all folder settings above before selecting Generate. This will create terrain table notes, add roller links to all table notes, and link each hex note to its terrain's encounters table. Safe to run multiple times — existing notes and links are not overwritten.", - }); - new Setting(containerEl) - .setName("Generate terrain tables & hex links") - .setDesc("Creates missing terrain table notes, adds roller links to all table notes (so they can be opened in the Duckmage Roller from within Obsidian), and links each hex note's terrain encounters table into its Encounters Table section.") - .addButton(btn => - btn.setButtonText("Generate").setCta().onClick(async () => { - btn.setDisabled(true); - btn.setButtonText("Generating…"); - try { - await this.plugin.ensureTerrainTables(); - await this.plugin.ensureAllRollerLinks(); - await this.plugin.backfillTerrainLinks(); - } finally { - btn.setDisabled(false); - btn.setButtonText("Generate"); - } - }), - ); - - new Setting(containerEl) - .setName("Hex editor sections start collapsed") - .setDesc("Choose which sections open collapsed by default in the right-click hex editor.") - .then(setting => { - const addCb = (label: string, get: () => boolean, set: (v: boolean) => void) => { - const lbl = setting.controlEl.createEl("label", { cls: "duckmage-collapse-cb-label" }); - const cb = lbl.createEl("input") as HTMLInputElement; - cb.type = "checkbox"; - cb.checked = get(); - cb.addEventListener("change", async () => { set(cb.checked); await this.plugin.saveSettings(); }); - lbl.appendText(label); - }; - addCb("Terrain", () => this.plugin.settings.hexEditorTerrainCollapsed, v => { this.plugin.settings.hexEditorTerrainCollapsed = v; }); - addCb("World features", () => this.plugin.settings.hexEditorFeaturesCollapsed, v => { this.plugin.settings.hexEditorFeaturesCollapsed = v; }); - addCb("Notes", () => this.plugin.settings.hexEditorNotesCollapsed, v => { this.plugin.settings.hexEditorNotesCollapsed = v; }); - }); - - new Setting(containerEl) - .setName("Template path") - .setDesc("Vault-relative path to a hex note template. Supports {{x}}, {{y}}, {{title}}. Include ## Towns, ## Dungeons, and ## Features headings for the link sections.") - .addText(text => - text - .setPlaceholder("templates/hex.md") - .setValue(this.plugin.settings.templatePath) - .onChange(async value => { - this.plugin.settings.templatePath = (value ?? "").replace(/^\/+|\/+$/g, ""); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Hex orientation") - .setDesc("Pointy-top: points face north/south, flat sides east/west. Flat-top: flat sides face north/south, points east/west.") - .addDropdown(dropdown => - dropdown - .addOption("pointy", "Pointy-top") - .addOption("flat", "Flat-top") - .setValue(this.plugin.settings.hexOrientation ?? "pointy") - .onChange(async value => { - this.plugin.settings.hexOrientation = value as "pointy" | "flat"; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Grid width") - .setDesc("Number of hex columns.") - .addText(text => - text - .setPlaceholder("20") - .setValue(String(this.plugin.settings.gridSize.cols)) - .onChange(async value => { - this.plugin.settings.gridSize.cols = Number(value.trim()) || 20; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Grid height") - .setDesc("Number of hex rows.") - .addText(text => - text - .setPlaceholder("16") - .setValue(String(this.plugin.settings.gridSize.rows)) - .onChange(async value => { - this.plugin.settings.gridSize.rows = Number(value.trim()) || 16; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Hex cell spacing") - .setDesc("Gap between hex cells (0 – 0.5 em).") - .addSlider(slider => - slider - .setLimits(0, 0.5, 0.01) - .setValue(parseFloat(this.plugin.settings.hexGap ?? "0.15") || 0.15) - .setDynamicTooltip() - .onChange(async value => { - this.plugin.settings.hexGap = String(value); - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Custom icons folder") - .setDesc("Vault-relative folder containing additional icon images (PNG, JPG, SVG, etc.) for the terrain palette and hex icon override. These are merged with the built-in icons.") - .addText(text => - text - .setPlaceholder("icons") - .setValue(this.plugin.settings.iconsFolder ?? "") - .onChange(async value => { - this.plugin.settings.iconsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - await this.plugin.loadAvailableIcons(); - }), - ); - - containerEl.createEl("h3", { text: "Roads & rivers" }); - new Setting(containerEl) - .setName("Road color") - .setDesc("Color used to draw road lines between connected road hexes.") - .addColorPicker(color => - color - .setValue(this.plugin.settings.roadColor ?? "#a16207") - .onChange(async value => { - this.plugin.settings.roadColor = value; - await this.plugin.saveSettings(); - }), - ); - new Setting(containerEl) - .setName("River color") - .setDesc("Color used to draw river lines between connected river hexes.") - .addColorPicker(color => - color - .setValue(this.plugin.settings.riverColor ?? "#3b82f6") - .onChange(async value => { - this.plugin.settings.riverColor = value; - await this.plugin.saveSettings(); - }), - ); - - containerEl.createEl("h3", { text: "Terrain palette" }); - containerEl.createEl("p", { - text: "Right-click a hex to set terrain. Each type can have a fill color and an icon from the plugin's icons folder or your custom icons folder.", - cls: "setting-item-description", - }); - - const listEl = containerEl.createDiv({ cls: "duckmage-palette-list" }); - const palette = this.plugin.settings.terrainPalette ?? []; - - let dragSrcIndex = -1; - - for (let i = 0; i < palette.length; i++) { - const entry = palette[i]; - const itemEl = listEl.createDiv({ cls: "duckmage-palette-item" }); - itemEl.draggable = true; - - // Drag handle - itemEl.createSpan({ cls: "duckmage-palette-drag-handle", text: "⠿" }); - - itemEl.addEventListener("dragstart", (e: DragEvent) => { - dragSrcIndex = i; - itemEl.addClass("duckmage-palette-dragging"); - e.dataTransfer?.setDragImage(itemEl, 0, 0); - }); - itemEl.addEventListener("dragend", () => { - itemEl.removeClass("duckmage-palette-dragging"); - listEl.querySelectorAll(".duckmage-palette-drop-target").forEach(el => - el.classList.remove("duckmage-palette-drop-target"), - ); - }); - itemEl.addEventListener("dragover", (e: DragEvent) => { - e.preventDefault(); - listEl.querySelectorAll(".duckmage-palette-drop-target").forEach(el => - el.classList.remove("duckmage-palette-drop-target"), - ); - itemEl.addClass("duckmage-palette-drop-target"); - }); - itemEl.addEventListener("drop", async (e: DragEvent) => { - e.preventDefault(); - const dropIndex = i; - if (dragSrcIndex === -1 || dragSrcIndex === dropIndex) return; - const pal = this.plugin.settings.terrainPalette; - const [moved] = pal.splice(dragSrcIndex, 1); - pal.splice(dropIndex, 0, moved); - dragSrcIndex = -1; - await this.plugin.saveSettings(); - this.display(); - }); - - new Setting(itemEl) - .addText(text => { - let nameBeforeEdit = entry.name; - text - .setPlaceholder("Name") - .setValue(entry.name) - .onChange(async value => { - const trimmed = (value ?? "").trim(); - const isDuplicate = trimmed.length > 0 && this.plugin.settings.terrainPalette.some( - (e) => e !== entry && e.name.toLowerCase() === trimmed.toLowerCase(), - ); - if (isDuplicate) { - text.inputEl.addClass("duckmage-input-error"); - text.inputEl.title = `"${trimmed}" is already used by another terrain.`; - return; - } - text.inputEl.removeClass("duckmage-input-error"); - text.inputEl.title = ""; - entry.name = trimmed || entry.name; - await this.plugin.saveSettings(); - }); - text.inputEl.addEventListener("focus", () => { nameBeforeEdit = entry.name; }); - text.inputEl.addEventListener("blur", async () => { - if (text.inputEl.hasClass("duckmage-input-error")) return; - if (entry.name !== nameBeforeEdit) { - await this.renameTerrainTables(nameBeforeEdit, entry.name); - } - }); - }) - .addColorPicker(color => - color.setValue(entry.color).onChange(async value => { - entry.color = value; - await this.plugin.saveSettings(); - this.plugin.refreshHexMap(); - }), - ) - .addDropdown(dropdown => { - dropdown.addOption("", "— no icon —"); - for (const icon of this.plugin.availableIcons) { - const label = icon.replace(/^bw-/, "").replace(/\.png$/, "").replace(/-/g, " "); - dropdown.addOption(icon, label); - } - dropdown.setValue(entry.icon ?? ""); - dropdown.onChange(async value => { - entry.icon = value || undefined; - await this.plugin.saveSettings(); - this.plugin.refreshHexMap(); - }); - }) - .addExtraButton(btn => - btn.setIcon("trash-2").onClick(async () => { - this.plugin.settings.terrainPalette.splice(i, 1); - await this.plugin.saveSettings(); - this.display(); - }), - ); - } - - new Setting(containerEl).addButton(btn => - btn.setButtonText("Add terrain type").onClick(async () => { - this.plugin.settings.terrainPalette.push({ name: "New", color: "#888888" }); - await this.plugin.saveSettings(); - await this.plugin.ensureTerrainTables(); - this.display(); - }), - ); - } - - private getTerrainTablePath(terrainName: string, tableType: "description" | "encounters"): string { - const folder = normalizeFolder(this.plugin.settings.tablesFolder); - const subfolder = folder ? `${folder}/terrain` : "terrain"; - return `${subfolder}/${tableType}/${terrainName}.md`; - } - - private async renameTerrainTables(oldName: string, newName: string): Promise { - for (const tableType of ["description", "encounters"] as const) { - const oldPath = this.getTerrainTablePath(oldName, tableType); - const newPath = this.getTerrainTablePath(newName, tableType); - const file = this.app.vault.getAbstractFileByPath(oldPath); - if (file instanceof TFile) { - try { await this.app.vault.rename(file, newPath); } catch { /* ignore */ } - } - } - } -} +import { App, Notice, PluginSettingTab, Setting, TFile } from "obsidian"; +import type DuckmagePlugin from "./DuckmagePlugin"; +import { normalizeFolder } from "./utils"; + +export class DuckmageSettingTab extends PluginSettingTab { + plugin: DuckmagePlugin; + + constructor(app: App, plugin: DuckmagePlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + new Setting(containerEl) + .setName("World notes folder") + .setDesc( + "Vault-relative path. Scopes the file search when adding links to hexes.", + ) + .addText((text) => + text + .setPlaceholder("world") + .setValue(this.plugin.settings.worldFolder) + .onChange(async (value) => { + this.plugin.settings.worldFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Set up folders") + .setDesc( + "Populates any blank folder settings below with defaults under the world folder, then creates those folders in your vault. Only blank fields are affected — manually set values are left untouched.", + ) + .addButton((btn) => + btn + .setButtonText("Generate folders") + .setCta() + .onClick(async () => { + const world = + normalizeFolder(this.plugin.settings.worldFolder) || "world"; + const defaults: [keyof typeof this.plugin.settings, string][] = [ + ["hexFolder", `${world}/hexes`], + ["townsFolder", `${world}/towns`], + ["dungeonsFolder", `${world}/dungeons`], + ["questsFolder", `${world}/quests`], + ["featuresFolder", `${world}/features`], + ["factionsFolder", `${world}/factions`], + ["tablesFolder", `${world}/tables`], + ]; + for (const [key, path] of defaults) { + if (!this.plugin.settings[key]) { + (this.plugin.settings as unknown as Record)[ + key + ] = path; + try { + if (!this.app.vault.getAbstractFileByPath(path)) { + await this.app.vault.createFolder(path); + } + } catch { + /* folder already exists */ + } + } + } + await this.plugin.saveSettings(); + new Notice("Folders generated."); + this.display(); + }), + ); + + new Setting(containerEl) + .setName("Hex notes folder") + .setDesc("Vault-relative path where hex notes (x_y.md) are stored.") + .addText((text) => + text + .setPlaceholder("world/hexes") + .setValue(this.plugin.settings.hexFolder) + .onChange(async (value) => { + this.plugin.settings.hexFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Towns folder") + .setDesc( + "Vault-relative folder to populate the Towns dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/towns") + .setValue(this.plugin.settings.townsFolder) + .onChange(async (value) => { + this.plugin.settings.townsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Dungeons folder") + .setDesc( + "Vault-relative folder to populate the Dungeons dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/dungeons") + .setValue(this.plugin.settings.dungeonsFolder) + .onChange(async (value) => { + this.plugin.settings.dungeonsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Quests folder") + .setDesc( + "Vault-relative folder to populate the Quests dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/quests") + .setValue(this.plugin.settings.questsFolder) + .onChange(async (value) => { + this.plugin.settings.questsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Features folder") + .setDesc( + "Vault-relative folder to populate the Features dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/features") + .setValue(this.plugin.settings.featuresFolder) + .onChange(async (value) => { + this.plugin.settings.featuresFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Factions folder") + .setDesc( + "Vault-relative folder to populate the Factions dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/factions") + .setValue(this.plugin.settings.factionsFolder) + .onChange(async (value) => { + this.plugin.settings.factionsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Tables folder") + .setDesc( + "Vault-relative folder for random table notes. Used by the Encounters Table section and the Random Tables view.", + ) + .addText((text) => + text + .setPlaceholder("world/tables") + .setValue(this.plugin.settings.tablesFolder) + .onChange(async (value) => { + this.plugin.settings.tablesFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Default die for new tables") + .setDesc( + "Die size used when creating new random table notes (d6, d20, d100, etc.).", + ) + .addDropdown((dropdown) => + dropdown + .addOption("4", "d4") + .addOption("6", "d6") + .addOption("8", "d8") + .addOption("10", "d10") + .addOption("12", "d12") + .addOption("20", "d20") + .addOption("100", "d100") + .addOption("200", "d200") + .addOption("500", "d500") + .addOption("1000", "d1000") + .setValue(String(this.plugin.settings.defaultTableDice ?? 100)) + .onChange(async (value) => { + this.plugin.settings.defaultTableDice = parseInt(value, 10); + await this.plugin.saveSettings(); + }), + ); + + containerEl.createEl("h3", { text: "Generate world data" }); + containerEl.createEl("p", { + cls: "setting-item-description duckmage-generate-warning", + text: "⚠️ Configure all folder settings above before selecting Generate. This will create terrain table notes, add roller links to all table notes, and link each hex note to its terrain's encounters table. Safe to run multiple times — existing notes and links are not overwritten.", + }); + new Setting(containerEl) + .setName("Generate terrain tables & hex links") + .setDesc( + "Creates missing terrain table notes, adds roller links to all table notes (so they can be opened in the Duckmage Roller from within Obsidian), and links each hex note's terrain encounters table into its Encounters Table section.", + ) + .addButton((btn) => + btn + .setButtonText("Generate") + .setCta() + .onClick(async () => { + btn.setDisabled(true); + btn.setButtonText("Generating…"); + try { + await this.plugin.ensureTerrainTables(); + await this.plugin.ensureAllRollerLinks(); + await this.plugin.backfillTerrainLinks(); + } finally { + btn.setDisabled(false); + btn.setButtonText("Generate"); + } + }), + ); + + new Setting(containerEl) + .setName("Hex editor sections start collapsed") + .setDesc( + "Choose which sections open collapsed by default in the right-click hex editor.", + ) + .then((setting) => { + const addCb = ( + label: string, + get: () => boolean, + set: (v: boolean) => void, + ) => { + const lbl = setting.controlEl.createEl("label", { + cls: "duckmage-collapse-cb-label", + }); + const cb = lbl.createEl("input") as HTMLInputElement; + cb.type = "checkbox"; + cb.checked = get(); + cb.addEventListener("change", async () => { + set(cb.checked); + await this.plugin.saveSettings(); + }); + lbl.appendText(label); + }; + addCb( + "Terrain", + () => this.plugin.settings.hexEditorTerrainCollapsed, + (v) => { + this.plugin.settings.hexEditorTerrainCollapsed = v; + }, + ); + addCb( + "Features", + () => this.plugin.settings.hexEditorFeaturesCollapsed, + (v) => { + this.plugin.settings.hexEditorFeaturesCollapsed = v; + }, + ); + addCb( + "Notes", + () => this.plugin.settings.hexEditorNotesCollapsed, + (v) => { + this.plugin.settings.hexEditorNotesCollapsed = v; + }, + ); + }); + + new Setting(containerEl) + .setName("Template path") + .setDesc( + "Vault-relative path to a hex note template. Supports {{x}}, {{y}}, {{title}}. Include ## Towns, ## Dungeons, and ## Features headings for the link sections.", + ) + .addText((text) => + text + .setPlaceholder("templates/hex.md") + .setValue(this.plugin.settings.templatePath) + .onChange(async (value) => { + this.plugin.settings.templatePath = (value ?? "").replace( + /^\/+|\/+$/g, + "", + ); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Hex orientation") + .setDesc( + "Pointy-top: points face north/south, flat sides east/west. Flat-top: flat sides face north/south, points east/west.", + ) + .addDropdown((dropdown) => + dropdown + .addOption("pointy", "Pointy-top") + .addOption("flat", "Flat-top") + .setValue(this.plugin.settings.hexOrientation ?? "pointy") + .onChange(async (value) => { + this.plugin.settings.hexOrientation = value as "pointy" | "flat"; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Grid width") + .setDesc("Number of hex columns.") + .addText((text) => + text + .setPlaceholder("20") + .setValue(String(this.plugin.settings.gridSize.cols)) + .onChange(async (value) => { + this.plugin.settings.gridSize.cols = Number(value.trim()) || 20; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Grid height") + .setDesc("Number of hex rows.") + .addText((text) => + text + .setPlaceholder("16") + .setValue(String(this.plugin.settings.gridSize.rows)) + .onChange(async (value) => { + this.plugin.settings.gridSize.rows = Number(value.trim()) || 16; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Hex cell spacing") + .setDesc("Gap between hex cells (0 – 0.5 em).") + .addSlider((slider) => + slider + .setLimits(0, 0.5, 0.01) + .setValue(parseFloat(this.plugin.settings.hexGap ?? "0.15") || 0.15) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.hexGap = String(value); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Custom icons folder") + .setDesc( + "Vault-relative folder containing additional icon images (PNG, JPG, SVG, etc.) for the terrain palette and hex icon override. These are merged with the built-in icons.", + ) + .addText((text) => + text + .setPlaceholder("icons") + .setValue(this.plugin.settings.iconsFolder ?? "") + .onChange(async (value) => { + this.plugin.settings.iconsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + await this.plugin.loadAvailableIcons(); + }), + ); + + containerEl.createEl("h3", { text: "Roads & rivers" }); + new Setting(containerEl) + .setName("Road color") + .setDesc("Color used to draw road lines between connected road hexes.") + .addColorPicker((color) => + color + .setValue(this.plugin.settings.roadColor ?? "#a16207") + .onChange(async (value) => { + this.plugin.settings.roadColor = value; + await this.plugin.saveSettings(); + }), + ); + new Setting(containerEl) + .setName("River color") + .setDesc("Color used to draw river lines between connected river hexes.") + .addColorPicker((color) => + color + .setValue(this.plugin.settings.riverColor ?? "#3b82f6") + .onChange(async (value) => { + this.plugin.settings.riverColor = value; + await this.plugin.saveSettings(); + }), + ); + + containerEl.createEl("h3", { text: "Terrain palette" }); + containerEl.createEl("p", { + text: "Right-click a hex to set terrain. Each type can have a fill color and an icon from the plugin's icons folder or your custom icons folder.", + cls: "setting-item-description", + }); + + const listEl = containerEl.createDiv({ cls: "duckmage-palette-list" }); + const palette = this.plugin.settings.terrainPalette ?? []; + + let dragSrcIndex = -1; + + for (let i = 0; i < palette.length; i++) { + const entry = palette[i]; + const itemEl = listEl.createDiv({ cls: "duckmage-palette-item" }); + itemEl.draggable = true; + + // Drag handle + itemEl.createSpan({ cls: "duckmage-palette-drag-handle", text: "⠿" }); + + itemEl.addEventListener("dragstart", (e: DragEvent) => { + dragSrcIndex = i; + itemEl.addClass("duckmage-palette-dragging"); + e.dataTransfer?.setDragImage(itemEl, 0, 0); + }); + itemEl.addEventListener("dragend", () => { + itemEl.removeClass("duckmage-palette-dragging"); + listEl + .querySelectorAll(".duckmage-palette-drop-target") + .forEach((el) => el.classList.remove("duckmage-palette-drop-target")); + }); + itemEl.addEventListener("dragover", (e: DragEvent) => { + e.preventDefault(); + listEl + .querySelectorAll(".duckmage-palette-drop-target") + .forEach((el) => el.classList.remove("duckmage-palette-drop-target")); + itemEl.addClass("duckmage-palette-drop-target"); + }); + itemEl.addEventListener("drop", async (e: DragEvent) => { + e.preventDefault(); + const dropIndex = i; + if (dragSrcIndex === -1 || dragSrcIndex === dropIndex) return; + const pal = this.plugin.settings.terrainPalette; + const [moved] = pal.splice(dragSrcIndex, 1); + pal.splice(dropIndex, 0, moved); + dragSrcIndex = -1; + await this.plugin.saveSettings(); + this.display(); + }); + + new Setting(itemEl) + .addText((text) => { + let nameBeforeEdit = entry.name; + text + .setPlaceholder("Name") + .setValue(entry.name) + .onChange(async (value) => { + const trimmed = (value ?? "").trim(); + const isDuplicate = + trimmed.length > 0 && + this.plugin.settings.terrainPalette.some( + (e) => + e !== entry && + e.name.toLowerCase() === trimmed.toLowerCase(), + ); + if (isDuplicate) { + text.inputEl.addClass("duckmage-input-error"); + text.inputEl.title = `"${trimmed}" is already used by another terrain.`; + return; + } + text.inputEl.removeClass("duckmage-input-error"); + text.inputEl.title = ""; + entry.name = trimmed || entry.name; + await this.plugin.saveSettings(); + }); + text.inputEl.addEventListener("focus", () => { + nameBeforeEdit = entry.name; + }); + text.inputEl.addEventListener("blur", async () => { + if (text.inputEl.hasClass("duckmage-input-error")) return; + if (entry.name !== nameBeforeEdit) { + await this.renameTerrainTables(nameBeforeEdit, entry.name); + } + }); + }) + .addColorPicker((color) => + color.setValue(entry.color).onChange(async (value) => { + entry.color = value; + await this.plugin.saveSettings(); + this.plugin.refreshHexMap(); + }), + ) + .addDropdown((dropdown) => { + dropdown.addOption("", "— no icon —"); + for (const icon of this.plugin.availableIcons) { + const label = icon + .replace(/^bw-/, "") + .replace(/\.png$/, "") + .replace(/-/g, " "); + dropdown.addOption(icon, label); + } + dropdown.setValue(entry.icon ?? ""); + dropdown.onChange(async (value) => { + entry.icon = value || undefined; + await this.plugin.saveSettings(); + this.plugin.refreshHexMap(); + }); + }) + .addExtraButton((btn) => + btn.setIcon("trash-2").onClick(async () => { + this.plugin.settings.terrainPalette.splice(i, 1); + await this.plugin.saveSettings(); + this.display(); + }), + ); + } + + new Setting(containerEl).addButton((btn) => + btn.setButtonText("Add terrain type").onClick(async () => { + this.plugin.settings.terrainPalette.push({ + name: "New", + color: "#888888", + }); + await this.plugin.saveSettings(); + await this.plugin.ensureTerrainTables(); + this.display(); + }), + ); + } + + private getTerrainTablePath( + terrainName: string, + tableType: "description" | "encounters", + ): string { + const folder = normalizeFolder(this.plugin.settings.tablesFolder); + const subfolder = folder ? `${folder}/terrain` : "terrain"; + return `${subfolder}/${tableType}/${terrainName}.md`; + } + + private async renameTerrainTables( + oldName: string, + newName: string, + ): Promise { + for (const tableType of ["description", "encounters"] as const) { + const oldPath = this.getTerrainTablePath(oldName, tableType); + const newPath = this.getTerrainTablePath(newName, tableType); + const file = this.app.vault.getAbstractFileByPath(oldPath); + if (file instanceof TFile) { + try { + await this.app.vault.rename(file, newPath); + } catch { + /* ignore */ + } + } + } + } +} diff --git a/.claude/worktrees/agent-ad301d84/src/HexEditorModal.ts b/.claude/worktrees/agent-ad301d84/src/HexEditorModal.ts index 0cb2769..7970e8d 100644 --- a/.claude/worktrees/agent-ad301d84/src/HexEditorModal.ts +++ b/.claude/worktrees/agent-ad301d84/src/HexEditorModal.ts @@ -1,811 +1,845 @@ -import { App, Modal, Notice, TFile } from "obsidian"; -import type DuckmagePlugin from "./DuckmagePlugin"; -import { getIconUrl, normalizeFolder, makeTableTemplate } from "./utils"; -import { - getTerrainFromFile, - setTerrainInFile, - setIconOverrideInFile, -} from "./frontmatter"; -import { - addLinkToSection, - removeLinkFromSection, - getLinksInSection, - getAllSectionData, - setSectionContent, - addBacklinkToFile, -} from "./sections"; -import { FileLinkSuggestModal } from "./FileLinkSuggestModal"; -import { TEXT_SECTIONS } from "./types"; -import type { LinkSection } from "./types"; -import { RandomTableModal } from "./RandomTableModal"; -import { VIEW_TYPE_HEX_MAP, VIEW_TYPE_RANDOM_TABLES } from "./constants"; - -export class HexEditorModal extends Modal { - private hexExists = false; - private allText = new Map(); - private allLinks = new Map(); - private directTerrain: string | null = null; - private directIcon: string | null = null; - - constructor( - app: App, - private plugin: DuckmagePlugin, - private x: number, - private y: number, - private onChanged: ( - terrainOverrides?: Map, - iconOverrides?: Map, - ) => void, - ) { - super(app); - } - - async loadData(): Promise { - // Reset all fields so stale data from a previous hex never bleeds through - this.allText = new Map(); - this.allLinks = new Map(); - this.directTerrain = null; - this.directIcon = null; - - const path = this.plugin.hexPath(this.x, this.y); - this.hexExists = - this.app.vault.getAbstractFileByPath(path) instanceof TFile; - if (!this.hexExists) return; - - ({ text: this.allText, links: this.allLinks } = await getAllSectionData( - this.app, - path, - )); - const rawContent = await this.app.vault.read( - this.app.vault.getAbstractFileByPath(path) as TFile, - ); - const fmMatch = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (fmMatch) { - const tm = fmMatch[1].match(/^\s*terrain:\s*(.+)$/m); - if (tm) this.directTerrain = tm[1].trim(); - const im = fmMatch[1].match(/^\s*icon:\s*(.+)$/m); - if (im) this.directIcon = im[1].trim(); - } - } - - onOpen() { - const { contentEl } = this; - contentEl.empty(); - contentEl.addClass("duckmage-hex-editor"); - - const { hexExists, allText, allLinks, directTerrain, directIcon } = this; - const path = this.plugin.hexPath(this.x, this.y); - const titleRow = contentEl.createDiv({ cls: "duckmage-editor-title-row" }); - const titleLeft = titleRow.createDiv({ cls: "duckmage-editor-title-left" }); - titleLeft.createEl("h2", { text: `Hex ${this.x}, ${this.y}` }); - const centerBtn = titleLeft.createEl("button", { - text: "⌖", - cls: "duckmage-editor-center-btn", - title: "Center map on this hex", - }); - centerBtn.addEventListener("click", () => { - const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_HEX_MAP); - if (leaves.length > 0) (leaves[0].view as any).centerOnHex?.(this.x, this.y); - }); - - if (hexExists) { - const file = this.app.vault.getAbstractFileByPath(path) as TFile; - const openLink = titleLeft.createEl("a", { - text: "Open note", - cls: "duckmage-editor-open-link", - }); - openLink.addEventListener("click", () => { - this.app.workspace.getLeaf("tab").openFile(file); - this.close(); - }); - } - this.renderNeighborWidget(titleRow, this.x, this.y); - - const s = this.plugin.settings; - - const { body: terrainBody, header: terrainHeader } = this.makeCollapsible( - contentEl, - "Terrain", - s.hexEditorTerrainCollapsed ?? false, - ); - // Show current terrain as a small swatch + name in the header - const paletteEntry = directTerrain - ? (this.plugin.settings.terrainPalette ?? []).find(p => p.name === directTerrain) - : undefined; - const iconToShow = directIcon ?? paletteEntry?.icon; - if (paletteEntry || iconToShow) { - const preview = terrainHeader.createSpan({ cls: "duckmage-terrain-header-preview" }); - const swatch = preview.createSpan({ cls: "duckmage-terrain-header-swatch" }); - if (paletteEntry) swatch.style.backgroundColor = paletteEntry.color; - if (iconToShow) { - const img = swatch.createEl("img"); - img.src = getIconUrl(this.plugin, iconToShow); - } - if (paletteEntry) { - preview.createSpan({ text: paletteEntry.name, cls: "duckmage-terrain-header-name" }); - } - } - this.renderTerrainSection(terrainBody, path, directTerrain, directIcon); - - contentEl.createEl("hr", { cls: "duckmage-editor-divider" }); - - const { body: notesBody } = this.makeCollapsible( - contentEl, - "Notes", - s.hexEditorNotesCollapsed ?? false, - ); - for (const { key, label } of TEXT_SECTIONS) { - this.renderTextSection( - notesBody, - path, - key, - label, - allText.get(key) ?? "", - ); - } - - contentEl.createEl("hr", { cls: "duckmage-editor-divider" }); - - const { body: featuresBody } = this.makeCollapsible( - contentEl, - "World features", - s.hexEditorFeaturesCollapsed ?? false, - ); - this.renderDropdownSection( - featuresBody, - path, - "Encounters Table", - hexExists, - s.tablesFolder, - allLinks.get("encounters table") ?? [], - ); - this.renderDropdownSection( - featuresBody, - path, - "Towns", - hexExists, - s.townsFolder, - allLinks.get("towns") ?? [], - ); - this.renderDropdownSection( - featuresBody, - path, - "Dungeons", - hexExists, - s.dungeonsFolder, - allLinks.get("dungeons") ?? [], - ); - this.renderDropdownSection( - featuresBody, - path, - "Quests", - hexExists, - s.questsFolder, - allLinks.get("quests") ?? [], - ); - this.renderDropdownSection( - featuresBody, - path, - "Factions", - hexExists, - s.factionsFolder, - allLinks.get("factions") ?? [], - ); - this.renderDropdownSection( - featuresBody, - path, - "Features", - hexExists, - s.featuresFolder, - allLinks.get("features") ?? [], - ); - - this.makeDraggable(); - } - - onClose() { - this.contentEl.empty(); - } - - private dragInitialized = false; - - private makeDraggable(): void { - if (this.dragInitialized) return; - this.dragInitialized = true; - - const modal = this.modalEl; - modal.addClass("duckmage-editor-modal-drag"); - modal.style.position = "absolute"; - modal.style.left = "50%"; - modal.style.top = "50%"; - modal.style.transform = "translate(-50%, -50%)"; - modal.style.margin = "0"; - - modal.addEventListener("mousedown", (e: MouseEvent) => { - // Only drag from the native modal header — the strip above .modal-content. - // This area never scrolls so the drag zone is always accessible. - const modalContent = modal.querySelector(".modal-content"); - if (modalContent && e.clientY >= modalContent.getBoundingClientRect().top) return; - if ((e.target as HTMLElement).closest("button, a")) return; - - e.preventDefault(); - const r = modal.getBoundingClientRect(); - modal.style.transform = "none"; - modal.style.left = `${r.left}px`; - modal.style.top = `${r.top}px`; - const sx = e.clientX, sy = e.clientY; - const ox = r.left, oy = r.top; - const onMove = (ev: MouseEvent) => { - modal.style.left = `${ox + ev.clientX - sx}px`; - modal.style.top = `${oy + ev.clientY - sy}px`; - }; - const onUp = () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - }); - } - - private isOnMap(nx: number, ny: number): boolean { - const { gridOffset, gridSize } = this.plugin.settings; - return ( - nx >= gridOffset.x && - nx < gridOffset.x + gridSize.cols && - ny >= gridOffset.y && - ny < gridOffset.y + gridSize.rows - ); - } - - private renderNeighborWidget(container: HTMLElement, x: number, y: number): void { - const isFlat = this.plugin.settings.hexOrientation === "flat"; - const widget = container.createDiv({ cls: "duckmage-neighbor-widget" }); - - type NeighborDef = { l: number; t: number; nx: number; ny: number }; - const defs: NeighborDef[] = isFlat - ? [ - { l: 22, t: 2, nx: x, ny: y - 1 }, // N - { l: 42, t: 13, nx: x + 1, ny: x % 2 === 0 ? y - 1 : y }, // NE - { l: 42, t: 32, nx: x + 1, ny: x % 2 === 0 ? y : y + 1 }, // SE - { l: 22, t: 40, nx: x, ny: y + 1 }, // S - { l: 2, t: 32, nx: x - 1, ny: x % 2 === 0 ? y : y + 1 }, // SW - { l: 2, t: 13, nx: x - 1, ny: x % 2 === 0 ? y - 1 : y }, // NW - ] - : [ - { l: 10, t: 1, nx: y % 2 === 0 ? x - 1 : x, ny: y - 1 }, // NW - { l: 34, t: 1, nx: y % 2 === 0 ? x : x + 1, ny: y - 1 }, // NE - { l: 0, t: 18, nx: x - 1, ny: y }, // W - { l: 44, t: 18, nx: x + 1, ny: y }, // E - { l: 10, t: 35, nx: y % 2 === 0 ? x - 1 : x, ny: y + 1 }, // SW - { l: 34, t: 35, nx: y % 2 === 0 ? x : x + 1, ny: y + 1 }, // SE - ]; - - for (const { l, t, nx, ny } of defs) { - const onMap = this.isOnMap(nx, ny); - const tile = widget.createDiv({ - cls: `duckmage-neighbor-tile${onMap ? "" : " duckmage-neighbor-tile-offmap"}`, - }); - tile.style.left = `${l}px`; - tile.style.top = `${t}px`; - - if (onMap) { - tile.title = `Hex ${nx}, ${ny}`; - const nPath = this.plugin.hexPath(nx, ny); - const terrain = getTerrainFromFile(this.app, nPath); - const entry = terrain - ? this.plugin.settings.terrainPalette.find(p => p.name === terrain) - : undefined; - if (entry) tile.style.backgroundColor = entry.color; - tile.addEventListener("click", () => { - this.x = nx; - this.y = ny; - this.loadData().then(() => this.onOpen()); - }); - } else { - tile.title = "Off map"; - } - } - } - - private makeCollapsible( - container: HTMLElement, - label: string, - startCollapsed: boolean, - ): { body: HTMLElement; header: HTMLElement } { - const wrapper = container.createDiv({ cls: "duckmage-editor-collapsible" }); - const header = wrapper.createDiv({ - cls: "duckmage-editor-collapsible-header", - }); - const arrow = header.createSpan({ - cls: "duckmage-editor-collapsible-arrow", - text: startCollapsed ? "▶" : "▼", - }); - header.createEl("h3", { - text: label, - cls: "duckmage-editor-collapsible-title", - }); - const body = wrapper.createDiv({ cls: "duckmage-editor-collapsible-body" }); - if (startCollapsed) body.style.display = "none"; - header.addEventListener("click", () => { - const collapsed = body.style.display === "none"; - body.style.display = collapsed ? "" : "none"; - arrow.textContent = collapsed ? "▼" : "▶"; - }); - return { body, header }; - } - - private renderTerrainSection( - container: HTMLElement, - path: string, - currentTerrain: string | null, - currentIcon: string | null, - ): void { - const palette = this.plugin.settings.terrainPalette; - - const section = container.createDiv({ cls: "duckmage-editor-section" }); - - const grid = section.createDiv({ cls: "duckmage-terrain-picker" }); - - // Clear terrain — always first in the grid - if (currentTerrain) { - const clearBtn = grid.createDiv({ cls: "duckmage-terrain-option duckmage-terrain-option-clear" }); - clearBtn.createDiv({ cls: "duckmage-terrain-preview duckmage-terrain-preview-clear" }); - clearBtn.createSpan({ text: "Clear", cls: "duckmage-terrain-option-name" }); - clearBtn.addEventListener("click", async () => { - await setTerrainInFile(this.app, path, null); - void this.plugin.syncHexEncounterTableLink(path, null); - this.onChanged(new Map([[path, null]])); - this.close(); - }); - } - - for (const entry of palette) { - const btn = grid.createDiv({ - cls: `duckmage-terrain-option${entry.name === currentTerrain ? " is-selected" : ""}`, - }); - - const preview = btn.createDiv({ cls: "duckmage-terrain-preview" }); - preview.style.backgroundColor = entry.color; - - if (entry.icon) { - const img = preview.createEl("img", { - cls: "duckmage-terrain-preview-icon", - }); - img.src = getIconUrl(this.plugin, entry.icon); - img.alt = entry.name; - } - - btn.createSpan({ text: entry.name, cls: "duckmage-terrain-option-name" }); - - btn.addEventListener("click", async () => { - await this.ensureHexNote(); - await setTerrainInFile(this.app, path, entry.name); - void this.plugin.syncHexEncounterTableLink(path, entry.name); - this.onChanged(new Map([[path, entry.name]])); - this.close(); - }); - } - - // Icon override row - const iconRow = section.createDiv({ cls: "duckmage-icon-override-row" }); - iconRow.createSpan({ - text: "Icon override", - cls: "duckmage-icon-override-label", - }); - const iconSelect = iconRow.createEl("select", { - cls: "duckmage-icon-override-select", - }); - iconSelect.createEl("option", { - value: "", - text: "— use terrain default —", - }); - for (const icon of this.plugin.availableIcons) { - const label = icon - .replace(/^bw-/, "") - .replace(/\.png$/, "") - .replace(/-/g, " "); - iconSelect.createEl("option", { value: icon, text: label }); - } - // Use directly-read icon value (not the stale metadata cache) - iconSelect.value = currentIcon ?? ""; - // Keep terrain in the overrides map so renderGrid doesn't lose it during - // the brief window when Obsidian clears the metadata cache on file modify. - const terrainOverrides: Map | undefined = - currentTerrain ? new Map([[path, currentTerrain]]) : undefined; - - iconSelect.addEventListener("change", async () => { - await this.ensureHexNote(); - await setIconOverrideInFile(this.app, path, iconSelect.value || null); - this.onChanged(terrainOverrides, new Map([[path, iconSelect.value || null]])); - }); - const clearIconBtn = iconRow.createEl("button", { - text: "Clear", - cls: "duckmage-clear-btn", - title: "Remove icon override", - }); - clearIconBtn.style.visibility = currentIcon ? "visible" : "hidden"; - clearIconBtn.addEventListener("click", async () => { - await this.ensureHexNote(); - await setIconOverrideInFile(this.app, path, null); - this.onChanged(terrainOverrides, new Map([[path, null]])); - iconSelect.value = ""; - clearIconBtn.style.visibility = "hidden"; - }); - // Show/hide clear button as icon selection changes - iconSelect.addEventListener("change", () => { - clearIconBtn.style.visibility = iconSelect.value ? "visible" : "hidden"; - }); - } - - private getFilesForDropdown(folder: string, filterType?: "roll-filter" | "encounter-filter"): TFile[] { - const normalized = normalizeFolder(folder); - const all = this.app.vault.getMarkdownFiles(); - const scoped = normalized - ? all.filter((f) => f.path.startsWith(normalized + "/")) - : all; - let filtered = scoped.filter((f) => !f.basename.startsWith("_")); - if (filterType) { - const excluded = filterType === "encounter-filter" - ? this.plugin.settings.encounterTableExcludedFolders - : this.plugin.settings.rollTableExcludedFolders; - filtered = this.plugin.filterTableFiles(filtered, filterType, excluded); - } - return filtered.sort((a, b) => a.basename.localeCompare(b.basename)); - } - - private renderDropdownSection( - container: HTMLElement, - path: string, - section: LinkSection, - hexExists: boolean, - sourceFolder: string, - initialLinks: string[], - ): void { - const sectionEl = container.createDiv({ - cls: "duckmage-editor-link-section", - }); - const header = sectionEl.createDiv({ cls: "duckmage-link-section-header" }); - header.createEl("h4", { text: section }); - - const select = header.createEl("select", { cls: "duckmage-link-select" }); - select.createEl("option", { value: "", text: "— add —" }); - const filterType = section === "Encounters Table" ? "encounter-filter" as const : undefined; - for (const file of this.getFilesForDropdown(sourceFolder, filterType)) { - select.createEl("option", { value: file.path, text: file.basename }); - } - - const linksEl = sectionEl.createDiv({ cls: "duckmage-link-list" }); - - // For Encounters Table: clicking a linked item opens the table view for rolling - const onItemClick = - section === "Encounters Table" - ? async (_link: string, file: TFile) => { - const leaves = this.app.workspace.getLeavesOfType( - VIEW_TYPE_RANDOM_TABLES, - ); - if (leaves.length > 0) { - this.app.workspace.revealLeaf(leaves[0]); - (leaves[0].view as any).openTable?.(file.path); - } else { - const leaf = this.app.workspace.getLeaf("tab"); - await leaf.setViewState({ type: VIEW_TYPE_RANDOM_TABLES }); - (leaf.view as any).openTable?.(file.path); - } - this.close(); - } - : undefined; - - const onRemove = async (link: string) => { - await removeLinkFromSection(this.app, path, section, link); - this.onChanged(); - await refresh(); - }; - - const onRollClick = - section === "Encounters Table" - ? (file: TFile) => - new RandomTableModal( - this.app, - this.plugin, - undefined, - file.path, - ).open() - : undefined; - - const refresh = async () => { - linksEl.empty(); - this.renderLinkList( - linksEl, - await getLinksInSection(this.app, path, section), - path, - onRemove, - onItemClick, - onRollClick, - ); - }; - - if (hexExists) { - this.renderLinkList( - linksEl, - initialLinks, - path, - onRemove, - onItemClick, - onRollClick, - ); - } else { - linksEl.createSpan({ text: "None", cls: "duckmage-link-empty" }); - } - - select.addEventListener("change", async () => { - const selectedPath = select.value; - select.value = ""; - if (!selectedPath) return; - const file = this.app.vault.getAbstractFileByPath(selectedPath); - if (!(file instanceof TFile)) return; - const hexFile = await this.ensureHexNote(); - if (!hexFile) { - new Notice("Could not create hex note."); - return; - } - const linkText = `[[${this.app.metadataCache.fileToLinktext(file, path)}]]`; - await addLinkToSection(this.app, path, section, linkText); - await addBacklinkToFile(this.app, file.path, path); - this.onChanged(); - await refresh(); - }); - - // Create-new row - const createRow = sectionEl.createDiv({ - cls: "duckmage-editor-create-row", - }); - const createInput = createRow.createEl("input", { - type: "text", - cls: "duckmage-editor-create-input", - }); - createInput.placeholder = `New ${section.slice(0, -1).toLowerCase()}…`; - const createBtn = createRow.createEl("button", { - text: "Create", - cls: "duckmage-editor-create-btn", - }); - - const createAndLink = async () => { - const name = createInput.value.trim(); - if (!name) return; - const folder = normalizeFolder(sourceFolder); - const newPath = folder ? `${folder}/${name}.md` : `${name}.md`; - let file = this.app.vault.getAbstractFileByPath(newPath); - if (!(file instanceof TFile)) { - try { - if (folder && !this.app.vault.getAbstractFileByPath(folder)) { - await this.app.vault.createFolder(folder); - } - file = await this.app.vault.create( - newPath, - section === "Encounters Table" - ? makeTableTemplate(this.plugin.settings.defaultTableDice) - : "", - ); - } catch (err) { - new Notice(`Could not create ${newPath}: ${err}`); - return; - } - } - const hexFile = await this.ensureHexNote(); - if (!hexFile) { - new Notice("Could not create hex note."); - return; - } - const linkText = `[[${this.app.metadataCache.fileToLinktext(file as TFile, path)}]]`; - await addLinkToSection(this.app, path, section, linkText); - await addBacklinkToFile(this.app, (file as TFile).path, path); - this.onChanged(); - createInput.value = ""; - await refresh(); - }; - - createBtn.addEventListener("click", createAndLink); - createInput.addEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Enter") createAndLink(); - }); - } - - private renderLinkSection( - container: HTMLElement, - path: string, - section: LinkSection, - hexExists: boolean, - initialLinks: string[], - ): void { - const sectionEl = container.createDiv({ - cls: "duckmage-editor-link-section", - }); - const header = sectionEl.createDiv({ cls: "duckmage-link-section-header" }); - header.createEl("h4", { text: section }); - const addBtn = header.createEl("button", { - text: "+ Add", - cls: "duckmage-add-btn", - }); - const linksEl = sectionEl.createDiv({ cls: "duckmage-link-list" }); - - if (hexExists) { - this.renderLinkList(linksEl, initialLinks, path); - } else { - linksEl.createSpan({ text: "—", cls: "duckmage-link-empty" }); - } - - addBtn.addEventListener("click", () => { - new FileLinkSuggestModal(this.app, this.plugin, async (file) => { - const hexFile = await this.ensureHexNote(); - if (!hexFile) { - new Notice("Could not create hex note."); - return; - } - const linkText = `[[${this.app.metadataCache.fileToLinktext(file, path)}]]`; - await addLinkToSection(this.app, path, section, linkText); - this.onChanged(); - const links = await getLinksInSection(this.app, path, section); - linksEl.empty(); - this.renderLinkList(linksEl, links, path); - }).open(); - }); - } - - private renderLinkList( - container: HTMLElement, - links: string[], - sourcePath: string, - onRemove?: (link: string) => void, - onItemClick?: (link: string, file: TFile) => void, - onRollClick?: (file: TFile) => void, - ): void { - if (links.length === 0) { - container.createSpan({ text: "None", cls: "duckmage-link-empty" }); - } else { - for (const link of links) { - const item = container.createDiv({ cls: "duckmage-link-item" }); - const label = item.createSpan({ - text: `[[${link}]]`, - cls: "duckmage-link-item-label", - }); - const file = this.app.metadataCache.getFirstLinkpathDest( - link, - sourcePath, - ); - if (file instanceof TFile) { - label.addClass("duckmage-link-item-clickable"); - label.addEventListener("click", () => { - if (onItemClick) { - void onItemClick(link, file); - } else { - this.app.workspace.getLeaf("tab").openFile(file); - this.close(); - } - }); - if (onRollClick) { - const rollBtn = item.createEl("button", { - text: "🎲", - cls: "duckmage-link-roll-btn", - }); - rollBtn.title = "Roll on this table"; - rollBtn.addEventListener("click", () => onRollClick(file)); - } - } - if (onRemove) { - const removeBtn = item.createEl("button", { - text: "×", - cls: "duckmage-link-remove-btn", - }); - removeBtn.addEventListener("click", () => onRemove(link)); - } - } - } - } - - private renderTextSection( - container: HTMLElement, - path: string, - section: string, - label: string, - initialContent: string, - ): void { - const sectionEl = container.createDiv({ - cls: "duckmage-editor-text-section", - }); - const labelRow = sectionEl.createDiv({ - cls: "duckmage-text-section-label-row", - }); - labelRow.createEl("label", { - text: label, - cls: "duckmage-text-section-label", - }); - - // Button group on the right — keeps 📖 and 🎲 clustered together - const btnGroup = labelRow.createDiv({ - cls: "duckmage-text-section-btn-group", - }); - - // 📖 button: terrain description table (description section) or generic section table - const tablesFolder = this.plugin.settings.tablesFolder - ? this.plugin.settings.tablesFolder.replace(/^\/+|\/+$/g, "") - : ""; - let previewTablePath: string | null = null; - let previewTitle = ""; - - if (section === "description") { - const terrain = getTerrainFromFile(this.app, path); - if (terrain) { - const p = tablesFolder - ? `${tablesFolder}/terrain/description/${terrain}.md` - : `terrain/description/${terrain}.md`; - if (this.app.vault.getAbstractFileByPath(p)) { - previewTablePath = p; - previewTitle = `Roll on ${terrain} description table`; - } - } - } else if ( - section === "landmark" || - section === "hidden" || - section === "secret" - ) { - const p = tablesFolder - ? `${tablesFolder}/${section}.md` - : `${section}.md`; - if (this.app.vault.getAbstractFileByPath(p)) { - previewTablePath = p; - previewTitle = `Roll on ${section} table`; - } - } - - if (previewTablePath) { - const previewBtn = btnGroup.createEl("button", { - text: "📖", - cls: "duckmage-section-desc-table-btn", - }); - previewBtn.title = previewTitle; - const capturedPath = previewTablePath; - previewBtn.addEventListener("click", () => { - new RandomTableModal( - this.app, - this.plugin, - undefined, - capturedPath, - ).open(); - }); - } - - const rollBtn = btnGroup.createEl("button", { - text: "🎲", - cls: "duckmage-section-roll-btn", - }); - rollBtn.title = "Roll on a table and append result"; - const textarea = sectionEl.createEl("textarea", { - cls: "duckmage-text-section-textarea", - }); - rollBtn.addEventListener("click", () => { - new RandomTableModal(this.app, this.plugin, (result) => { - if (textarea.value && !textarea.value.endsWith("\n")) - textarea.value += "\n"; - textarea.value += result; - }).open(); - }); - textarea.rows = 3; - textarea.placeholder = `${label}…`; - textarea.value = initialContent; - - textarea.addEventListener("blur", async () => { - const file = await this.ensureHexNote(); - if (!file) return; - await setSectionContent(this.app, path, section, textarea.value); - this.onChanged(); - }); - } - - private async ensureHexNote(): Promise { - const path = this.plugin.hexPath(this.x, this.y); - const existing = this.app.vault.getAbstractFileByPath(path); - if (existing instanceof TFile) return existing; - return this.plugin.createHexNote(this.x, this.y); - } -} +import { App, Modal, Notice, TFile } from "obsidian"; +import type DuckmagePlugin from "./DuckmagePlugin"; +import { getIconUrl, normalizeFolder, makeTableTemplate } from "./utils"; +import { + getTerrainFromFile, + setTerrainInFile, + setIconOverrideInFile, +} from "./frontmatter"; +import { + addLinkToSection, + removeLinkFromSection, + getLinksInSection, + getAllSectionData, + setSectionContent, + addBacklinkToFile, +} from "./sections"; +import { FileLinkSuggestModal } from "./FileLinkSuggestModal"; +import { TEXT_SECTIONS } from "./types"; +import type { LinkSection } from "./types"; +import { RandomTableModal } from "./RandomTableModal"; +import { VIEW_TYPE_HEX_MAP, VIEW_TYPE_RANDOM_TABLES } from "./constants"; + +export class HexEditorModal extends Modal { + private hexExists = false; + private allText = new Map(); + private allLinks = new Map(); + private directTerrain: string | null = null; + private directIcon: string | null = null; + + constructor( + app: App, + private plugin: DuckmagePlugin, + private x: number, + private y: number, + private onChanged: ( + terrainOverrides?: Map, + iconOverrides?: Map, + ) => void, + ) { + super(app); + } + + async loadData(): Promise { + // Reset all fields so stale data from a previous hex never bleeds through + this.allText = new Map(); + this.allLinks = new Map(); + this.directTerrain = null; + this.directIcon = null; + + const path = this.plugin.hexPath(this.x, this.y); + this.hexExists = + this.app.vault.getAbstractFileByPath(path) instanceof TFile; + if (!this.hexExists) return; + + ({ text: this.allText, links: this.allLinks } = await getAllSectionData( + this.app, + path, + )); + const rawContent = await this.app.vault.read( + this.app.vault.getAbstractFileByPath(path) as TFile, + ); + const fmMatch = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (fmMatch) { + const tm = fmMatch[1].match(/^\s*terrain:\s*(.+)$/m); + if (tm) this.directTerrain = tm[1].trim(); + const im = fmMatch[1].match(/^\s*icon:\s*(.+)$/m); + if (im) this.directIcon = im[1].trim(); + } + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("duckmage-hex-editor"); + + const { hexExists, allText, allLinks, directTerrain, directIcon } = this; + const path = this.plugin.hexPath(this.x, this.y); + const titleRow = contentEl.createDiv({ cls: "duckmage-editor-title-row" }); + const titleLeft = titleRow.createDiv({ cls: "duckmage-editor-title-left" }); + titleLeft.createEl("h2", { text: `Hex ${this.x}, ${this.y}` }); + const centerBtn = titleLeft.createEl("button", { + text: "⌖", + cls: "duckmage-editor-center-btn", + title: "Center map on this hex", + }); + centerBtn.addEventListener("click", () => { + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_HEX_MAP); + if (leaves.length > 0) + (leaves[0].view as any).centerOnHex?.(this.x, this.y); + }); + + if (hexExists) { + const file = this.app.vault.getAbstractFileByPath(path) as TFile; + const openLink = titleLeft.createEl("a", { + text: "Open note", + cls: "duckmage-editor-open-link", + }); + openLink.addEventListener("click", () => { + this.app.workspace.getLeaf("tab").openFile(file); + this.close(); + }); + } + this.renderNeighborWidget(titleRow, this.x, this.y); + + const s = this.plugin.settings; + + const { body: terrainBody, header: terrainHeader } = this.makeCollapsible( + contentEl, + "Terrain", + s.hexEditorTerrainCollapsed ?? false, + ); + // Show current terrain as a small swatch + name in the header + const paletteEntry = directTerrain + ? (this.plugin.settings.terrainPalette ?? []).find( + (p) => p.name === directTerrain, + ) + : undefined; + const iconToShow = directIcon ?? paletteEntry?.icon; + if (paletteEntry || iconToShow) { + const preview = terrainHeader.createSpan({ + cls: "duckmage-terrain-header-preview", + }); + const swatch = preview.createSpan({ + cls: "duckmage-terrain-header-swatch", + }); + if (paletteEntry) swatch.style.backgroundColor = paletteEntry.color; + if (iconToShow) { + const img = swatch.createEl("img"); + img.src = getIconUrl(this.plugin, iconToShow); + } + if (paletteEntry) { + preview.createSpan({ + text: paletteEntry.name, + cls: "duckmage-terrain-header-name", + }); + } + } + this.renderTerrainSection(terrainBody, path, directTerrain, directIcon); + + contentEl.createEl("hr", { cls: "duckmage-editor-divider" }); + + const { body: notesBody } = this.makeCollapsible( + contentEl, + "Notes", + s.hexEditorNotesCollapsed ?? false, + ); + for (const { key, label } of TEXT_SECTIONS) { + this.renderTextSection( + notesBody, + path, + key, + label, + allText.get(key) ?? "", + ); + } + + contentEl.createEl("hr", { cls: "duckmage-editor-divider" }); + + const { body: featuresBody } = this.makeCollapsible( + contentEl, + "Features", + s.hexEditorFeaturesCollapsed ?? false, + ); + this.renderDropdownSection( + featuresBody, + path, + "Encounters Table", + hexExists, + s.tablesFolder, + allLinks.get("encounters table") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Towns", + hexExists, + s.townsFolder, + allLinks.get("towns") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Dungeons", + hexExists, + s.dungeonsFolder, + allLinks.get("dungeons") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Quests", + hexExists, + s.questsFolder, + allLinks.get("quests") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Factions", + hexExists, + s.factionsFolder, + allLinks.get("factions") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Features", + hexExists, + s.featuresFolder, + allLinks.get("features") ?? [], + ); + + this.makeDraggable(); + } + + onClose() { + this.contentEl.empty(); + } + + private dragInitialized = false; + + private makeDraggable(): void { + if (this.dragInitialized) return; + this.dragInitialized = true; + + const modal = this.modalEl; + modal.addClass("duckmage-editor-modal-drag"); + modal.style.position = "absolute"; + modal.style.left = "50%"; + modal.style.top = "50%"; + modal.style.transform = "translate(-50%, -50%)"; + modal.style.margin = "0"; + + modal.addEventListener("mousedown", (e: MouseEvent) => { + // Only drag from the native modal header — the strip above .modal-content. + // This area never scrolls so the drag zone is always accessible. + const modalContent = modal.querySelector(".modal-content"); + if (modalContent && e.clientY >= modalContent.getBoundingClientRect().top) + return; + if ((e.target as HTMLElement).closest("button, a")) return; + + e.preventDefault(); + const r = modal.getBoundingClientRect(); + modal.style.transform = "none"; + modal.style.left = `${r.left}px`; + modal.style.top = `${r.top}px`; + const sx = e.clientX, + sy = e.clientY; + const ox = r.left, + oy = r.top; + const onMove = (ev: MouseEvent) => { + modal.style.left = `${ox + ev.clientX - sx}px`; + modal.style.top = `${oy + ev.clientY - sy}px`; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }); + } + + private isOnMap(nx: number, ny: number): boolean { + const { gridOffset, gridSize } = this.plugin.settings; + return ( + nx >= gridOffset.x && + nx < gridOffset.x + gridSize.cols && + ny >= gridOffset.y && + ny < gridOffset.y + gridSize.rows + ); + } + + private renderNeighborWidget( + container: HTMLElement, + x: number, + y: number, + ): void { + const isFlat = this.plugin.settings.hexOrientation === "flat"; + const widget = container.createDiv({ cls: "duckmage-neighbor-widget" }); + + type NeighborDef = { l: number; t: number; nx: number; ny: number }; + const defs: NeighborDef[] = isFlat + ? [ + { l: 22, t: 2, nx: x, ny: y - 1 }, // N + { l: 42, t: 13, nx: x + 1, ny: x % 2 === 0 ? y - 1 : y }, // NE + { l: 42, t: 32, nx: x + 1, ny: x % 2 === 0 ? y : y + 1 }, // SE + { l: 22, t: 40, nx: x, ny: y + 1 }, // S + { l: 2, t: 32, nx: x - 1, ny: x % 2 === 0 ? y : y + 1 }, // SW + { l: 2, t: 13, nx: x - 1, ny: x % 2 === 0 ? y - 1 : y }, // NW + ] + : [ + { l: 10, t: 1, nx: y % 2 === 0 ? x - 1 : x, ny: y - 1 }, // NW + { l: 34, t: 1, nx: y % 2 === 0 ? x : x + 1, ny: y - 1 }, // NE + { l: 0, t: 18, nx: x - 1, ny: y }, // W + { l: 44, t: 18, nx: x + 1, ny: y }, // E + { l: 10, t: 35, nx: y % 2 === 0 ? x - 1 : x, ny: y + 1 }, // SW + { l: 34, t: 35, nx: y % 2 === 0 ? x : x + 1, ny: y + 1 }, // SE + ]; + + for (const { l, t, nx, ny } of defs) { + const onMap = this.isOnMap(nx, ny); + const tile = widget.createDiv({ + cls: `duckmage-neighbor-tile${onMap ? "" : " duckmage-neighbor-tile-offmap"}`, + }); + tile.style.left = `${l}px`; + tile.style.top = `${t}px`; + + if (onMap) { + tile.title = `Hex ${nx}, ${ny}`; + const nPath = this.plugin.hexPath(nx, ny); + const terrain = getTerrainFromFile(this.app, nPath); + const entry = terrain + ? this.plugin.settings.terrainPalette.find((p) => p.name === terrain) + : undefined; + if (entry) tile.style.backgroundColor = entry.color; + tile.addEventListener("click", () => { + this.x = nx; + this.y = ny; + this.loadData().then(() => this.onOpen()); + }); + } else { + tile.title = "Off map"; + } + } + } + + private makeCollapsible( + container: HTMLElement, + label: string, + startCollapsed: boolean, + ): { body: HTMLElement; header: HTMLElement } { + const wrapper = container.createDiv({ cls: "duckmage-editor-collapsible" }); + const header = wrapper.createDiv({ + cls: "duckmage-editor-collapsible-header", + }); + const arrow = header.createSpan({ + cls: "duckmage-editor-collapsible-arrow", + text: startCollapsed ? "▶" : "▼", + }); + header.createEl("h3", { + text: label, + cls: "duckmage-editor-collapsible-title", + }); + const body = wrapper.createDiv({ cls: "duckmage-editor-collapsible-body" }); + if (startCollapsed) body.style.display = "none"; + header.addEventListener("click", () => { + const collapsed = body.style.display === "none"; + body.style.display = collapsed ? "" : "none"; + arrow.textContent = collapsed ? "▼" : "▶"; + }); + return { body, header }; + } + + private renderTerrainSection( + container: HTMLElement, + path: string, + currentTerrain: string | null, + currentIcon: string | null, + ): void { + const palette = this.plugin.settings.terrainPalette; + + const section = container.createDiv({ cls: "duckmage-editor-section" }); + + const grid = section.createDiv({ cls: "duckmage-terrain-picker" }); + + // Clear terrain — always first in the grid + if (currentTerrain) { + const clearBtn = grid.createDiv({ + cls: "duckmage-terrain-option duckmage-terrain-option-clear", + }); + clearBtn.createDiv({ + cls: "duckmage-terrain-preview duckmage-terrain-preview-clear", + }); + clearBtn.createSpan({ + text: "Clear", + cls: "duckmage-terrain-option-name", + }); + clearBtn.addEventListener("click", async () => { + await setTerrainInFile(this.app, path, null); + void this.plugin.syncHexEncounterTableLink(path, null); + this.onChanged(new Map([[path, null]])); + this.close(); + }); + } + + for (const entry of palette) { + const btn = grid.createDiv({ + cls: `duckmage-terrain-option${entry.name === currentTerrain ? " is-selected" : ""}`, + }); + + const preview = btn.createDiv({ cls: "duckmage-terrain-preview" }); + preview.style.backgroundColor = entry.color; + + if (entry.icon) { + const img = preview.createEl("img", { + cls: "duckmage-terrain-preview-icon", + }); + img.src = getIconUrl(this.plugin, entry.icon); + img.alt = entry.name; + } + + btn.createSpan({ text: entry.name, cls: "duckmage-terrain-option-name" }); + + btn.addEventListener("click", async () => { + await this.ensureHexNote(); + await setTerrainInFile(this.app, path, entry.name); + void this.plugin.syncHexEncounterTableLink(path, entry.name); + this.onChanged(new Map([[path, entry.name]])); + this.close(); + }); + } + + // Icon override row + const iconRow = section.createDiv({ cls: "duckmage-icon-override-row" }); + iconRow.createSpan({ + text: "Icon override", + cls: "duckmage-icon-override-label", + }); + const iconSelect = iconRow.createEl("select", { + cls: "duckmage-icon-override-select", + }); + iconSelect.createEl("option", { + value: "", + text: "— use terrain default —", + }); + for (const icon of this.plugin.availableIcons) { + const label = icon + .replace(/^bw-/, "") + .replace(/\.png$/, "") + .replace(/-/g, " "); + iconSelect.createEl("option", { value: icon, text: label }); + } + // Use directly-read icon value (not the stale metadata cache) + iconSelect.value = currentIcon ?? ""; + // Keep terrain in the overrides map so renderGrid doesn't lose it during + // the brief window when Obsidian clears the metadata cache on file modify. + const terrainOverrides: Map | undefined = + currentTerrain ? new Map([[path, currentTerrain]]) : undefined; + + iconSelect.addEventListener("change", async () => { + await this.ensureHexNote(); + await setIconOverrideInFile(this.app, path, iconSelect.value || null); + this.onChanged( + terrainOverrides, + new Map([[path, iconSelect.value || null]]), + ); + }); + const clearIconBtn = iconRow.createEl("button", { + text: "Clear", + cls: "duckmage-clear-btn", + title: "Remove icon override", + }); + clearIconBtn.style.visibility = currentIcon ? "visible" : "hidden"; + clearIconBtn.addEventListener("click", async () => { + await this.ensureHexNote(); + await setIconOverrideInFile(this.app, path, null); + this.onChanged(terrainOverrides, new Map([[path, null]])); + iconSelect.value = ""; + clearIconBtn.style.visibility = "hidden"; + }); + // Show/hide clear button as icon selection changes + iconSelect.addEventListener("change", () => { + clearIconBtn.style.visibility = iconSelect.value ? "visible" : "hidden"; + }); + } + + private getFilesForDropdown( + folder: string, + filterType?: "roll-filter" | "encounter-filter", + ): TFile[] { + const normalized = normalizeFolder(folder); + const all = this.app.vault.getMarkdownFiles(); + const scoped = normalized + ? all.filter((f) => f.path.startsWith(normalized + "/")) + : all; + let filtered = scoped.filter((f) => !f.basename.startsWith("_")); + if (filterType) { + const excluded = + filterType === "encounter-filter" + ? this.plugin.settings.encounterTableExcludedFolders + : this.plugin.settings.rollTableExcludedFolders; + filtered = this.plugin.filterTableFiles(filtered, filterType, excluded); + } + return filtered.sort((a, b) => a.basename.localeCompare(b.basename)); + } + + private renderDropdownSection( + container: HTMLElement, + path: string, + section: LinkSection, + hexExists: boolean, + sourceFolder: string, + initialLinks: string[], + ): void { + const sectionEl = container.createDiv({ + cls: "duckmage-editor-link-section", + }); + const header = sectionEl.createDiv({ cls: "duckmage-link-section-header" }); + header.createEl("h4", { text: section }); + + const select = header.createEl("select", { cls: "duckmage-link-select" }); + select.createEl("option", { value: "", text: "— add —" }); + const filterType = + section === "Encounters Table" + ? ("encounter-filter" as const) + : undefined; + for (const file of this.getFilesForDropdown(sourceFolder, filterType)) { + select.createEl("option", { value: file.path, text: file.basename }); + } + + const linksEl = sectionEl.createDiv({ cls: "duckmage-link-list" }); + + // For Encounters Table: clicking a linked item opens the table view for rolling + const onItemClick = + section === "Encounters Table" + ? async (_link: string, file: TFile) => { + const leaves = this.app.workspace.getLeavesOfType( + VIEW_TYPE_RANDOM_TABLES, + ); + if (leaves.length > 0) { + this.app.workspace.revealLeaf(leaves[0]); + (leaves[0].view as any).openTable?.(file.path); + } else { + const leaf = this.app.workspace.getLeaf("tab"); + await leaf.setViewState({ type: VIEW_TYPE_RANDOM_TABLES }); + (leaf.view as any).openTable?.(file.path); + } + this.close(); + } + : undefined; + + const onRemove = async (link: string) => { + await removeLinkFromSection(this.app, path, section, link); + this.onChanged(); + await refresh(); + }; + + const onRollClick = + section === "Encounters Table" + ? (file: TFile) => + new RandomTableModal( + this.app, + this.plugin, + undefined, + file.path, + ).open() + : undefined; + + const refresh = async () => { + linksEl.empty(); + this.renderLinkList( + linksEl, + await getLinksInSection(this.app, path, section), + path, + onRemove, + onItemClick, + onRollClick, + ); + }; + + if (hexExists) { + this.renderLinkList( + linksEl, + initialLinks, + path, + onRemove, + onItemClick, + onRollClick, + ); + } else { + linksEl.createSpan({ text: "None", cls: "duckmage-link-empty" }); + } + + select.addEventListener("change", async () => { + const selectedPath = select.value; + select.value = ""; + if (!selectedPath) return; + const file = this.app.vault.getAbstractFileByPath(selectedPath); + if (!(file instanceof TFile)) return; + const hexFile = await this.ensureHexNote(); + if (!hexFile) { + new Notice("Could not create hex note."); + return; + } + const linkText = `[[${this.app.metadataCache.fileToLinktext(file, path)}]]`; + await addLinkToSection(this.app, path, section, linkText); + await addBacklinkToFile(this.app, file.path, path); + this.onChanged(); + await refresh(); + }); + + // Create-new row + const createRow = sectionEl.createDiv({ + cls: "duckmage-editor-create-row", + }); + const createInput = createRow.createEl("input", { + type: "text", + cls: "duckmage-editor-create-input", + }); + createInput.placeholder = `New ${section.slice(0, -1).toLowerCase()}…`; + const createBtn = createRow.createEl("button", { + text: "Create", + cls: "duckmage-editor-create-btn", + }); + + const createAndLink = async () => { + const name = createInput.value.trim(); + if (!name) return; + const folder = normalizeFolder(sourceFolder); + const newPath = folder ? `${folder}/${name}.md` : `${name}.md`; + let file = this.app.vault.getAbstractFileByPath(newPath); + if (!(file instanceof TFile)) { + try { + if (folder && !this.app.vault.getAbstractFileByPath(folder)) { + await this.app.vault.createFolder(folder); + } + file = await this.app.vault.create( + newPath, + section === "Encounters Table" + ? makeTableTemplate(this.plugin.settings.defaultTableDice) + : "", + ); + } catch (err) { + new Notice(`Could not create ${newPath}: ${err}`); + return; + } + } + const hexFile = await this.ensureHexNote(); + if (!hexFile) { + new Notice("Could not create hex note."); + return; + } + const linkText = `[[${this.app.metadataCache.fileToLinktext(file as TFile, path)}]]`; + await addLinkToSection(this.app, path, section, linkText); + await addBacklinkToFile(this.app, (file as TFile).path, path); + this.onChanged(); + createInput.value = ""; + await refresh(); + }; + + createBtn.addEventListener("click", createAndLink); + createInput.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") createAndLink(); + }); + } + + private renderLinkSection( + container: HTMLElement, + path: string, + section: LinkSection, + hexExists: boolean, + initialLinks: string[], + ): void { + const sectionEl = container.createDiv({ + cls: "duckmage-editor-link-section", + }); + const header = sectionEl.createDiv({ cls: "duckmage-link-section-header" }); + header.createEl("h4", { text: section }); + const addBtn = header.createEl("button", { + text: "+ Add", + cls: "duckmage-add-btn", + }); + const linksEl = sectionEl.createDiv({ cls: "duckmage-link-list" }); + + if (hexExists) { + this.renderLinkList(linksEl, initialLinks, path); + } else { + linksEl.createSpan({ text: "—", cls: "duckmage-link-empty" }); + } + + addBtn.addEventListener("click", () => { + new FileLinkSuggestModal(this.app, this.plugin, async (file) => { + const hexFile = await this.ensureHexNote(); + if (!hexFile) { + new Notice("Could not create hex note."); + return; + } + const linkText = `[[${this.app.metadataCache.fileToLinktext(file, path)}]]`; + await addLinkToSection(this.app, path, section, linkText); + this.onChanged(); + const links = await getLinksInSection(this.app, path, section); + linksEl.empty(); + this.renderLinkList(linksEl, links, path); + }).open(); + }); + } + + private renderLinkList( + container: HTMLElement, + links: string[], + sourcePath: string, + onRemove?: (link: string) => void, + onItemClick?: (link: string, file: TFile) => void, + onRollClick?: (file: TFile) => void, + ): void { + if (links.length === 0) { + container.createSpan({ text: "None", cls: "duckmage-link-empty" }); + } else { + for (const link of links) { + const item = container.createDiv({ cls: "duckmage-link-item" }); + const label = item.createSpan({ + text: `[[${link}]]`, + cls: "duckmage-link-item-label", + }); + const file = this.app.metadataCache.getFirstLinkpathDest( + link, + sourcePath, + ); + if (file instanceof TFile) { + label.addClass("duckmage-link-item-clickable"); + label.addEventListener("click", () => { + if (onItemClick) { + void onItemClick(link, file); + } else { + this.app.workspace.getLeaf("tab").openFile(file); + this.close(); + } + }); + if (onRollClick) { + const rollBtn = item.createEl("button", { + text: "🎲", + cls: "duckmage-link-roll-btn", + }); + rollBtn.title = "Roll on this table"; + rollBtn.addEventListener("click", () => onRollClick(file)); + } + } + if (onRemove) { + const removeBtn = item.createEl("button", { + text: "×", + cls: "duckmage-link-remove-btn", + }); + removeBtn.addEventListener("click", () => onRemove(link)); + } + } + } + } + + private renderTextSection( + container: HTMLElement, + path: string, + section: string, + label: string, + initialContent: string, + ): void { + const sectionEl = container.createDiv({ + cls: "duckmage-editor-text-section", + }); + const labelRow = sectionEl.createDiv({ + cls: "duckmage-text-section-label-row", + }); + labelRow.createEl("label", { + text: label, + cls: "duckmage-text-section-label", + }); + + // Button group on the right — keeps 📖 and 🎲 clustered together + const btnGroup = labelRow.createDiv({ + cls: "duckmage-text-section-btn-group", + }); + + // 📖 button: terrain description table (description section) or generic section table + const tablesFolder = this.plugin.settings.tablesFolder + ? this.plugin.settings.tablesFolder.replace(/^\/+|\/+$/g, "") + : ""; + let previewTablePath: string | null = null; + let previewTitle = ""; + + if (section === "description") { + const terrain = getTerrainFromFile(this.app, path); + if (terrain) { + const p = tablesFolder + ? `${tablesFolder}/terrain/description/${terrain}.md` + : `terrain/description/${terrain}.md`; + if (this.app.vault.getAbstractFileByPath(p)) { + previewTablePath = p; + previewTitle = `Roll on ${terrain} description table`; + } + } + } else if ( + section === "landmark" || + section === "hidden" || + section === "secret" + ) { + const p = tablesFolder + ? `${tablesFolder}/${section}.md` + : `${section}.md`; + if (this.app.vault.getAbstractFileByPath(p)) { + previewTablePath = p; + previewTitle = `Roll on ${section} table`; + } + } + + if (previewTablePath) { + const previewBtn = btnGroup.createEl("button", { + text: "📖", + cls: "duckmage-section-desc-table-btn", + }); + previewBtn.title = previewTitle; + const capturedPath = previewTablePath; + previewBtn.addEventListener("click", () => { + new RandomTableModal( + this.app, + this.plugin, + undefined, + capturedPath, + ).open(); + }); + } + + const rollBtn = btnGroup.createEl("button", { + text: "🎲", + cls: "duckmage-section-roll-btn", + }); + rollBtn.title = "Roll on a table and append result"; + const textarea = sectionEl.createEl("textarea", { + cls: "duckmage-text-section-textarea", + }); + rollBtn.addEventListener("click", () => { + new RandomTableModal(this.app, this.plugin, (result) => { + if (textarea.value && !textarea.value.endsWith("\n")) + textarea.value += "\n"; + textarea.value += result; + }).open(); + }); + textarea.rows = 3; + textarea.placeholder = `${label}…`; + textarea.value = initialContent; + + textarea.addEventListener("blur", async () => { + const file = await this.ensureHexNote(); + if (!file) return; + await setSectionContent(this.app, path, section, textarea.value); + this.onChanged(); + }); + } + + private async ensureHexNote(): Promise { + const path = this.plugin.hexPath(this.x, this.y); + const existing = this.app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) return existing; + return this.plugin.createHexNote(this.x, this.y); + } +} diff --git a/src/HexmakerSettingTab.ts b/src/HexmakerSettingTab.ts index 2fb8567..95db16d 100644 --- a/src/HexmakerSettingTab.ts +++ b/src/HexmakerSettingTab.ts @@ -3,390 +3,500 @@ import type HexmakerPlugin from "./HexmakerPlugin"; import { normalizeFolder } from "./utils"; export class HexmakerSettingTab extends PluginSettingTab { - plugin: HexmakerPlugin; + plugin: HexmakerPlugin; - constructor(app: App, plugin: HexmakerPlugin) { - super(app, plugin); - this.plugin = plugin; - } + constructor(app: App, plugin: HexmakerPlugin) { + super(app, plugin); + this.plugin = plugin; + } - display(): void { - const { containerEl } = this; - containerEl.empty(); + display(): void { + const { containerEl } = this; + containerEl.empty(); - new Setting(containerEl) - .setName("World notes folder") - .setDesc("Vault-relative path. Scopes the file search when adding links to hexes.") - .addText(text => - text - .setPlaceholder("world") - .setValue(this.plugin.settings.worldFolder) - .onChange(async value => { - this.plugin.settings.worldFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("World notes folder") + .setDesc( + "Vault-relative path. Scopes the file search when adding links to hexes.", + ) + .addText((text) => + text + .setPlaceholder("world") + .setValue(this.plugin.settings.worldFolder) + .onChange(async (value) => { + this.plugin.settings.worldFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Set up folders") - .setDesc("Populates any blank folder settings below with defaults under the world folder, then creates those folders in your vault. Only blank fields are affected — manually set values are left untouched.") - .addButton(btn => - btn.setButtonText("Generate folders").setCta().onClick(async () => { - const world = normalizeFolder(this.plugin.settings.worldFolder) || "world"; - const defaults: [keyof typeof this.plugin.settings, string][] = [ - ["hexFolder", `${world}/hexes`], - ["townsFolder", `${world}/towns`], - ["dungeonsFolder", `${world}/dungeons`], - ["questsFolder", `${world}/quests`], - ["featuresFolder", `${world}/features`], - ["factionsFolder", `${world}/factions`], - ["tablesFolder", `${world}/tables`], - ["workflowsFolder", `${world}/workflows`], - ]; - for (const [key, path] of defaults) { - if (!this.plugin.settings[key]) { - (this.plugin.settings as unknown as Record)[key] = path; - try { - if (!this.app.vault.getAbstractFileByPath(path)) { - await this.app.vault.createFolder(path); - } - } catch { /* folder already exists */ } - } - } - await this.plugin.saveSettings(); - // Ensure all region subfolders exist and generate hex notes - const hexF = normalizeFolder(this.plugin.settings.hexFolder); - if (hexF) { - let totalCreated = 0; - for (const region of this.plugin.settings.regions) { - const regionFolder = `${hexF}/${region.name}`; - if (!this.app.vault.getAbstractFileByPath(regionFolder)) { - try { await this.app.vault.createFolder(regionFolder); } catch { /* exists */ } - } - const { cols, rows } = region.gridSize; - const { x: ox, y: oy } = region.gridOffset; - const xs = Array.from({ length: cols }, (_, i) => ox + i); - const ys = Array.from({ length: rows }, (_, i) => oy + i); - totalCreated += await this.plugin.generateHexNotes(region.name, xs, ys); - } - if (totalCreated > 0) new Notice(`Hexmaker: generated ${totalCreated} hex note${totalCreated !== 1 ? "s" : ""}.`); - } - new Notice("Folders generated."); - this.display(); - }), - ); + new Setting(containerEl) + .setName("Set up folders") + .setDesc( + "Populates any blank folder settings below with defaults under the world folder, then creates those folders in your vault. Only blank fields are affected — manually set values are left untouched.", + ) + .addButton((btn) => + btn + .setButtonText("Generate folders") + .setCta() + .onClick(async () => { + const world = + normalizeFolder(this.plugin.settings.worldFolder) || "world"; + const defaults: [keyof typeof this.plugin.settings, string][] = [ + ["hexFolder", `${world}/hexes`], + ["townsFolder", `${world}/towns`], + ["dungeonsFolder", `${world}/dungeons`], + ["questsFolder", `${world}/quests`], + ["featuresFolder", `${world}/features`], + ["factionsFolder", `${world}/factions`], + ["tablesFolder", `${world}/tables`], + ["workflowsFolder", `${world}/workflows`], + ]; + for (const [key, path] of defaults) { + if (!this.plugin.settings[key]) { + (this.plugin.settings as unknown as Record)[ + key + ] = path; + try { + if (!this.app.vault.getAbstractFileByPath(path)) { + await this.app.vault.createFolder(path); + } + } catch { + /* folder already exists */ + } + } + } + await this.plugin.saveSettings(); + // Ensure all region subfolders exist and generate hex notes + const hexF = normalizeFolder(this.plugin.settings.hexFolder); + if (hexF) { + let totalCreated = 0; + for (const region of this.plugin.settings.regions) { + const regionFolder = `${hexF}/${region.name}`; + if (!this.app.vault.getAbstractFileByPath(regionFolder)) { + try { + await this.app.vault.createFolder(regionFolder); + } catch { + /* exists */ + } + } + const { cols, rows } = region.gridSize; + const { x: ox, y: oy } = region.gridOffset; + const xs = Array.from({ length: cols }, (_, i) => ox + i); + const ys = Array.from({ length: rows }, (_, i) => oy + i); + totalCreated += await this.plugin.generateHexNotes( + region.name, + xs, + ys, + ); + } + if (totalCreated > 0) + new Notice( + `Hexmaker: generated ${totalCreated} hex note${totalCreated !== 1 ? "s" : ""}.`, + ); + } + new Notice("Folders generated."); + this.display(); + }), + ); - new Setting(containerEl) - .setName("Hex notes folder") - .setDesc("Vault-relative path where hex notes (x_y.md) are stored.") - .addText(text => - text - .setPlaceholder("world/hexes") - .setValue(this.plugin.settings.hexFolder) - .onChange(async value => { - this.plugin.settings.hexFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Hex notes folder") + .setDesc("Vault-relative path where hex notes (x_y.md) are stored.") + .addText((text) => + text + .setPlaceholder("world/hexes") + .setValue(this.plugin.settings.hexFolder) + .onChange(async (value) => { + this.plugin.settings.hexFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Towns folder") - .setDesc("Vault-relative folder to populate the Towns dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/towns") - .setValue(this.plugin.settings.townsFolder) - .onChange(async value => { - this.plugin.settings.townsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Towns folder") + .setDesc( + "Vault-relative folder to populate the Towns dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/towns") + .setValue(this.plugin.settings.townsFolder) + .onChange(async (value) => { + this.plugin.settings.townsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Dungeons folder") - .setDesc("Vault-relative folder to populate the Dungeons dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/dungeons") - .setValue(this.plugin.settings.dungeonsFolder) - .onChange(async value => { - this.plugin.settings.dungeonsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Dungeons folder") + .setDesc( + "Vault-relative folder to populate the Dungeons dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/dungeons") + .setValue(this.plugin.settings.dungeonsFolder) + .onChange(async (value) => { + this.plugin.settings.dungeonsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Quests folder") - .setDesc("Vault-relative folder to populate the Quests dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/quests") - .setValue(this.plugin.settings.questsFolder) - .onChange(async value => { - this.plugin.settings.questsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Quests folder") + .setDesc( + "Vault-relative folder to populate the Quests dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/quests") + .setValue(this.plugin.settings.questsFolder) + .onChange(async (value) => { + this.plugin.settings.questsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Features folder") - .setDesc("Vault-relative folder to populate the Features dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/features") - .setValue(this.plugin.settings.featuresFolder) - .onChange(async value => { - this.plugin.settings.featuresFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Features folder") + .setDesc( + "Vault-relative folder to populate the Features dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/features") + .setValue(this.plugin.settings.featuresFolder) + .onChange(async (value) => { + this.plugin.settings.featuresFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Factions folder") - .setDesc("Vault-relative folder to populate the Factions dropdown in the hex editor. Files starting with _ are excluded.") - .addText(text => - text - .setPlaceholder("world/factions") - .setValue(this.plugin.settings.factionsFolder) - .onChange(async value => { - this.plugin.settings.factionsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Factions folder") + .setDesc( + "Vault-relative folder to populate the Factions dropdown in the hex editor. Files starting with _ are excluded.", + ) + .addText((text) => + text + .setPlaceholder("world/factions") + .setValue(this.plugin.settings.factionsFolder) + .onChange(async (value) => { + this.plugin.settings.factionsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Tables folder") - .setDesc("Vault-relative folder for random table notes. Used by the Encounters Table section and the Random Tables view.") - .addText(text => - text - .setPlaceholder("world/tables") - .setValue(this.plugin.settings.tablesFolder) - .onChange(async value => { - this.plugin.settings.tablesFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Tables folder") + .setDesc( + "Vault-relative folder for random table notes. Used by the Encounters Table section and the Random Tables view.", + ) + .addText((text) => + text + .setPlaceholder("world/tables") + .setValue(this.plugin.settings.tablesFolder) + .onChange(async (value) => { + this.plugin.settings.tablesFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Workflows folder") - .setDesc("Vault-relative folder for workflow notes. Browsable from the Random Tables view via the Workflows tab.") - .addText(text => - text - .setPlaceholder("world/workflows") - .setValue(this.plugin.settings.workflowsFolder) - .onChange(async value => { - this.plugin.settings.workflowsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Workflows folder") + .setDesc( + "Vault-relative folder for workflow notes. Browsable from the Random Tables view via the Workflows tab.", + ) + .addText((text) => + text + .setPlaceholder("world/workflows") + .setValue(this.plugin.settings.workflowsFolder) + .onChange(async (value) => { + this.plugin.settings.workflowsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Default die for new tables") - .setDesc("Die size used when creating new random table notes (d6, d20, d100, etc.).") - .addDropdown(dropdown => - dropdown - .addOption("4", "d4") - .addOption("6", "d6") - .addOption("8", "d8") - .addOption("10", "d10") - .addOption("12", "d12") - .addOption("20", "d20") - .addOption("100", "d100") - .addOption("200", "d200") - .addOption("500", "d500") - .addOption("1000", "d1000") - .setValue(String(this.plugin.settings.defaultTableDice ?? 100)) - .onChange(async value => { - this.plugin.settings.defaultTableDice = parseInt(value, 10); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Default die for new tables") + .setDesc( + "Die size used when creating new random table notes (d6, d20, d100, etc.).", + ) + .addDropdown((dropdown) => + dropdown + .addOption("4", "d4") + .addOption("6", "d6") + .addOption("8", "d8") + .addOption("10", "d10") + .addOption("12", "d12") + .addOption("20", "d20") + .addOption("100", "d100") + .addOption("200", "d200") + .addOption("500", "d500") + .addOption("1000", "d1000") + .setValue(String(this.plugin.settings.defaultTableDice ?? 100)) + .onChange(async (value) => { + this.plugin.settings.defaultTableDice = parseInt(value, 10); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Hex editor sections start collapsed") - .setDesc("Choose which sections open collapsed by default in the right-click hex editor.") - .then(setting => { - const addCb = (label: string, get: () => boolean, set: (v: boolean) => void) => { - const lbl = setting.controlEl.createEl("label", { cls: "duckmage-collapse-cb-label" }); - const cb = lbl.createEl("input") as HTMLInputElement; - cb.type = "checkbox"; - cb.checked = get(); - cb.addEventListener("change", async () => { set(cb.checked); await this.plugin.saveSettings(); }); - lbl.appendText(label); - }; - addCb("Terrain", () => this.plugin.settings.hexEditorTerrainCollapsed, v => { this.plugin.settings.hexEditorTerrainCollapsed = v; }); - addCb("World features", () => this.plugin.settings.hexEditorFeaturesCollapsed, v => { this.plugin.settings.hexEditorFeaturesCollapsed = v; }); - addCb("Notes", () => this.plugin.settings.hexEditorNotesCollapsed, v => { this.plugin.settings.hexEditorNotesCollapsed = v; }); - }); + new Setting(containerEl) + .setName("Hex editor sections start collapsed") + .setDesc( + "Choose which sections open collapsed by default in the right-click hex editor.", + ) + .then((setting) => { + const addCb = ( + label: string, + get: () => boolean, + set: (v: boolean) => void, + ) => { + const lbl = setting.controlEl.createEl("label", { + cls: "duckmage-collapse-cb-label", + }); + const cb = lbl.createEl("input") as HTMLInputElement; + cb.type = "checkbox"; + cb.checked = get(); + cb.addEventListener("change", async () => { + set(cb.checked); + await this.plugin.saveSettings(); + }); + lbl.appendText(label); + }; + addCb( + "Terrain", + () => this.plugin.settings.hexEditorTerrainCollapsed, + (v) => { + this.plugin.settings.hexEditorTerrainCollapsed = v; + }, + ); + addCb( + "Features", + () => this.plugin.settings.hexEditorFeaturesCollapsed, + (v) => { + this.plugin.settings.hexEditorFeaturesCollapsed = v; + }, + ); + addCb( + "Notes", + () => this.plugin.settings.hexEditorNotesCollapsed, + (v) => { + this.plugin.settings.hexEditorNotesCollapsed = v; + }, + ); + }); - new Setting(containerEl) - .setName("Template path") - .setDesc("Vault-relative path to a hex note template. Supports {{x}}, {{y}}, {{title}}. Include ## Towns, ## Dungeons, and ## Features headings for the link sections.") - .addText(text => - text - .setPlaceholder("templates/hex.md") - .setValue(this.plugin.settings.templatePath) - .onChange(async value => { - this.plugin.settings.templatePath = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Template path") + .setDesc( + "Vault-relative path to a hex note template. Supports {{x}}, {{y}}, {{title}}. Include ## Towns, ## Dungeons, and ## Features headings for the link sections.", + ) + .addText((text) => + text + .setPlaceholder("templates/hex.md") + .setValue(this.plugin.settings.templatePath) + .onChange(async (value) => { + this.plugin.settings.templatePath = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Default region") - .setDesc("The region opened when the hex map is launched.") - .addDropdown(dropdown => { - for (const r of this.plugin.settings.regions) { - dropdown.addOption(r.name, r.name); - } - dropdown - .setValue(this.plugin.settings.defaultRegion ?? this.plugin.settings.regions[0]?.name ?? "") - .onChange(async value => { - this.plugin.settings.defaultRegion = value; - await this.plugin.saveSettings(); - }); - }); + new Setting(containerEl) + .setName("Default region") + .setDesc("The region opened when the hex map is launched.") + .addDropdown((dropdown) => { + for (const r of this.plugin.settings.regions) { + dropdown.addOption(r.name, r.name); + } + dropdown + .setValue( + this.plugin.settings.defaultRegion ?? + this.plugin.settings.regions[0]?.name ?? + "", + ) + .onChange(async (value) => { + this.plugin.settings.defaultRegion = value; + await this.plugin.saveSettings(); + }); + }); - new Setting(containerEl) - .setName("Hex orientation") - .setDesc("Pointy-top: points face north/south, flat sides east/west. Flat-top: flat sides face north/south, points east/west.") - .addDropdown(dropdown => - dropdown - .addOption("pointy", "Pointy-top") - .addOption("flat", "Flat-top") - .setValue(this.plugin.settings.hexOrientation ?? "pointy") - .onChange(async value => { - this.plugin.settings.hexOrientation = value as "pointy" | "flat"; - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Hex orientation") + .setDesc( + "Pointy-top: points face north/south, flat sides east/west. Flat-top: flat sides face north/south, points east/west.", + ) + .addDropdown((dropdown) => + dropdown + .addOption("pointy", "Pointy-top") + .addOption("flat", "Flat-top") + .setValue(this.plugin.settings.hexOrientation ?? "pointy") + .onChange(async (value) => { + this.plugin.settings.hexOrientation = value as "pointy" | "flat"; + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Hex cell spacing") - .setDesc("Gap between hex cells (0 – 0.5 em).") - .addSlider(slider => - slider - .setLimits(0, 0.5, 0.01) - .setValue(parseFloat(this.plugin.settings.hexGap ?? "0.15") || 0.15) - .setDynamicTooltip() - .onChange(async value => { - this.plugin.settings.hexGap = String(value); - await this.plugin.saveSettings(); - }), - ); + new Setting(containerEl) + .setName("Hex cell spacing") + .setDesc("Gap between hex cells (0 – 0.5 em).") + .addSlider((slider) => + slider + .setLimits(0, 0.5, 0.01) + .setValue(parseFloat(this.plugin.settings.hexGap ?? "0.15") || 0.15) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.hexGap = String(value); + await this.plugin.saveSettings(); + }), + ); - new Setting(containerEl) - .setName("Custom icons folder") - .setDesc("Vault-relative folder containing additional icon images (PNG, JPG, SVG, etc.) for the terrain palette and hex icon override. These are merged with the built-in icons.") - .addText(text => - text - .setPlaceholder("icons") - .setValue(this.plugin.settings.iconsFolder ?? "") - .onChange(async value => { - this.plugin.settings.iconsFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - await this.plugin.loadAvailableIcons(); - }), - ); + new Setting(containerEl) + .setName("Custom icons folder") + .setDesc( + "Vault-relative folder containing additional icon images (PNG, JPG, SVG, etc.) for the terrain palette and hex icon override. These are merged with the built-in icons.", + ) + .addText((text) => + text + .setPlaceholder("icons") + .setValue(this.plugin.settings.iconsFolder ?? "") + .onChange(async (value) => { + this.plugin.settings.iconsFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + await this.plugin.loadAvailableIcons(); + }), + ); - new Setting(containerEl).setName("Generate world data").setHeading(); - containerEl.createEl("p", { - cls: "setting-item-description duckmage-generate-warning", - text: "⚠️ Configure all folder settings above before selecting Generate. This will create terrain table notes, add roller links to all table notes, and link each hex note to its terrain's encounters table. Safe to run multiple times — existing notes and links are not overwritten.", - }); - new Setting(containerEl) - .setName("Generate terrain tables & hex links") - .setDesc("Creates missing terrain table notes, adds roller links to all table notes (so they can be opened in the Hexmaker Roller from within Obsidian), and links each hex note's terrain encounters table into its Encounters Table section.") - .addButton(btn => - btn.setButtonText("Generate").setCta().onClick(async () => { - btn.setDisabled(true); - btn.setButtonText("Generating…"); - try { - await this.plugin.ensureTerrainTables(); - await this.plugin.ensureAllRollerLinks(); - await this.plugin.backfillTerrainLinks(); - } finally { - btn.setDisabled(false); - btn.setButtonText("Generate"); - } - }), - ); + new Setting(containerEl).setName("Generate world data").setHeading(); + containerEl.createEl("p", { + cls: "setting-item-description duckmage-generate-warning", + text: "⚠️ Configure all folder settings above before selecting Generate. This will create terrain table notes, add roller links to all table notes, and link each hex note to its terrain's encounters table. Safe to run multiple times — existing notes and links are not overwritten.", + }); + new Setting(containerEl) + .setName("Generate terrain tables & hex links") + .setDesc( + "Creates missing terrain table notes, adds roller links to all table notes (so they can be opened in the Hexmaker Roller from within Obsidian), and links each hex note's terrain encounters table into its Encounters Table section.", + ) + .addButton((btn) => + btn + .setButtonText("Generate") + .setCta() + .onClick(async () => { + btn.setDisabled(true); + btn.setButtonText("Generating…"); + try { + await this.plugin.ensureTerrainTables(); + await this.plugin.ensureAllRollerLinks(); + await this.plugin.backfillTerrainLinks(); + } finally { + btn.setDisabled(false); + btn.setButtonText("Generate"); + } + }), + ); - new Setting(containerEl).setName("Path types").setHeading(); - containerEl.createEl("p", { - text: "Path types define the available drawing tools (roads, rivers, etc.). Edit them from the Path tool on the hex map.", - cls: "setting-item-description", - }); - const pathList = containerEl.createDiv({ cls: "duckmage-path-type-list" }); - for (const pt of this.plugin.settings.pathTypes) { - const row = pathList.createDiv({ cls: "duckmage-path-type-row" }); - const swatch = row.createSpan({ cls: "duckmage-path-type-swatch" }); - swatch.style.backgroundColor = pt.color; - row.createSpan({ text: `${pt.name} (${pt.width}px, ${pt.lineStyle}, ${pt.routing})` }); - } + new Setting(containerEl).setName("Path types").setHeading(); + containerEl.createEl("p", { + text: "Path types define the available drawing tools (roads, rivers, etc.). Edit them from the Path tool on the hex map.", + cls: "setting-item-description", + }); + const pathList = containerEl.createDiv({ cls: "duckmage-path-type-list" }); + for (const pt of this.plugin.settings.pathTypes) { + const row = pathList.createDiv({ cls: "duckmage-path-type-row" }); + const swatch = row.createSpan({ cls: "duckmage-path-type-swatch" }); + swatch.style.backgroundColor = pt.color; + row.createSpan({ + text: `${pt.name} (${pt.width}px, ${pt.lineStyle}, ${pt.routing})`, + }); + } - new Setting(containerEl).setName("Terrain palettes").setHeading(); - containerEl.createEl("p", { - text: "Each region uses one palette. Assign a palette when creating a region — it cannot be changed after. Edit palette contents from the terrain tool on the hex map.", - cls: "setting-item-description", - }); + new Setting(containerEl).setName("Terrain palettes").setHeading(); + containerEl.createEl("p", { + text: "Each region uses one palette. Assign a palette when creating a region — it cannot be changed after. Edit palette contents from the terrain tool on the hex map.", + cls: "setting-item-description", + }); - const palettes = this.plugin.settings.terrainPalettes; + const palettes = this.plugin.settings.terrainPalettes; - const renderPaletteList = () => { - const existingList = containerEl.querySelector(".duckmage-palette-mgmt-list"); - if (existingList) existingList.remove(); + const renderPaletteList = () => { + const existingList = containerEl.querySelector( + ".duckmage-palette-mgmt-list", + ); + if (existingList) existingList.remove(); - const listEl = containerEl.createDiv({ cls: "duckmage-palette-mgmt-list" }); + const listEl = containerEl.createDiv({ + cls: "duckmage-palette-mgmt-list", + }); - for (let i = 0; i < palettes.length; i++) { - const pal = palettes[i]; - const usedBy = this.plugin.settings.regions.filter(r => r.paletteName === pal.name).length; - const rowEl = listEl.createDiv({ cls: "duckmage-palette-mgmt-row" }); + for (let i = 0; i < palettes.length; i++) { + const pal = palettes[i]; + const usedBy = this.plugin.settings.regions.filter( + (r) => r.paletteName === pal.name, + ).length; + const rowEl = listEl.createDiv({ cls: "duckmage-palette-mgmt-row" }); - const nameInput = rowEl.createEl("input", { type: "text", value: pal.name }) as HTMLInputElement; - nameInput.addClass("duckmage-palette-mgmt-name"); - nameInput.addEventListener("blur", async () => { - const trimmed = nameInput.value.trim(); - if (!trimmed || trimmed === pal.name) { nameInput.value = pal.name; return; } - const isDupe = palettes.some((p, j) => j !== i && p.name.toLowerCase() === trimmed.toLowerCase()); - if (isDupe) { - new Notice(`Palette "${trimmed}" already exists.`); - nameInput.value = pal.name; - return; - } - // Update any regions using this palette - for (const r of this.plugin.settings.regions) { - if (r.paletteName === pal.name) r.paletteName = trimmed; - } - pal.name = trimmed; - await this.plugin.saveSettings(); - }); + const nameInput = rowEl.createEl("input", { + type: "text", + value: pal.name, + }) as HTMLInputElement; + nameInput.addClass("duckmage-palette-mgmt-name"); + nameInput.addEventListener("blur", async () => { + const trimmed = nameInput.value.trim(); + if (!trimmed || trimmed === pal.name) { + nameInput.value = pal.name; + return; + } + const isDupe = palettes.some( + (p, j) => j !== i && p.name.toLowerCase() === trimmed.toLowerCase(), + ); + if (isDupe) { + new Notice(`Palette "${trimmed}" already exists.`); + nameInput.value = pal.name; + return; + } + // Update any regions using this palette + for (const r of this.plugin.settings.regions) { + if (r.paletteName === pal.name) r.paletteName = trimmed; + } + pal.name = trimmed; + await this.plugin.saveSettings(); + }); - rowEl.createSpan({ cls: "duckmage-palette-mgmt-badge", text: `(${usedBy} region${usedBy !== 1 ? "s" : ""})` }); + rowEl.createSpan({ + cls: "duckmage-palette-mgmt-badge", + text: `(${usedBy} region${usedBy !== 1 ? "s" : ""})`, + }); - const deleteBtn = rowEl.createEl("button", { text: "Delete" }); - deleteBtn.disabled = usedBy > 0 || palettes.length <= 1; - deleteBtn.title = usedBy > 0 ? "Cannot delete — in use by a region" : palettes.length <= 1 ? "Cannot delete the last palette" : ""; - deleteBtn.addEventListener("click", async () => { - palettes.splice(i, 1); - await this.plugin.saveSettings(); - renderPaletteList(); - }); - } + const deleteBtn = rowEl.createEl("button", { text: "Delete" }); + deleteBtn.disabled = usedBy > 0 || palettes.length <= 1; + deleteBtn.title = + usedBy > 0 + ? "Cannot delete — in use by a region" + : palettes.length <= 1 + ? "Cannot delete the last palette" + : ""; + deleteBtn.addEventListener("click", async () => { + palettes.splice(i, 1); + await this.plugin.saveSettings(); + renderPaletteList(); + }); + } - new Setting(listEl).addButton(btn => - btn.setButtonText("Add palette").onClick(async () => { - palettes.push({ - name: "New Palette", - terrains: this.plugin.settings.terrainPalettes[0]?.terrains.map(t => ({ ...t })) ?? [], - }); - await this.plugin.saveSettings(); - renderPaletteList(); - }), - ); - }; + new Setting(listEl).addButton((btn) => + btn.setButtonText("Add palette").onClick(async () => { + palettes.push({ + name: "New Palette", + terrains: + this.plugin.settings.terrainPalettes[0]?.terrains.map((t) => ({ + ...t, + })) ?? [], + }); + await this.plugin.saveSettings(); + renderPaletteList(); + }), + ); + }; - renderPaletteList(); - } + renderPaletteList(); + } } diff --git a/src/hex-map/HexEditorModal.ts b/src/hex-map/HexEditorModal.ts index f9f82b4..6c4c474 100644 --- a/src/hex-map/HexEditorModal.ts +++ b/src/hex-map/HexEditorModal.ts @@ -1,7 +1,12 @@ import { App, Notice, TFile } from "obsidian"; import { HexmakerModal } from "../HexmakerModal"; import type HexmakerPlugin from "../HexmakerPlugin"; -import { getIconUrl, normalizeFolder, makeTableTemplate, createIconEl } from "../utils"; +import { + getIconUrl, + normalizeFolder, + makeTableTemplate, + createIconEl, +} from "../utils"; import { getTerrainFromFile, setTerrainInFile, @@ -92,7 +97,8 @@ export class HexEditorModal extends HexmakerModal { }); centerBtn.addEventListener("click", () => { const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_HEX_MAP); - if (leaves.length > 0) (leaves[0].view as any).centerOnHex?.(this.x, this.y); + if (leaves.length > 0) + (leaves[0].view as any).centerOnHex?.(this.x, this.y); }); // "Open note" can be determined synchronously from the vault index @@ -131,19 +137,28 @@ export class HexEditorModal extends HexmakerModal { s.hexEditorTerrainCollapsed ?? false, ); const paletteEntry = directTerrain - ? this.plugin.getRegionPalette(this.regionName).find(p => p.name === directTerrain) + ? this.plugin + .getRegionPalette(this.regionName) + .find((p) => p.name === directTerrain) : undefined; const iconToShow = directIcon ?? paletteEntry?.icon; if (paletteEntry || iconToShow) { - const preview = terrainHeader.createSpan({ cls: "duckmage-terrain-header-preview" }); - const swatch = preview.createSpan({ cls: "duckmage-terrain-header-swatch" }); + const preview = terrainHeader.createSpan({ + cls: "duckmage-terrain-header-preview", + }); + const swatch = preview.createSpan({ + cls: "duckmage-terrain-header-swatch", + }); if (paletteEntry) swatch.style.backgroundColor = paletteEntry.color; if (iconToShow) { const img = swatch.createEl("img"); img.src = getIconUrl(this.plugin, iconToShow); } if (paletteEntry) { - preview.createSpan({ text: paletteEntry.name, cls: "duckmage-terrain-header-name" }); + preview.createSpan({ + text: paletteEntry.name, + cls: "duckmage-terrain-header-name", + }); } } this.renderTerrainSection(terrainBody, path, directTerrain, directIcon); @@ -156,22 +171,70 @@ export class HexEditorModal extends HexmakerModal { s.hexEditorNotesCollapsed ?? false, ); for (const { key, label } of TEXT_SECTIONS) { - this.renderTextSection(notesBody, path, key, label, allText.get(key) ?? ""); + this.renderTextSection( + notesBody, + path, + key, + label, + allText.get(key) ?? "", + ); } bodyEl.createEl("hr", { cls: "duckmage-editor-divider" }); const { body: featuresBody } = this.makeCollapsible( bodyEl, - "World features", + "Features", s.hexEditorFeaturesCollapsed ?? false, ); - this.renderDropdownSection(featuresBody, path, "Encounters Table", hexExists, s.tablesFolder, allLinks.get("encounters table") ?? []); - this.renderDropdownSection(featuresBody, path, "Towns", hexExists, s.townsFolder, allLinks.get("towns") ?? []); - this.renderDropdownSection(featuresBody, path, "Dungeons", hexExists, s.dungeonsFolder, allLinks.get("dungeons") ?? []); - this.renderDropdownSection(featuresBody, path, "Quests", hexExists, s.questsFolder, allLinks.get("quests") ?? []); - this.renderDropdownSection(featuresBody, path, "Factions", hexExists, s.factionsFolder, allLinks.get("factions") ?? []); - this.renderDropdownSection(featuresBody, path, "Features", hexExists, s.featuresFolder, allLinks.get("features") ?? []); + this.renderDropdownSection( + featuresBody, + path, + "Encounters Table", + hexExists, + s.tablesFolder, + allLinks.get("encounters table") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Towns", + hexExists, + s.townsFolder, + allLinks.get("towns") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Dungeons", + hexExists, + s.dungeonsFolder, + allLinks.get("dungeons") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Quests", + hexExists, + s.questsFolder, + allLinks.get("quests") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Factions", + hexExists, + s.factionsFolder, + allLinks.get("factions") ?? [], + ); + this.renderDropdownSection( + featuresBody, + path, + "Features", + hexExists, + s.featuresFolder, + allLinks.get("features") ?? [], + ); } onClose() { @@ -179,7 +242,6 @@ export class HexEditorModal extends HexmakerModal { this.contentEl.empty(); } - private isOnMap(nx: number, ny: number): boolean { const region = this.plugin.getOrCreateRegion(this.regionName); const { gridOffset, gridSize } = region; @@ -191,28 +253,34 @@ export class HexEditorModal extends HexmakerModal { ); } - private renderNeighborWidget(container: HTMLElement, x: number, y: number): void { + private renderNeighborWidget( + container: HTMLElement, + x: number, + y: number, + ): void { const isFlat = this.plugin.settings.hexOrientation === "flat"; - const paletteMap = new Map(this.plugin.getRegionPalette(this.regionName).map(p => [p.name, p])); + const paletteMap = new Map( + this.plugin.getRegionPalette(this.regionName).map((p) => [p.name, p]), + ); const widget = container.createDiv({ cls: "duckmage-neighbor-widget" }); type NeighborDef = { l: number; t: number; nx: number; ny: number }; const defs: NeighborDef[] = isFlat ? [ - { l: 22, t: 2, nx: x, ny: y - 1 }, // N - { l: 42, t: 13, nx: x + 1, ny: x % 2 === 0 ? y - 1 : y }, // NE - { l: 42, t: 32, nx: x + 1, ny: x % 2 === 0 ? y : y + 1 }, // SE - { l: 22, t: 40, nx: x, ny: y + 1 }, // S - { l: 2, t: 32, nx: x - 1, ny: x % 2 === 0 ? y : y + 1 }, // SW - { l: 2, t: 13, nx: x - 1, ny: x % 2 === 0 ? y - 1 : y }, // NW + { l: 22, t: 2, nx: x, ny: y - 1 }, // N + { l: 42, t: 13, nx: x + 1, ny: x % 2 === 0 ? y - 1 : y }, // NE + { l: 42, t: 32, nx: x + 1, ny: x % 2 === 0 ? y : y + 1 }, // SE + { l: 22, t: 40, nx: x, ny: y + 1 }, // S + { l: 2, t: 32, nx: x - 1, ny: x % 2 === 0 ? y : y + 1 }, // SW + { l: 2, t: 13, nx: x - 1, ny: x % 2 === 0 ? y - 1 : y }, // NW ] : [ - { l: 10, t: 1, nx: y % 2 === 0 ? x - 1 : x, ny: y - 1 }, // NW - { l: 34, t: 1, nx: y % 2 === 0 ? x : x + 1, ny: y - 1 }, // NE - { l: 0, t: 18, nx: x - 1, ny: y }, // W - { l: 44, t: 18, nx: x + 1, ny: y }, // E - { l: 10, t: 35, nx: y % 2 === 0 ? x - 1 : x, ny: y + 1 }, // SW - { l: 34, t: 35, nx: y % 2 === 0 ? x : x + 1, ny: y + 1 }, // SE + { l: 10, t: 1, nx: y % 2 === 0 ? x - 1 : x, ny: y - 1 }, // NW + { l: 34, t: 1, nx: y % 2 === 0 ? x : x + 1, ny: y - 1 }, // NE + { l: 0, t: 18, nx: x - 1, ny: y }, // W + { l: 44, t: 18, nx: x + 1, ny: y }, // E + { l: 10, t: 35, nx: y % 2 === 0 ? x - 1 : x, ny: y + 1 }, // SW + { l: 34, t: 35, nx: y % 2 === 0 ? x : x + 1, ny: y + 1 }, // SE ]; for (const { l, t, nx, ny } of defs) { @@ -282,9 +350,16 @@ export class HexEditorModal extends HexmakerModal { // Clear terrain — always first in the grid if (currentTerrain) { - const clearBtn = grid.createDiv({ cls: "duckmage-terrain-option duckmage-terrain-option-clear" }); - clearBtn.createDiv({ cls: "duckmage-terrain-preview duckmage-terrain-preview-clear" }); - clearBtn.createSpan({ text: "Clear", cls: "duckmage-terrain-option-name" }); + const clearBtn = grid.createDiv({ + cls: "duckmage-terrain-option duckmage-terrain-option-clear", + }); + clearBtn.createDiv({ + cls: "duckmage-terrain-preview duckmage-terrain-preview-clear", + }); + clearBtn.createSpan({ + text: "Clear", + cls: "duckmage-terrain-option-name", + }); clearBtn.addEventListener("click", async () => { await setTerrainInFile(this.app, path, null); void this.plugin.syncHexEncounterTableLink(path, null); @@ -302,7 +377,13 @@ export class HexEditorModal extends HexmakerModal { preview.style.backgroundColor = entry.color; if (entry.icon) { - createIconEl(preview, getIconUrl(this.plugin, entry.icon), entry.name, entry.iconColor, "duckmage-terrain-preview-icon"); + createIconEl( + preview, + getIconUrl(this.plugin, entry.icon), + entry.name, + entry.iconColor, + "duckmage-terrain-preview-icon", + ); } btn.createSpan({ text: entry.name, cls: "duckmage-terrain-option-name" }); @@ -346,7 +427,10 @@ export class HexEditorModal extends HexmakerModal { iconSelect.addEventListener("change", async () => { await this.ensureHexNote(); await setIconOverrideInFile(this.app, path, iconSelect.value || null); - this.onChanged(terrainOverrides, new Map([[path, iconSelect.value || null]])); + this.onChanged( + terrainOverrides, + new Map([[path, iconSelect.value || null]]), + ); }); const clearIconBtn = iconRow.createEl("button", { text: "Clear", @@ -367,7 +451,10 @@ export class HexEditorModal extends HexmakerModal { }); } - private getFilesForDropdown(folder: string, filterType?: "roll-filter" | "encounter-filter"): TFile[] { + private getFilesForDropdown( + folder: string, + filterType?: "roll-filter" | "encounter-filter", + ): TFile[] { const normalized = normalizeFolder(folder); const all = this.app.vault.getMarkdownFiles(); const scoped = normalized @@ -375,9 +462,10 @@ export class HexEditorModal extends HexmakerModal { : all; let filtered = scoped.filter((f) => !f.basename.startsWith("_")); if (filterType) { - const excluded = filterType === "encounter-filter" - ? this.plugin.settings.encounterTableExcludedFolders - : this.plugin.settings.rollTableExcludedFolders; + const excluded = + filterType === "encounter-filter" + ? this.plugin.settings.encounterTableExcludedFolders + : this.plugin.settings.rollTableExcludedFolders; filtered = this.plugin.filterTableFiles(filtered, filterType, excluded); } return filtered.sort((a, b) => a.basename.localeCompare(b.basename)); @@ -391,8 +479,13 @@ export class HexEditorModal extends HexmakerModal { sourceFolder: string, initialLinks: string[], ): void { - const sectionEl = container.createDiv({ cls: "duckmage-editor-link-section" }); - sectionEl.createEl("h4", { text: section, cls: "duckmage-link-section-title" }); + const sectionEl = container.createDiv({ + cls: "duckmage-editor-link-section", + }); + sectionEl.createEl("h4", { + text: section, + cls: "duckmage-link-section-title", + }); // ── Combo box ────────────────────────────────────────────────────────── const comboWrap = sectionEl.createDiv({ cls: "duckmage-link-combo" }); @@ -407,19 +500,26 @@ export class HexEditorModal extends HexmakerModal { cls: "duckmage-link-combo-arrow", title: "Show all", }); - const dropdown = comboWrap.createDiv({ cls: "duckmage-link-combo-dropdown" }); + const dropdown = comboWrap.createDiv({ + cls: "duckmage-link-combo-dropdown", + }); dropdown.style.display = "none"; // ── Link list ────────────────────────────────────────────────────────── const linksEl = sectionEl.createDiv({ cls: "duckmage-link-list" }); let currentLinks = hexExists ? [...initialLinks] : []; - const filterType = section === "Encounters Table" ? "encounter-filter" as const : undefined; + const filterType = + section === "Encounters Table" + ? ("encounter-filter" as const) + : undefined; const onItemClick = section === "Encounters Table" ? async (_link: string, file: TFile) => { - const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_RANDOM_TABLES); + const leaves = this.app.workspace.getLeavesOfType( + VIEW_TYPE_RANDOM_TABLES, + ); if (leaves.length > 0) { this.app.workspace.revealLeaf(leaves[0]); (leaves[0].view as any).openTable?.(file.path); @@ -435,11 +535,16 @@ export class HexEditorModal extends HexmakerModal { const onRollClick = section === "Encounters Table" ? (file: TFile) => - new RandomTableModal(this.app, this.plugin, undefined, file.path).open() + new RandomTableModal( + this.app, + this.plugin, + undefined, + file.path, + ).open() : undefined; const onRemove = async (link: string) => { - currentLinks = currentLinks.filter(l => l !== link); + currentLinks = currentLinks.filter((l) => l !== link); refresh(); await removeLinkFromSection(this.app, path, section, link); this.onChanged(); @@ -447,7 +552,14 @@ export class HexEditorModal extends HexmakerModal { const refresh = () => { linksEl.empty(); - this.renderLinkList(linksEl, currentLinks, path, onRemove, onItemClick, onRollClick); + this.renderLinkList( + linksEl, + currentLinks, + path, + onRemove, + onItemClick, + onRollClick, + ); }; refresh(); @@ -459,7 +571,7 @@ export class HexEditorModal extends HexmakerModal { const files = this.getFilesForDropdown(sourceFolder, filterType); if (!query) return files; const q = query.toLowerCase(); - return files.filter(f => f.basename.toLowerCase().includes(q)); + return files.filter((f) => f.basename.toLowerCase().includes(q)); }; const populateDropdown = (query: string) => { @@ -468,7 +580,10 @@ export class HexEditorModal extends HexmakerModal { const files = getFiltered(trimmed); if (files.length === 0 && !trimmed) { - dropdown.createDiv({ cls: "duckmage-link-combo-empty", text: "No files in folder" }); + dropdown.createDiv({ + cls: "duckmage-link-combo-empty", + text: "No files in folder", + }); } for (const file of files) { @@ -480,7 +595,9 @@ export class HexEditorModal extends HexmakerModal { }); } - const exactMatch = files.some(f => f.basename.toLowerCase() === trimmed.toLowerCase()); + const exactMatch = files.some( + (f) => f.basename.toLowerCase() === trimmed.toLowerCase(), + ); if (trimmed && !exactMatch) { const createItem = dropdown.createDiv({ cls: "duckmage-link-combo-item duckmage-link-combo-create", @@ -508,7 +625,10 @@ export class HexEditorModal extends HexmakerModal { closeDropdown(); input.value = ""; const hexFile = await this.ensureHexNote(); - if (!hexFile) { new Notice("Could not create hex note."); return; } + if (!hexFile) { + new Notice("Could not create hex note."); + return; + } const linkPath = this.app.metadataCache.fileToLinktext(file, path); currentLinks = [...currentLinks, linkPath]; refresh(); @@ -540,8 +660,14 @@ export class HexEditorModal extends HexmakerModal { } } const hexFile = await this.ensureHexNote(); - if (!hexFile) { new Notice("Could not create hex note."); return; } - const linkPath = this.app.metadataCache.fileToLinktext(file as TFile, path); + if (!hexFile) { + new Notice("Could not create hex note."); + return; + } + const linkPath = this.app.metadataCache.fileToLinktext( + file as TFile, + path, + ); currentLinks = [...currentLinks, linkPath]; refresh(); void addLinkToSection(this.app, path, section, `[[${linkPath}]]`); @@ -550,18 +676,26 @@ export class HexEditorModal extends HexmakerModal { }; input.addEventListener("focus", () => openDropdown()); - input.addEventListener("blur", () => setTimeout(() => closeDropdown(), 150)); + input.addEventListener("blur", () => + setTimeout(() => closeDropdown(), 150), + ); input.addEventListener("input", () => { if (!isOpen) openDropdown(); else populateDropdown(input.value); }); input.addEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Escape") { closeDropdown(); input.blur(); return; } + if (e.key === "Escape") { + closeDropdown(); + input.blur(); + return; + } if (e.key === "Enter") { const trimmed = input.value.trim(); if (!trimmed) return; const files = getFiltered(trimmed); - const exact = files.find(f => f.basename.toLowerCase() === trimmed.toLowerCase()); + const exact = files.find( + (f) => f.basename.toLowerCase() === trimmed.toLowerCase(), + ); if (exact) void selectFile(exact); else if (files.length === 1) void selectFile(files[0]); else void createAndLink(trimmed); @@ -571,7 +705,10 @@ export class HexEditorModal extends HexmakerModal { arrowBtn.addEventListener("mousedown", (e) => e.preventDefault()); arrowBtn.addEventListener("click", () => { if (isOpen) closeDropdown(); - else { input.focus(); openDropdown(); } + else { + input.focus(); + openDropdown(); + } }); } @@ -686,7 +823,9 @@ export class HexEditorModal extends HexmakerModal { }); // 📖 button: terrain description table (description section) or section-specific table - const tablesFolder = normalizeFolder(this.plugin.settings.tablesFolder ?? ""); + const tablesFolder = normalizeFolder( + this.plugin.settings.tablesFolder ?? "", + ); let previewTablePath: string | null = null; let previewTitle = ""; @@ -720,7 +859,9 @@ export class HexEditorModal extends HexmakerModal { }); if (previewTablePath) { - const btnGroup = labelRow.createDiv({ cls: "duckmage-text-section-btn-group" }); + const btnGroup = labelRow.createDiv({ + cls: "duckmage-text-section-btn-group", + }); const previewBtn = btnGroup.createEl("button", { text: "📖", cls: "duckmage-section-desc-table-btn", @@ -728,11 +869,16 @@ export class HexEditorModal extends HexmakerModal { previewBtn.title = previewTitle; const capturedPath = previewTablePath; previewBtn.addEventListener("click", () => { - new RandomTableModal(this.app, this.plugin, (result) => { - if (textarea.value && !textarea.value.endsWith("\n")) - textarea.value += "\n"; - textarea.value += result; - }, capturedPath).open(); + new RandomTableModal( + this.app, + this.plugin, + (result) => { + if (textarea.value && !textarea.value.endsWith("\n")) + textarea.value += "\n"; + textarea.value += result; + }, + capturedPath, + ).open(); }); } textarea.rows = 3;