sbuffkin_hexmaker/src/frontmatter.ts
isaprettycoolguy@protonmail.com 8926f60eb7 feat: multi-GM-icon support per hex (closes hexmaker#28)
A hex can now carry multiple GM icons. The HexEditorModal picker uses
left-click to add and right-click to remove individual icons, with a
"×N" badge on each tile when an icon is stacked. On the map, the icons
lay out in a hex-subgrid arrangement (reusing the same
tokenGroupOffsets helper the token layer uses, so multi-icon layout
stays visually consistent across the two systems).

Storage:
- New frontmatter key `gm-icons: [name, name, ...]` (array, duplicates
  preserved for count).
- Legacy `gm-icon: name` (singular) still READ for back-compat —
  existing notes Just Work. Writers always emit the new array key AND
  clean up the legacy singular at the same time, so a note touched by
  the new model standardises on the new shape.

Helpers (src/frontmatter.ts):
- New `getGmIconsFromFile(app, path): string[]` — authoritative reader.
- New `setGmIconsInFile(app, path, icons: string[])` — list writer.
- `getGmIconFromFile` / `setGmIconInFile` kept as thin compat wrappers
  on top of the list helpers so the on-map painter and any future
  caller that wants single-set semantics still has them.

HexEditorModal:
- `renderGmIconCountGrid` replaces the prior single-pick grid for GM
  icons. Left-click increments, right-click decrements (clamped at 0),
  the "— clear all —" tile wipes the list. Each tile shows ✓ for 1 and
  ×N for 2+. Writes the full list to frontmatter via `setGmIconsInFile`.
- Authoritative read via `getGmIconsFromFile`, keeping `directGmIcon`
  populated as the first entry for the rest of the modal code that
  still uses the singular field.

HexMapView:
- `data-gm-icon` (single) becomes `data-gm-icons` (JSON-encoded array)
  on each hex element. New `parseGmIconsDataset` helper decodes it.
- `renderPathOverlay` GM-icon section: iterates unique icons in
  first-seen order; positions each via `tokenGroupOffsets(n)` so they
  cluster nicely in a sub-hex pattern. Icon size shrinks with slot
  count (38%→22%) to keep them inside the hex. "×N" SVG text badge in
  the lower-right when count > 1.
- Painter (paintIconGmOnly mode) still SETs to a single-icon list —
  same overwriting behavior as before; multi-add is editor-only.
  Painter undo records the FIRST prior icon (acceptable fidelity loss
  for the painter's single-set semantics).

Styles:
- New `.duckmage-svg-gm-icon-count` for the on-map ×N badge (white
  fill, stroked black for legibility over any terrain).
- New `.duckmage-icon-count-badge` for the picker tile badge.

Validation: 351 unit tests + 18 e2e pass. Manual UX test pending.
2026-06-21 10:27:12 -04:00

462 lines
15 KiB
TypeScript

import { App, TFile } from "obsidian";
import type { TokenEntry, TokenShape, TokenSize } from "./types";
import {
type OverlayPatternKey,
DEFAULT_OVERLAY_PATTERN_KEY,
DEFAULT_OVERLAY_PATTERN_SCALE,
DEFAULT_OVERLAY_PATTERN_OPACITY,
DEFAULT_OVERLAY_OUTLINE_WIDTH,
normalizeOverlayPatternKey,
normalizeOverlayPatternScale,
normalizeOverlayPatternOpacity,
normalizeOverlayOutlineWidth,
} from "./overlayPatterns";
export interface OverlayStyle {
pattern: OverlayPatternKey;
scale: number;
opacity: number;
outlineWidth: number;
}
export const DEFAULT_OVERLAY_STYLE: OverlayStyle = {
pattern: DEFAULT_OVERLAY_PATTERN_KEY,
scale: DEFAULT_OVERLAY_PATTERN_SCALE,
opacity: DEFAULT_OVERLAY_PATTERN_OPACITY,
outlineWidth: DEFAULT_OVERLAY_OUTLINE_WIDTH,
};
export interface Frontmatter {
[key: string]: string | string[] | boolean | undefined;
terrain?: string;
icon?: string;
"gm-icon"?: string;
"gm-icons"?: string[];
tags?: string[];
aliases?: string[];
cssclass?: string;
publish?: boolean;
linkedFolder?: string;
"roll-filter"?: boolean;
"encounter-filter"?: boolean;
"faction-color"?: string;
"faction-pattern"?: string;
"faction-pattern-scale"?: string;
"faction-pattern-opacity"?: string;
"faction-outline-width"?: string;
"region-color"?: string;
"region-pattern"?: string;
"region-pattern-scale"?: string;
"region-pattern-opacity"?: string;
"region-outline-width"?: string;
}
export function getFrontMatter(app: App, path: string) {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return null;
const cache = app.metadataCache.getFileCache(file);
const frontmatter = cache?.frontmatter as Frontmatter;
return frontmatter;
}
export function getTerrainFromFile(app: App, path: string): string | null {
const terrain = getFrontMatter(app, path)?.terrain;
return typeof terrain === "string" ? terrain : null;
}
export async function setTerrainInFile(
app: App,
path: string,
terrainKey: string | null,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
if (terrainKey === null) {
delete fm["terrain"];
} else {
fm["terrain"] = terrainKey;
}
});
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 getFactionStyleFromFile(app: App, path: string): OverlayStyle {
const fm = getFrontMatter(app, path);
return {
pattern: normalizeOverlayPatternKey(fm?.["faction-pattern"]),
scale: normalizeOverlayPatternScale(fm?.["faction-pattern-scale"]),
opacity: normalizeOverlayPatternOpacity(fm?.["faction-pattern-opacity"]),
outlineWidth: normalizeOverlayOutlineWidth(fm?.["faction-outline-width"]),
};
}
export async function setFactionStyleInFile(
app: App,
path: string,
style: Partial<OverlayStyle>,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
if (style.pattern !== undefined) {
if (style.pattern === DEFAULT_OVERLAY_PATTERN_KEY) delete fm["faction-pattern"];
else fm["faction-pattern"] = style.pattern;
}
if (style.scale !== undefined) {
if (style.scale === DEFAULT_OVERLAY_PATTERN_SCALE) delete fm["faction-pattern-scale"];
else fm["faction-pattern-scale"] = String(style.scale);
}
if (style.opacity !== undefined) {
if (style.opacity === DEFAULT_OVERLAY_PATTERN_OPACITY) delete fm["faction-pattern-opacity"];
else fm["faction-pattern-opacity"] = String(style.opacity);
}
if (style.outlineWidth !== undefined) {
if (style.outlineWidth === DEFAULT_OVERLAY_OUTLINE_WIDTH) delete fm["faction-outline-width"];
else fm["faction-outline-width"] = String(style.outlineWidth);
}
});
return true;
}
export function getHexRegionFromFile(app: App, path: string): string | null {
const region = getFrontMatter(app, path)?.["region"];
return typeof region === "string" ? region : null;
}
export async function setHexRegionInFile(
app: App,
path: string,
regionName: string | null,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
if (regionName === null) {
delete fm["region"];
} else {
fm["region"] = regionName;
}
});
return true;
}
export function getRegionColorFromFile(app: App, path: string): string | null {
const color = getFrontMatter(app, path)?.["region-color"];
return typeof color === "string" ? color : null;
}
export async function setRegionColorInFile(
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["region-color"];
} else {
fm["region-color"] = color;
}
});
return true;
}
export function getRegionStyleFromFile(app: App, path: string): OverlayStyle {
const fm = getFrontMatter(app, path);
return {
pattern: normalizeOverlayPatternKey(fm?.["region-pattern"]),
scale: normalizeOverlayPatternScale(fm?.["region-pattern-scale"]),
opacity: normalizeOverlayPatternOpacity(fm?.["region-pattern-opacity"]),
outlineWidth: normalizeOverlayOutlineWidth(fm?.["region-outline-width"]),
};
}
export async function setRegionStyleInFile(
app: App,
path: string,
style: Partial<OverlayStyle>,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
if (style.pattern !== undefined) {
if (style.pattern === DEFAULT_OVERLAY_PATTERN_KEY) delete fm["region-pattern"];
else fm["region-pattern"] = style.pattern;
}
if (style.scale !== undefined) {
if (style.scale === DEFAULT_OVERLAY_PATTERN_SCALE) delete fm["region-pattern-scale"];
else fm["region-pattern-scale"] = String(style.scale);
}
if (style.opacity !== undefined) {
if (style.opacity === DEFAULT_OVERLAY_PATTERN_OPACITY) delete fm["region-pattern-opacity"];
else fm["region-pattern-opacity"] = String(style.opacity);
}
if (style.outlineWidth !== undefined) {
if (style.outlineWidth === DEFAULT_OVERLAY_OUTLINE_WIDTH) delete fm["region-outline-width"];
else fm["region-outline-width"] = String(style.outlineWidth);
}
});
return true;
}
export function getIconOverrideFromFile(app: App, path: string): string | null {
const icon = getFrontMatter(app, path)?.icon;
return typeof icon === "string" ? icon : null;
}
export async function setIconOverrideInFile(
app: App,
path: string,
icon: string | null,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
if (icon === null) {
delete fm["icon"];
} else {
fm["icon"] = icon;
}
});
return true;
}
/**
* Legacy single-icon getter — returns the first GM icon if any.
* Kept for back-compat with code that hasn't migrated to the multi-icon
* model yet (e.g. on-map painter, which is still single-set). Prefer
* `getGmIconsFromFile` for any new read site.
*/
export function getGmIconFromFile(app: App, path: string): string | null {
return getGmIconsFromFile(app, path)[0] ?? null;
}
/**
* Multi-icon getter — returns the list of GM icons, with duplicates
* preserved (a user can stack multiple of the same icon for a count).
*
* Reads the new array key `gm-icons`. Falls back to the legacy
* singular key `gm-icon` if present so existing notes Just Work.
*/
export function getGmIconsFromFile(app: App, path: string): string[] {
const fm = getFrontMatter(app, path);
if (!fm) return [];
const arr = fm["gm-icons"];
if (Array.isArray(arr)) {
return arr.filter((v): v is string => typeof v === "string");
}
const legacy = fm["gm-icon"];
return typeof legacy === "string" ? [legacy] : [];
}
/**
* Legacy single-icon writer. Replaces the entire GM icon list with
* `[icon]` (or clears it when `icon === null`). Used by the on-map
* paint tool, which is still single-set. The editor uses
* `setGmIconsInFile` to manage the multi-icon list directly.
*/
export async function setGmIconInFile(
app: App,
path: string,
icon: string | null,
): Promise<boolean> {
return setGmIconsInFile(app, path, icon === null ? [] : [icon]);
}
/**
* Multi-icon writer. Replaces the entire `gm-icons` list with the
* given array (which may have duplicates for count). Always clears the
* legacy singular `gm-icon` key when writing — once a note has been
* touched by the new model, we standardise on the new key.
*/
export async function setGmIconsInFile(
app: App,
path: string,
icons: string[],
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
// Always clean up the legacy key
delete fm["gm-icon"];
if (icons.length === 0) {
delete fm["gm-icons"];
} else {
fm["gm-icons"] = icons;
}
});
return true;
}
// ── Token helpers ─────────────────────────────────────────────────────────────
const VALID_SHAPES: TokenShape[] = ["circle", "square", "hexagon"];
const VALID_SIZES: TokenSize[] = ["sm", "md", "lg"];
export function getTokenDataFromCache(app: App, file: TFile): TokenEntry | null {
const cache = app.metadataCache.getFileCache(file);
const fm = cache?.frontmatter as Frontmatter | undefined;
if (!fm?.["token"]) return null;
const rawShape = fm["token-shape"] as string | undefined;
const rawSize = fm["token-size"] as string | undefined;
const tokenLink = typeof fm["token-link"] === "string" ? fm["token-link"] : undefined;
// Resolve display title from the linked note if this is a proxy
let title = file.basename;
if (tokenLink) {
const linked = app.vault.getAbstractFileByPath(tokenLink);
if (linked instanceof TFile) title = linked.basename;
}
return {
filePath: file.path,
title,
tokenLink,
icon: typeof fm["token-icon"] === "string" ? fm["token-icon"] : undefined,
hex: typeof fm["token-hex"] === "string" ? fm["token-hex"] : "",
map: typeof fm["token-map"] === "string" ? fm["token-map"] : "",
visible: fm["token-visible"] !== false,
shape: VALID_SHAPES.includes(rawShape as TokenShape)
? (rawShape as TokenShape)
: "circle",
size: VALID_SIZES.includes(rawSize as TokenSize)
? (rawSize as TokenSize)
: "md",
color: typeof fm["token-color"] === "string" ? fm["token-color"] : undefined,
border: typeof fm["token-border"] === "string" ? fm["token-border"] : undefined,
description: typeof fm["token-description"] === "string" ? fm["token-description"] : undefined,
};
}
export async function setTokenHex(
app: App,
path: string,
hex: string,
map: string,
): Promise<void> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
fm["token-hex"] = hex;
fm["token-map"] = map;
});
}
export async function setTokenVisible(
app: App,
path: string,
visible: boolean,
): Promise<void> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
fm["token-visible"] = visible;
});
}
export async function removeTokenFrontmatter(
app: App,
path: string,
): Promise<void> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
delete fm["token"];
delete fm["token-icon"];
delete fm["token-hex"];
delete fm["token-map"];
delete fm["token-visible"];
delete fm["token-shape"];
delete fm["token-size"];
delete fm["token-color"];
delete fm["token-border"];
delete fm["token-link"];
delete fm["token-description"];
});
}
export async function applyTokenFrontmatter(
app: App,
path: string,
data: {
icon?: string;
hex?: string;
map?: string;
visible?: boolean;
shape?: TokenShape;
size?: TokenSize;
color?: string;
border?: string;
tokenLink?: string;
description?: string;
},
): Promise<void> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
fm["token"] = true;
if (data.icon !== undefined) {
if (data.icon) fm["token-icon"] = data.icon;
else delete fm["token-icon"];
}
if (data.hex !== undefined) fm["token-hex"] = data.hex;
if (data.map !== undefined) fm["token-map"] = data.map;
if (data.visible !== undefined) fm["token-visible"] = data.visible;
if (data.shape !== undefined) fm["token-shape"] = data.shape;
if (data.size !== undefined) fm["token-size"] = data.size;
if (data.color !== undefined) {
if (data.color) fm["token-color"] = data.color; else delete fm["token-color"];
}
if (data.border !== undefined) {
if (data.border) fm["token-border"] = data.border; else delete fm["token-border"];
}
if (data.tokenLink !== undefined) fm["token-link"] = data.tokenLink;
if (data.description !== undefined) {
if (data.description) fm["token-description"] = data.description;
else delete fm["token-description"];
}
});
}
export function getSubmapFromFile(app: App, path: string): string | undefined {
const val = getFrontMatter(app, path)?.["duckmage-submap"];
return typeof val === "string" ? val : undefined;
}
export async function setSubmapInFile(
app: App,
path: string,
mapName: string | null,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
if (mapName === null) delete fm["duckmage-submap"];
else fm["duckmage-submap"] = mapName;
});
return true;
}