diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 83a04f4..d3f9ab7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,7 +31,7 @@ The clip/controls separation ensures toolbar buttons are never hidden by the vie | Terrain | `"terrain"` | `TerrainPickerModal` | frontmatter `terrain:` | | Icon | `"icon"` | `IconPickerModal` | frontmatter `icon:` | | Link Table | `"tableLink"` | `TablePickerModal` | `### Encounters Table` | -| Link Faction | `"factionLink"` | `FactionPickerModal` | `### Factions` | +| Factions | `"factionLink"` | `FactionPickerModal` | `### Factions` | **Tool lifecycle** (standard pattern): 1. Button click → `handle*Button()` opens picker modal diff --git a/src/frontmatter.ts b/src/frontmatter.ts index 99db9f0..c66d4c3 100644 --- a/src/frontmatter.ts +++ b/src/frontmatter.ts @@ -43,6 +43,28 @@ export async function setTerrainInFile( return true; } +export function getFactionColorFromFile(app: App, path: string): string | null { + const color = getFrontMatter(app, path)?.["faction-color"]; + return typeof color === "string" ? color : null; +} + +export async function setFactionColorInFile( + app: App, + path: string, + color: string | null, +): Promise { + const file = app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) return false; + await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => { + if (color === null) { + delete fm["faction-color"]; + } else { + fm["faction-color"] = color; + } + }); + return true; +} + export function getIconOverrideFromFile(app: App, path: string): string | null { const icon = getFrontMatter(app, path)?.icon; return typeof icon === "string" ? icon : null; diff --git a/src/hex-map/FactionPickerModal.ts b/src/hex-map/FactionPickerModal.ts new file mode 100644 index 0000000..f5b6886 --- /dev/null +++ b/src/hex-map/FactionPickerModal.ts @@ -0,0 +1,218 @@ +import { App, Setting, TFile } from "obsidian"; +import { HexmakerModal } from "../HexmakerModal"; +import type HexmakerPlugin from "../HexmakerPlugin"; +import { normalizeFolder } from "../utils"; +import { getFactionColorFromFile, setFactionColorInFile } from "../frontmatter"; + +// ── Faction editor sub-modal ───────────────────────────────────────────────── + +class FactionEditorModal extends HexmakerModal { + constructor( + app: App, + private file: TFile, + private initialColor: string | null, + /** Called on save with the (possibly updated) color and basename. */ + private onSaved: (color: string | null, newBasename: string) => void, + ) { + super(app); + } + + onOpen(): void { + this.titleEl.setText(this.file.basename); + this.makeDraggable(); + const { contentEl } = this; + + let pendingColor = this.initialColor ?? "#888888"; + let pendingName = this.file.basename; + + // ── Open-note link ──────────────────────────────────────────────────────── + const headerRow = contentEl.createDiv({ cls: "duckmage-faction-editor-header" }); + const openBtn = headerRow.createEl("button", { + text: "Open note →", + cls: "duckmage-faction-editor-open-btn", + }); + openBtn.addEventListener("click", () => { + void this.app.workspace.getLeaf().openFile(this.file); + this.close(); + }); + + // ── Description (async read from note body) ─────────────────────────────── + const descWrap = contentEl.createDiv({ cls: "duckmage-faction-editor-desc" }); + descWrap.createSpan({ text: "Description", cls: "duckmage-faction-editor-desc-label" }); + const descText = descWrap.createEl("p", { + text: "Loading…", + cls: "duckmage-faction-editor-desc-text", + }); + + void (async () => { + const raw = await this.app.vault.read(this.file); + // Strip YAML frontmatter + const body = raw.replace(/^---[\s\S]*?---\n?/, "").trim(); + // First non-empty paragraph + const para = (body.split(/\n{2,}/).find((p) => p.trim()) ?? "").trim(); + const preview = para.length > 400 ? para.slice(0, 400) + "…" : para; + descText.setText(preview || "(no description)"); + })(); + + // ── Name ────────────────────────────────────────────────────────────────── + new Setting(contentEl).setName("Name").addText((text) => + text.setValue(pendingName).onChange((v) => { + pendingName = v.trim() || pendingName; + }), + ); + + // ── Color ───────────────────────────────────────────────────────────────── + new Setting(contentEl) + .setName("Color") + .addColorPicker((cp) => + cp.setValue(pendingColor).onChange((v) => { + pendingColor = v; + }), + ); + + // ── Buttons ─────────────────────────────────────────────────────────────── + const btnRow = contentEl.createDiv({ cls: "duckmage-faction-editor-btns" }); + + const saveBtn = btnRow.createEl("button", { + text: "Save", + cls: "mod-cta duckmage-faction-editor-save", + }); + saveBtn.addEventListener("click", () => { + void (async () => { + // Rename first so this.file.path is updated before the color write + let newBasename = this.file.basename; + if (pendingName && pendingName !== this.file.basename) { + const folder = this.file.parent?.path ?? ""; + const newPath = folder + ? `${folder}/${pendingName}.md` + : `${pendingName}.md`; + await this.app.fileManager.renameFile(this.file, newPath); + newBasename = pendingName; + } + // this.file.path is updated in-place by Obsidian after rename + await setFactionColorInFile(this.app, this.file.path, pendingColor); + this.onSaved(pendingColor, newBasename); + this.close(); + })(); + }); + + const clearBtn = btnRow.createEl("button", { + text: "Clear color", + cls: "duckmage-faction-editor-clear", + }); + if (!this.initialColor) clearBtn.disabled = true; + clearBtn.addEventListener("click", () => { + void (async () => { + await setFactionColorInFile(this.app, this.file.path, null); + this.onSaved(null, this.file.basename); + this.close(); + })(); + }); + } + + onClose(): void { + this.contentEl.empty(); + } +} + +// ── Faction palette picker ─────────────────────────────────────────────────── + +export class FactionPickerModal extends HexmakerModal { + constructor( + app: App, + private plugin: HexmakerPlugin, + private onPicked: (filePath: string) => void, + ) { + super(app); + } + + onOpen(): void { + this.titleEl.setText("Factions"); + this.makeDraggable(); + this.buildPalette(); + } + + private buildPalette(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("duckmage-faction-picker"); + + const folder = normalizeFolder(this.plugin.settings.factionsFolder); + const files = this.app.vault + .getMarkdownFiles() + .filter((f) => { + if (f.basename.startsWith("_")) return false; + if (!folder) return true; + return f.path.startsWith(folder + "/"); + }) + .sort((a, b) => a.basename.localeCompare(b.basename)); + + if (files.length === 0) { + contentEl.createEl("p", { + text: folder + ? `No faction notes found in "${folder}".` + : "No factions folder configured.", + cls: "duckmage-faction-picker-empty", + }); + return; + } + + const grid = contentEl.createDiv({ cls: "duckmage-faction-palette" }); + + for (const file of files) { + let color = getFactionColorFromFile(this.app, file.path); + + const tile = grid.createDiv({ cls: "duckmage-faction-tile" }); + + const preview = tile.createDiv({ cls: "duckmage-faction-tile-preview" }); + if (color) { + preview.style.backgroundColor = color; + } else { + preview.addClass("duckmage-faction-tile-preview-empty"); + } + + // Edit button — top-right corner, shown on hover via CSS + const editBtn = tile.createEl("button", { + cls: "duckmage-faction-tile-edit", + attr: { title: "Edit faction" }, + }); + editBtn.setText("✏"); + + // Name label — kept as a reference so rename can update it + const nameEl = tile.createSpan({ + text: file.basename, + cls: "duckmage-faction-tile-name", + }); + + editBtn.addEventListener("click", (e) => { + e.stopPropagation(); + new FactionEditorModal( + this.app, + file, // TFile mutated in-place by Obsidian on rename + color, + (newColor, newBasename) => { + color = newColor; + if (newColor) { + preview.style.backgroundColor = newColor; + preview.removeClass("duckmage-faction-tile-preview-empty"); + } else { + preview.style.backgroundColor = ""; + preview.addClass("duckmage-faction-tile-preview-empty"); + } + nameEl.setText(newBasename); + }, + ).open(); + }); + + // Main tile click → select faction for painting (file.path reflects renames) + tile.addEventListener("click", () => { + this.onPicked(file.path); + this.close(); + }); + } + } + + onClose(): void { + this.contentEl.empty(); + } +} diff --git a/src/hex-map/HexMapView.ts b/src/hex-map/HexMapView.ts index 4dd5808..e0eb544 100644 --- a/src/hex-map/HexMapView.ts +++ b/src/hex-map/HexMapView.ts @@ -11,7 +11,8 @@ import { import { HexEditorModal } from "./HexEditorModal"; import { TerrainPickerModal } from "./TerrainPickerModal"; import { IconPickerModal } from "./IconPickerModal"; -import { addLinkToSection, getLinksInSection } from "../sections"; +import { addLinkToSection, getLinksInSection, removeLinkFromSection } from "../sections"; +import { getFactionColorFromFile } from "../frontmatter"; import { VIEW_TYPE_HEX_MAP, VIEW_TYPE_HEX_TABLE, @@ -30,6 +31,7 @@ import { import { GotoHexModal } from "./GotoHexModal"; import { HexHelpModal } from "./HexHelpModal"; import { FolderTreePickerModal } from "./FolderTreePickerModal"; +import { FactionPickerModal } from "./FactionPickerModal"; import { DrawingToolPanel, OverlayPanel } from "./HexSidePanel"; type TerrainUndoEntry = { @@ -101,6 +103,10 @@ export class HexMapView extends ItemView { { x: number; y: number; icon: string | null } >(); private flushing = new Set(); // "t:" or "i:" + // Faction links painted but not yet reflected in the metadata cache + private pendingFactionLinks = new Map>(); + // Faction links erased but cache may still reflect them — excluded from overlay + private erasedFactionLinks = new Map>(); private savingIndicatorEl: HTMLElement | null = null; // Undo / redo private readonly UNDO_DEPTH = 20; @@ -213,7 +219,7 @@ export class HexMapView extends ItemView { return; } if (e.button !== 0) return; - if (this.drawingMode === "terrain" || this.drawingMode === "icon") { + if (this.drawingMode === "terrain" || this.drawingMode === "icon" || this.drawingMode === "factionLink") { isTerrainPainting = true; lastPaintedKey = null; if (this.drawingMode === "terrain") @@ -227,12 +233,13 @@ export class HexMapView extends ItemView { const y = Number(hexEl.dataset.y); lastPaintedKey = `${x}_${y}`; if (this.drawingMode === "terrain") this.onHexPaintClick(x, y); - else this.onHexIconClick(x, y); + else if (this.drawingMode === "icon") this.onHexIconClick(x, y); + else void this.onHexFactionPaintClick(x, y); hasDragged = true; // suppress the subsequent click event } return; // skip pan setup } - // Any other active tool (road, river, tableLink, factionLink, swap): + // Any other active tool (road, river, tableLink, swap): // let the click event handle it — don't set up drag/pan so hasDragged // never swallows the click. if (this.drawingMode !== null) return; @@ -260,6 +267,7 @@ export class HexMapView extends ItemView { lastPaintedKey = key; if (this.drawingMode === "terrain") this.onHexPaintClick(x, y); else if (this.drawingMode === "icon") this.onHexIconClick(x, y); + else void this.onHexFactionPaintClick(x, y); } this.updateBrushHighlight(x, y); } else { @@ -334,7 +342,7 @@ export class HexMapView extends ItemView { } if (this.drawingMode === null) return; const onHex = (e.target as HTMLElement).closest(".duckmage-hex"); - if (onHex && this.drawingMode === "path") return; + if (onHex && (this.drawingMode === "path" || this.drawingMode === "factionLink")) return; e.preventDefault(); e.stopPropagation(); const now = Date.now(); @@ -482,6 +490,7 @@ export class HexMapView extends ItemView { this.plugin, () => this.viewportEl, () => this.getActiveMap(), + (show) => { if (show) this.updateFactionOverlay(); else this.clearFactionOverlay(); }, ); toolsPanel.onBeforeOpen = () => this.overlayPanel?.close(); this.overlayPanel.onBeforeOpen = () => toolsPanel.close(); @@ -492,6 +501,24 @@ export class HexMapView extends ItemView { text: "Saving…", }); + this.registerEvent( + this.app.metadataCache.on("changed", (file) => { + // Clear stale pending/erased entries for hex files + this.pendingFactionLinks.delete(file.path); + this.erasedFactionLinks.delete(file.path); + + // If a faction file's metadata changed (e.g. color edited), refresh the + // overlay now that the cache is guaranteed to have the new frontmatter. + if (this.getActiveMap().showFactionOverlay) { + const folder = normalizeFolder(this.plugin.settings.factionsFolder); + const isFactionFile = + !file.basename.startsWith("_") && + (folder ? file.path.startsWith(folder + "/") : true); + if (isFactionFile) this.updateFactionOverlay(); + } + }), + ); + this.renderGrid(); return Promise.resolve(); } @@ -652,7 +679,7 @@ export class HexMapView extends ItemView { cls: "duckmage-draw-btn duckmage-draw-btn-tablelink", }); this.factionLinkBtnLabel = this.factionLinkBtn.createSpan({ - text: "Link faction", + text: "Factions", }); this.factionLinkBtn.addEventListener("click", () => this.handleFactionLinkButton(), @@ -760,20 +787,11 @@ export class HexMapView extends ItemView { } private handleFactionLinkButton(): void { - if (this.drawingMode === "factionLink") { this.exitFactionLinkMode(); return; } - new FolderTreePickerModal( - this.app, - this.plugin, - this.plugin.settings.factionsFolder, - "Select faction", - "Filter factions…", - "No factions found.", - (file) => { - this.drawingMode = "factionLink"; - this.paintFactionPath = file.path; - this.updateToolbarButtonStates(); - }, - ).open(); + new FactionPickerModal(this.app, this.plugin, (filePath) => { + this.drawingMode = "factionLink"; + this.paintFactionPath = filePath; + this.updateToolbarButtonStates(); + }).open(); } private exitFactionLinkMode(): void { @@ -1094,7 +1112,7 @@ export class HexMapView extends ItemView { "Faction"; this.factionLinkBtnLabel.setText("Link: " + name); } else { - this.factionLinkBtnLabel.setText("Link faction"); + this.factionLinkBtnLabel.setText("Factions"); } } } @@ -1275,6 +1293,7 @@ export class HexMapView extends ItemView { } this.renderPathOverlay(gridContainer); + this.renderFactionOverlay(gridContainer); } private openHexEditorModal(x: number, y: number): void { @@ -1313,6 +1332,10 @@ export class HexMapView extends ItemView { void this.onHexPathDeleteClick(x, y); return; } + if (this.drawingMode === "factionLink") { + void this.onHexFactionEraseClick(x, y); + return; + } if (this.drawingMode === "swap") { this.exitSwapMode(); return; @@ -1374,10 +1397,6 @@ export class HexMapView extends ItemView { await this.onHexTableLinkClick(x, y); return; } - if (this.drawingMode === "factionLink") { - await this.onHexFactionLinkClick(x, y); - return; - } if (this.drawingMode === "swap") { await this.onHexSwapClick(x, y); return; @@ -1556,40 +1575,66 @@ export class HexMapView extends ItemView { } } - private async onHexFactionLinkClick(x: number, y: number): Promise { + private async onHexFactionPaintClick(x: number, y: number): Promise { if (this.drawingMode !== "factionLink" || !this.paintFactionPath) return; const hexPath = this.plugin.hexPath(x, y, this.activeMapName); - const factionFile = this.app.vault.getAbstractFileByPath( - this.paintFactionPath, - ); + const factionFile = this.app.vault.getAbstractFileByPath(this.paintFactionPath); if (!(factionFile instanceof TFile)) return; + const factionBasename = factionFile.basename; + + // Skip silently if already painted (pending or cache both use basenames now) + if (this.getFactionLinksFromCache(hexPath).includes(factionBasename)) return; + + // Update the overlay IMMEDIATELY (synchronous) — no awaits before this point + // If this faction was previously erased (still in cache), un-erase it + this.erasedFactionLinks.get(hexPath)?.delete(factionBasename); + const pending = this.pendingFactionLinks.get(hexPath) ?? new Set(); + pending.add(factionBasename); + this.pendingFactionLinks.set(hexPath, pending); + if (this.getActiveMap().showFactionOverlay) this.updateFactionOverlay(); + + // Create hex note if it doesn't exist yet let hexFile = this.app.vault.getAbstractFileByPath(hexPath); if (!(hexFile instanceof TFile)) { hexFile = await this.plugin.createHexNote(x, y, this.activeMapName); - if (!(hexFile instanceof TFile)) return; + if (!(hexFile instanceof TFile)) { + // Note creation failed — revert pending entry + this.pendingFactionLinks.get(hexPath)?.delete(factionBasename); + return; + } } - const target = this.app.metadataCache.fileToLinktext(factionFile, hexPath); - const linkText = `[[${target}]]`; - const existing = await getLinksInSection(this.app, hexPath, "Factions"); - if (existing.includes(target)) { - new Notice(`Already linked on ${x},${y}`); - return; - } - - await addLinkToSection(this.app, hexPath, "Factions", linkText); - const hexEl = this.viewportEl?.querySelector( `[data-x="${x}"][data-y="${y}"]`, ); - if (hexEl) { - hexEl.addClass("duckmage-hex-exists"); - const blip = hexEl.createSpan({ cls: "duckmage-hex-blip" }); - blip.addEventListener("animationend", () => blip.remove(), { - once: true, - }); - } + if (hexEl) hexEl.addClass("duckmage-hex-exists"); + + // Write the link in the background — addLinkToSection deduplicates internally + const target = this.app.metadataCache.fileToLinktext(factionFile, hexPath); + await addLinkToSection(this.app, hexPath, "Factions", `[[${target}]]`); + } + + private async onHexFactionEraseClick(x: number, y: number): Promise { + if (!this.paintFactionPath) return; + const factionFile = this.app.vault.getAbstractFileByPath(this.paintFactionPath); + if (!(factionFile instanceof TFile)) return; + + const factionBasename = factionFile.basename; + const hexPath = this.plugin.hexPath(x, y, this.activeMapName); + + if (!this.getFactionLinksFromCache(hexPath).includes(factionBasename)) return; + + // Update overlay immediately (synchronous): remove from pending, add to erased + this.pendingFactionLinks.get(hexPath)?.delete(factionBasename); + const erased = this.erasedFactionLinks.get(hexPath) ?? new Set(); + erased.add(factionBasename); + this.erasedFactionLinks.set(hexPath, erased); + if (this.getActiveMap().showFactionOverlay) this.updateFactionOverlay(); + + // Remove from file in the background + const target = this.app.metadataCache.fileToLinktext(factionFile, hexPath); + await removeLinkFromSection(this.app, hexPath, "Factions", target); } // ── Per-hex coalescing write queues ──────────────────────────────────────── @@ -2209,4 +2254,165 @@ export class HexMapView extends ItemView { } this.renderPathOverlay(gridContainer); } + + private updateFactionOverlay(): void { + const gridContainer = this.viewportEl?.querySelector( + ".duckmage-hex-map-grid", + ); + if (gridContainer) this.renderFactionOverlay(gridContainer); + } + + private clearFactionOverlay(): void { + this.viewportEl?.querySelector("svg.duckmage-faction-svg")?.remove(); + } + + private renderFactionOverlay(gridContainer: HTMLElement): void { + this.viewportEl?.querySelector("svg.duckmage-faction-svg")?.remove(); + if (!this.getActiveMap().showFactionOverlay) return; + + // Gather hex centers (same pattern as renderPathOverlay) + const hexEls = Array.from( + gridContainer.querySelectorAll(".duckmage-hex"), + ); + if (hexEls.length === 0) return; + + let hexW = 0, hexH = 0; + let gapPx = 0; + const centerMap = new Map(); + for (const hexEl of hexEls) { + if (hexW === 0) { + hexW = hexEl.offsetWidth; + hexH = hexEl.offsetHeight; + // Read the actual computed margin so polygons expand to cover the gap + gapPx = parseFloat(window.getComputedStyle(hexEl).marginTop) || 0; + } + let ox = hexEl.offsetWidth / 2, oy = hexEl.offsetHeight / 2; + let cur: HTMLElement | null = hexEl; + while (cur && cur !== this.viewportEl) { + ox += cur.offsetLeft; + oy += cur.offsetTop; + cur = cur.offsetParent as HTMLElement | null; + } + centerMap.set(`${hexEl.dataset.x}_${hexEl.dataset.y}`, { cx: ox, cy: oy }); + } + + const svgNS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(svgNS, "svg"); + svg.classList.add("duckmage-faction-svg"); + const w = gridContainer.offsetLeft + gridContainer.offsetWidth + 20; + const h = gridContainer.offsetTop + gridContainer.offsetHeight + 20; + svg.setAttribute("width", String(w)); + svg.setAttribute("height", String(h)); + + const isFlat = this.plugin.settings.hexOrientation === "flat"; + const W = hexW, H = hexH; + // Circumradius: for flat-top = W/2, for pointy-top = H/2 + const r = isFlat ? W / 2 : H / 2; + // Expand polygons by gapPx so adjacent same-faction hexes merge seamlessly + const scale = r > 0 ? (r + gapPx) / r : 1; + + const hexPolygonPoints = (cx: number, cy: number): string => { + const pts = isFlat + ? [ [-W/4, -H/2], [W/4, -H/2], [W/2, 0], [W/4, H/2], [-W/4, H/2], [-W/2, 0] ] + : [ [0, -H/2], [W/2, -H/4], [W/2, H/4], [0, H/2], [-W/2, H/4], [-W/2, -H/4] ]; + return pts + .map(([dx, dy]) => `${cx + dx * scale},${cy + dy * scale}`) + .join(" "); + }; + + // Build faction color map from all faction notes (metadata cache — no file reads) + const folder = normalizeFolder(this.plugin.settings.factionsFolder); + const factionColorMap = new Map(); + for (const f of this.app.vault.getMarkdownFiles()) { + if (folder && !f.path.startsWith(folder + "/")) continue; + const color = getFactionColorFromFile(this.app, f.path); + if (color) factionColorMap.set(f.basename, color); + } + + // Group polygons by faction so group-level opacity composites them together — + // overlapping expanded polygons from adjacent hexes don't double-darken. + const factionGroups = new Map(); + const getFactionGroup = (color: string): SVGGElement => { + if (factionGroups.has(color)) return factionGroups.get(color)!; + const g = document.createElementNS(svgNS, "g") as SVGGElement; + g.setAttribute("opacity", "0.45"); + svg.appendChild(g); + factionGroups.set(color, g); + return g; + }; + + let hasPolygons = false; + for (const hexEl of hexEls) { + const x = hexEl.dataset.x!; + const y = hexEl.dataset.y!; + const hexPath = this.plugin.hexPath(Number(x), Number(y), this.activeMapName); + const factionLinks = this.getFactionLinksFromCache(hexPath); + const pos = centerMap.get(`${x}_${y}`); + if (!pos) continue; + + for (const link of factionLinks) { + const color = factionColorMap.get(link); + if (!color) continue; + const g = getFactionGroup(color); + const polygon = document.createElementNS(svgNS, "polygon"); + polygon.setAttribute("points", hexPolygonPoints(pos.cx, pos.cy)); + polygon.setAttribute("fill", color); + g.appendChild(polygon); + hasPolygons = true; + } + } + + if (!hasPolygons) return; + + // Insert before path SVG so roads/labels render on top + const pathSvg = this.viewportEl?.querySelector("svg.duckmage-path-svg"); + if (pathSvg && this.viewportEl) { + this.viewportEl.insertBefore(svg, pathSvg); + } else if (this.viewportEl) { + this.viewportEl.appendChild(svg); + } + } + + private getFactionLinksFromCache(hexFilePath: string): string[] { + const file = this.app.vault.getAbstractFileByPath(hexFilePath); + const fromCache: string[] = []; + + if (file instanceof TFile) { + const cache = this.app.metadataCache.getFileCache(file); + if (cache) { + const headings = cache.headings ?? []; + const factionHeading = headings.find( + (h) => h.heading === "Factions" && h.level === 3, + ); + if (factionHeading) { + const factionStart = factionHeading.position.start.offset; + const nextHeading = headings.find( + (h) => h.position.start.offset > factionStart && h.level <= 3, + ); + const factionEnd = nextHeading?.position.start.offset ?? Infinity; + fromCache.push( + ...(cache.links ?? []) + .filter( + (lk) => + lk.position.start.offset > factionStart && + lk.position.start.offset < factionEnd, + ) + .map((lk) => { + // Normalize to basename — fileToLinktext may return a path when names clash + const raw = lk.link.split("|")[0].split("#")[0].trim(); + return raw.split("/").pop() ?? raw; + }), + ); + } + } + } + + const pending = this.pendingFactionLinks.get(hexFilePath); + const erased = this.erasedFactionLinks.get(hexFilePath); + + const merged = new Set(fromCache); + if (pending) for (const name of pending) merged.add(name); + if (erased) for (const name of erased) merged.delete(name); + return [...merged]; + } } diff --git a/src/hex-map/HexSidePanel.ts b/src/hex-map/HexSidePanel.ts index f3f693b..62c5e88 100644 --- a/src/hex-map/HexSidePanel.ts +++ b/src/hex-map/HexSidePanel.ts @@ -96,18 +96,22 @@ export class OverlayPanel extends HexSidePanel { private plugin: HexmakerPlugin; private getViewportEl: () => HTMLElement | null; private getActiveMap: () => MapData; + private onFactionOverlayChange: (show: boolean) => void; private checkboxes = new Map(); + private factionOverlayCb: HTMLInputElement | null = null; constructor( container: HTMLElement, plugin: HexmakerPlugin, getViewportEl: () => HTMLElement | null, getActiveMap: () => MapData, + onFactionOverlayChange: (show: boolean) => void, ) { super(container, "layers", 44, "Map overlays"); this.plugin = plugin; this.getViewportEl = getViewportEl; this.getActiveMap = getActiveMap; + this.onFactionOverlayChange = onFactionOverlayChange; this.buildPanel(this.panelEl); } @@ -136,19 +140,51 @@ export class OverlayPanel extends HexSidePanel { apply(); }); } + + // Faction overlay — triggers a re-render rather than a CSS class toggle + const factionRow = panel.createDiv({ cls: "duckmage-overlay-row" }); + const factionCb = document.createElement("input"); + factionCb.type = "checkbox"; + factionCb.checked = false; // default — refreshed in syncToRegion() + factionRow.appendChild(factionCb); + this.factionOverlayCb = factionCb; + + const factionLabel = factionRow.createSpan({ + text: "Faction overlay", + cls: "duckmage-overlay-label", + }); + + const applyFaction = () => { + const map = this.getActiveMap(); + map.showFactionOverlay = factionCb.checked; + void this.plugin.saveSettings(); + this.onFactionOverlayChange(factionCb.checked); + }; + + factionCb.addEventListener("change", applyFaction); + factionLabel.addEventListener("click", () => { + factionCb.checked = !factionCb.checked; + applyFaction(); + }); } - /** Read the current region's saved state and apply it to the viewport + checkboxes. */ + /** Read the current map's saved state and apply it to the viewport + checkboxes. */ syncToRegion(): void { - const region = this.getActiveMap(); + const map = this.getActiveMap(); for (const opt of OVERLAY_OPTIONS) { // undefined → true (backwards compat) - const value = region[opt.key]; + const value = map[opt.key]; const show = value === undefined ? true : Boolean(value); const cb = this.checkboxes.get(opt.key); if (cb) cb.checked = show; this.applyClass(opt, show); } + // Faction overlay — undefined → false (opt-in) + if (this.factionOverlayCb) { + const show = map.showFactionOverlay ?? false; + this.factionOverlayCb.checked = show; + this.onFactionOverlayChange(show); + } } private applyClass(opt: OverlayOption, show: boolean): void { diff --git a/src/hex-map/help.md b/src/hex-map/help.md index 29916b9..75d248a 100644 --- a/src/hex-map/help.md +++ b/src/hex-map/help.md @@ -30,7 +30,7 @@ Opens the icon palette. Select an icon to enter paint mode, then left-click hexe ### Link table Opens a folder-tree picker scoped to your Tables folder. Select a random-encounter table, then left-click hexes to link that table into each hex's **Encounters Table** section. -### Link faction +### Factions Opens a folder-tree picker scoped to your Factions folder. Select a faction note, then left-click hexes to link it into each hex's **Factions** section. ### Swap diff --git a/src/types.ts b/src/types.ts index d850c35..4baf609 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,7 @@ export interface MapData { showCoords?: boolean; // undefined = true (backwards-compatible) showTerrainIcons?: boolean; // undefined = true showIconOverrides?: boolean; // undefined = true + showFactionOverlay?: boolean; // undefined = false (opt-in) } export interface TerrainPalette { diff --git a/styles.css b/styles.css index 7cacfba..5259726 100644 --- a/styles.css +++ b/styles.css @@ -143,6 +143,15 @@ z-index: 5; } +/* SVG overlay for faction color fills — sits below paths */ +.duckmage-faction-svg { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 4; +} + /* ── Pointy-top row layout ────────────────────────────────────────────────── */ .duckmage-hex-row { @@ -3196,3 +3205,149 @@ tr:hover .duckmage-rt-entry-copy-btn:hover { flex-shrink: 0; border: 1px solid var(--background-modifier-border); } + +/* ── Faction picker modal ──────────────────────────────────────────────────── */ + +.duckmage-faction-picker { + min-width: 320px; +} + +.duckmage-faction-picker-empty { + color: var(--text-muted); + font-style: italic; + padding: 8px 0; +} + +/* Hidden native color picker — triggered via .click() */ +.duckmage-faction-color-input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +/* Palette grid */ +.duckmage-faction-palette { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); + gap: 6px; + padding: 4px 0; +} + +.duckmage-faction-tile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 6px 4px 4px; + border-radius: 6px; + cursor: pointer; + border: 2px solid transparent; + transition: border-color 0.1s, background 0.1s; +} + +.duckmage-faction-tile:hover { + background: var(--background-modifier-hover); + border-color: var(--color-accent); +} + +.duckmage-faction-tile-preview { + width: 48px; + height: 48px; + border-radius: 4px; + border: 1px solid var(--background-modifier-border); +} + +/* Empty / no-color state: diagonal stripe */ +.duckmage-faction-tile-preview-empty { + background: + linear-gradient( + to bottom right, + transparent calc(50% - 1px), + var(--background-modifier-border) calc(50% - 1px), + var(--background-modifier-border) calc(50% + 1px), + transparent calc(50% + 1px) + ), + var(--background-secondary); +} + +.duckmage-faction-tile-name { + font-size: 0.75em; + text-align: center; + word-break: break-word; + max-width: 100%; + color: var(--text-normal); + line-height: 1.2; +} + +/* Edit button — top-right corner, appears on hover */ +.duckmage-faction-tile-edit { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + padding: 0; + font-size: 0.7em; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.1s; + z-index: 1; +} + +.duckmage-faction-tile:hover .duckmage-faction-tile-edit { + opacity: 1; +} + +/* Faction editor sub-modal */ +.duckmage-faction-editor-header { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} + +.duckmage-faction-editor-open-btn { + font-size: 0.85em; + padding: 2px 8px; + cursor: pointer; +} + +.duckmage-faction-editor-desc { + margin-bottom: 12px; + padding: 8px 10px; + background: var(--background-secondary); + border-radius: 4px; +} + +.duckmage-faction-editor-desc-label { + display: block; + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 4px; +} + +.duckmage-faction-editor-desc-text { + margin: 0; + font-size: 0.88em; + line-height: 1.5; + color: var(--text-normal); + white-space: pre-wrap; +} + +.duckmage-faction-editor-btns { + display: flex; + gap: 8px; + margin-top: 12px; + justify-content: flex-end; +}