mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
add and enhance faction overlay.
This commit is contained in:
parent
efc9d637cf
commit
90d2496fc3
8 changed files with 690 additions and 52 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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;
|
||||
|
|
|
|||
218
src/hex-map/FactionPickerModal.ts
Normal file
218
src/hex-map/FactionPickerModal.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>(); // "t:<path>" or "i:<path>"
|
||||
// Faction links painted but not yet reflected in the metadata cache
|
||||
private pendingFactionLinks = new Map<string, Set<string>>();
|
||||
// Faction links erased but cache may still reflect them — excluded from overlay
|
||||
private erasedFactionLinks = new Map<string, Set<string>>();
|
||||
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<void> {
|
||||
private async onHexFactionPaintClick(x: number, y: number): Promise<void> {
|
||||
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<string>();
|
||||
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<HTMLElement>(
|
||||
`[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<void> {
|
||||
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<string>();
|
||||
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<HTMLElement>(
|
||||
".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<HTMLElement>(".duckmage-hex"),
|
||||
);
|
||||
if (hexEls.length === 0) return;
|
||||
|
||||
let hexW = 0, hexH = 0;
|
||||
let gapPx = 0;
|
||||
const centerMap = new Map<string, { cx: number; cy: number }>();
|
||||
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<string, string>();
|
||||
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<string, SVGGElement>();
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<OverlayKey, HTMLInputElement>();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
155
styles.css
155
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue