mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 14:30:24 +00:00
- Right-click hex → "Link submap" writes duckmage-submap frontmatter - Hex flower shows centre dot (hidden when no link) that navigates into the submap - Centre dot centering is orientation-aware (flat-top top:24px, pointy-top top:20px) - ← Back button appears next to map dropdown (via flex nav group) after submap drill-in - "Insert map link" editor context menu inserts obsidian://duckmage-openmap URI - Map rename updates all duckmage-submap references via updateSubmapReferences() - HexEditorOptions refactored: onNavigate/onModalClose/onSwitchMap in options object - 10 new tests for submap frontmatter helpers and rename propagation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
993 lines
32 KiB
TypeScript
993 lines
32 KiB
TypeScript
import { App, Notice, TFile } from "obsidian";
|
||
import { HexmakerModal } from "../HexmakerModal";
|
||
import type HexmakerPlugin from "../HexmakerPlugin";
|
||
import {
|
||
getIconUrl,
|
||
normalizeFolder,
|
||
makeTableTemplate,
|
||
createIconEl,
|
||
} from "../utils";
|
||
import {
|
||
getTerrainFromFile,
|
||
setTerrainInFile,
|
||
setIconOverrideInFile,
|
||
setGmIconInFile,
|
||
getSubmapFromFile,
|
||
} from "../frontmatter";
|
||
import {
|
||
addLinkToSection,
|
||
removeLinkFromSection,
|
||
getLinksInSection,
|
||
getAllSectionData,
|
||
setSectionContent,
|
||
addBacklinkToFile,
|
||
} from "../sections";
|
||
import { FileLinkSuggestModal } from "./FileLinkSuggestModal";
|
||
import { TEXT_SECTIONS } from "../types";
|
||
import type { LinkSection, HexEditorOptions } from "../types";
|
||
import { RandomTableModal } from "../random-tables/RandomTableModal";
|
||
import { VIEW_TYPE_HEX_MAP, VIEW_TYPE_RANDOM_TABLES } from "../constants";
|
||
|
||
export class HexEditorModal extends HexmakerModal {
|
||
private hexExists = false;
|
||
private allText = new Map<string, string>();
|
||
private allLinks = new Map<string, string[]>();
|
||
private directTerrain: string | null = null;
|
||
private directIcon: string | null = null;
|
||
private directGmIcon: string | null = null;
|
||
private dataPreloaded = false;
|
||
|
||
constructor(
|
||
app: App,
|
||
private plugin: HexmakerPlugin,
|
||
private x: number,
|
||
private y: number,
|
||
private mapName: string,
|
||
private onChanged: (
|
||
terrainOverrides?: Map<string, string | null>,
|
||
iconOverrides?: Map<string, string | null>,
|
||
) => void,
|
||
private options: HexEditorOptions = {},
|
||
) {
|
||
super(app);
|
||
}
|
||
|
||
async loadData(): Promise<void> {
|
||
// Reset all fields so stale data from a previous hex never bleeds through
|
||
this.hexExists = false;
|
||
this.allText = new Map();
|
||
this.allLinks = new Map();
|
||
this.directTerrain = null;
|
||
this.directIcon = null;
|
||
this.directGmIcon = null;
|
||
|
||
const path = this.plugin.hexPath(this.x, this.y, this.mapName);
|
||
const file = this.app.vault.getAbstractFileByPath(path);
|
||
if (!(file instanceof TFile)) return;
|
||
this.hexExists = true;
|
||
|
||
// Single read — reused for both frontmatter and section parsing
|
||
const rawContent = await this.app.vault.read(file);
|
||
|
||
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();
|
||
const gm = fmMatch[1].match(/^\s*gm-icon:\s*(.+)$/m);
|
||
if (gm) this.directGmIcon = gm[1].trim();
|
||
}
|
||
|
||
({ text: this.allText, links: this.allLinks } = await getAllSectionData(
|
||
this.app,
|
||
path,
|
||
rawContent,
|
||
));
|
||
}
|
||
|
||
onOpen() {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
contentEl.addClass("duckmage-hex-editor");
|
||
|
||
const path = this.plugin.hexPath(this.x, this.y, this.mapName);
|
||
|
||
// ── Static header — rendered immediately, no data needed ─────────────
|
||
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);
|
||
interface WithCenterOnHex {
|
||
centerOnHex?(x: number, y: number): void;
|
||
}
|
||
if (leaves.length > 0)
|
||
(leaves[0].view as unknown as WithCenterOnHex).centerOnHex?.(
|
||
this.x,
|
||
this.y,
|
||
);
|
||
});
|
||
|
||
// "Open note" can be determined synchronously from the vault index
|
||
const fileNow = this.app.vault.getAbstractFileByPath(path);
|
||
if (fileNow instanceof TFile) {
|
||
const openLink = titleLeft.createEl("a", {
|
||
text: "Open note",
|
||
cls: "duckmage-editor-open-link",
|
||
});
|
||
openLink.addEventListener("click", () => {
|
||
void this.app.workspace.getLeaf("tab").openFile(fileNow);
|
||
this.close();
|
||
});
|
||
}
|
||
this.renderNeighborWidget(titleRow, this.x, this.y);
|
||
|
||
this.makeDraggable();
|
||
|
||
// ── Body — populated after the single async read ──────────────────────
|
||
const bodyEl = contentEl.createDiv({ cls: "duckmage-editor-body" });
|
||
|
||
if (this.dataPreloaded) {
|
||
// Data was fetched before onOpen was called (navigation); render immediately
|
||
// so there is no intermediate "Loading…" frame visible to the user.
|
||
this.renderBody(bodyEl, path);
|
||
} else {
|
||
bodyEl.createSpan({ text: "Loading…", cls: "duckmage-editor-loading" });
|
||
void this.loadData().then(() => {
|
||
bodyEl.empty();
|
||
this.renderBody(bodyEl, path);
|
||
});
|
||
}
|
||
}
|
||
|
||
private renderBody(bodyEl: HTMLElement, path: string): void {
|
||
const { hexExists, allText, allLinks, directTerrain, directIcon } = this;
|
||
const s = this.plugin.settings;
|
||
|
||
const { body: terrainBody, header: terrainHeader } = this.makeCollapsible(
|
||
bodyEl,
|
||
"Terrain",
|
||
s.hexEditorTerrainCollapsed ?? false,
|
||
);
|
||
const paletteEntry = directTerrain
|
||
? this.plugin
|
||
.getMapPalette(this.mapName)
|
||
.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.setCssProps({ "background-color": 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, this.directGmIcon);
|
||
|
||
bodyEl.createEl("hr", { cls: "duckmage-editor-divider" });
|
||
|
||
const { body: notesBody } = this.makeCollapsible(
|
||
bodyEl,
|
||
"Notes",
|
||
!this.options.gmLayerActive && (s.hexEditorNotesCollapsed ?? false),
|
||
);
|
||
for (const { key, label } of TEXT_SECTIONS) {
|
||
if (!this.options.gmLayerActive && (key === "hidden" || key === "secret")) continue;
|
||
this.renderTextSection(
|
||
notesBody,
|
||
path,
|
||
key,
|
||
label,
|
||
allText.get(key) ?? "",
|
||
);
|
||
}
|
||
|
||
bodyEl.createEl("hr", { cls: "duckmage-editor-divider" });
|
||
|
||
const { body: featuresBody } = this.makeCollapsible(
|
||
bodyEl,
|
||
"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") ?? [],
|
||
);
|
||
}
|
||
|
||
onClose() {
|
||
this.options.onModalClose?.();
|
||
this.contentEl.empty();
|
||
}
|
||
|
||
private isOnMap(nx: number, ny: number): boolean {
|
||
const region = this.plugin.getOrCreateMap(this.mapName);
|
||
const { gridOffset, gridSize } = region;
|
||
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 paletteMap = new Map(
|
||
this.plugin.getMapPalette(this.mapName).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: 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.setCssProps({ left: `${l}px`, top: `${t}px` });
|
||
|
||
if (onMap) {
|
||
tile.title = `Hex ${nx}, ${ny}`;
|
||
const nPath = this.plugin.hexPath(nx, ny, this.mapName);
|
||
const terrain = getTerrainFromFile(this.app, nPath);
|
||
const entry = terrain ? paletteMap.get(terrain) : undefined;
|
||
if (entry) tile.setCssProps({ "background-color": entry.color });
|
||
tile.addEventListener("click", () => {
|
||
this.x = nx;
|
||
this.y = ny;
|
||
this.options.onNavigate?.(nx, ny);
|
||
void this.loadData().then(() => {
|
||
this.dataPreloaded = true;
|
||
this.onOpen();
|
||
this.dataPreloaded = false;
|
||
});
|
||
});
|
||
} else {
|
||
tile.title = "Off map";
|
||
}
|
||
}
|
||
|
||
// Centre dot — only shown when a submap is linked
|
||
const hexPath = this.plugin.hexPath(this.x, this.y, this.mapName);
|
||
const submap = getSubmapFromFile(this.app, hexPath);
|
||
if (submap) {
|
||
const centre = widget.createDiv({ cls: "duckmage-neighbor-centre duckmage-neighbor-centre--linked" });
|
||
// Flat-top flower center: (30,30); pointy-top: (30,26). Dot is 12×12.
|
||
const cy = isFlat ? 24 : 20;
|
||
centre.setCssProps({ left: "24px", top: `${cy}px` });
|
||
centre.title = `Submap: ${submap}`;
|
||
centre.addEventListener("click", () => {
|
||
this.close();
|
||
this.options.onSwitchMap?.(submap);
|
||
});
|
||
}
|
||
}
|
||
|
||
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.hide();
|
||
header.addEventListener("click", () => {
|
||
const collapsed = !body.isShown();
|
||
if (collapsed) {
|
||
body.show();
|
||
} else {
|
||
body.hide();
|
||
}
|
||
arrow.textContent = collapsed ? "▼" : "▶";
|
||
});
|
||
return { body, header };
|
||
}
|
||
|
||
private renderTerrainSection(
|
||
container: HTMLElement,
|
||
path: string,
|
||
currentTerrain: string | null,
|
||
currentIcon: string | null,
|
||
currentGmIcon: string | null,
|
||
): void {
|
||
const palette = this.plugin.getMapPalette(this.mapName);
|
||
|
||
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", () => {
|
||
void (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.setCssProps({ "background-color": entry.color });
|
||
|
||
if (entry.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" });
|
||
|
||
btn.addEventListener("click", () => {
|
||
void (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();
|
||
})();
|
||
});
|
||
}
|
||
|
||
// 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<string, string | null> | undefined =
|
||
currentTerrain ? new Map([[path, currentTerrain]]) : undefined;
|
||
|
||
// Icon override palette
|
||
const hidden = new Set(this.plugin.settings.hiddenIcons ?? []);
|
||
const visibleIcons = this.plugin.availableIcons.filter((i) => !hidden.has(i));
|
||
|
||
section.createEl("p", { text: "Icon override", cls: "duckmage-icon-inline-label" });
|
||
this.renderIconGrid(
|
||
section,
|
||
visibleIcons,
|
||
currentIcon,
|
||
"— terrain default —",
|
||
async (picked) => {
|
||
await this.ensureHexNote();
|
||
await setIconOverrideInFile(this.app, path, picked);
|
||
this.onChanged(terrainOverrides, new Map([[path, picked]]));
|
||
},
|
||
);
|
||
|
||
// GM icon palette — only when GM layer is active
|
||
if (this.options.gmLayerActive) {
|
||
section.createEl("p", { text: "GM icon", cls: "duckmage-icon-inline-label" }); // eslint-disable-line obsidianmd/ui/sentence-case
|
||
this.renderIconGrid(
|
||
section,
|
||
visibleIcons,
|
||
currentGmIcon,
|
||
"— none —",
|
||
async (picked) => {
|
||
await this.ensureHexNote();
|
||
await setGmIconInFile(this.app, path, picked);
|
||
this.onChanged();
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
private renderIconGrid(
|
||
container: HTMLElement,
|
||
icons: string[],
|
||
current: string | null,
|
||
noneLabel: string,
|
||
onPick: (icon: string | null) => Promise<void>,
|
||
): void {
|
||
let selected = current;
|
||
const grid = container.createDiv({ cls: "duckmage-icon-picker duckmage-icon-picker-inline" });
|
||
|
||
const makeTile = (icon: string | null) => {
|
||
const label = icon
|
||
? icon.replace(/^bw-/, "").replace(/\.(png|jpg|jpeg|gif|svg|webp)$/i, "").replace(/-/g, " ")
|
||
: noneLabel;
|
||
const tile = grid.createDiv({
|
||
cls: `duckmage-icon-option${selected === icon ? " is-selected" : ""}`,
|
||
});
|
||
const preview = tile.createDiv({
|
||
cls: `duckmage-icon-preview${!icon ? " duckmage-icon-preview-clear" : ""}`,
|
||
});
|
||
if (icon) {
|
||
const img = preview.createEl("img", { cls: "duckmage-icon-preview-img" });
|
||
img.src = getIconUrl(this.plugin, icon);
|
||
img.alt = label;
|
||
}
|
||
tile.createSpan({ text: label, cls: "duckmage-icon-option-name" });
|
||
tile.addEventListener("click", () => {
|
||
void (async () => {
|
||
selected = icon;
|
||
grid.querySelectorAll(".duckmage-icon-option").forEach((el) =>
|
||
el.toggleClass("is-selected", (el as HTMLElement).dataset["icon"] === (icon ?? "")),
|
||
);
|
||
await onPick(icon);
|
||
})();
|
||
});
|
||
tile.dataset["icon"] = icon ?? "";
|
||
return tile;
|
||
};
|
||
|
||
makeTile(null);
|
||
for (const icon of icons) makeTile(icon);
|
||
}
|
||
|
||
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",
|
||
});
|
||
sectionEl.createEl("h4", {
|
||
text: section,
|
||
cls: "duckmage-link-section-title",
|
||
});
|
||
|
||
// ── Combo box ──────────────────────────────────────────────────────────
|
||
const comboWrap = sectionEl.createDiv({ cls: "duckmage-link-combo" });
|
||
const input = comboWrap.createEl("input", {
|
||
type: "text",
|
||
cls: "duckmage-link-combo-input",
|
||
});
|
||
input.placeholder = `Search or create…`;
|
||
|
||
const arrowBtn = comboWrap.createEl("button", {
|
||
text: "▾",
|
||
cls: "duckmage-link-combo-arrow",
|
||
title: "Show all",
|
||
});
|
||
const dropdown = comboWrap.createDiv({
|
||
cls: "duckmage-link-combo-dropdown",
|
||
});
|
||
dropdown.hide();
|
||
|
||
// ── 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 onItemClick =
|
||
section === "Encounters Table"
|
||
? (_link: string, file: TFile) => {
|
||
void (async () => {
|
||
interface WithOpenTable {
|
||
openTable?(path: string): void;
|
||
}
|
||
const leaves = this.app.workspace.getLeavesOfType(
|
||
VIEW_TYPE_RANDOM_TABLES,
|
||
);
|
||
if (leaves.length > 0) {
|
||
void this.app.workspace.revealLeaf(leaves[0]);
|
||
(leaves[0].view as unknown as WithOpenTable).openTable?.(
|
||
file.path,
|
||
);
|
||
} else {
|
||
const leaf = this.app.workspace.getLeaf("tab");
|
||
await leaf.setViewState({ type: VIEW_TYPE_RANDOM_TABLES });
|
||
(leaf.view as unknown as WithOpenTable).openTable?.(file.path);
|
||
}
|
||
this.close();
|
||
})();
|
||
}
|
||
: undefined;
|
||
|
||
const onRollClick =
|
||
section === "Encounters Table"
|
||
? (file: TFile) =>
|
||
new RandomTableModal(
|
||
this.app,
|
||
this.plugin,
|
||
undefined,
|
||
file.path,
|
||
).open()
|
||
: undefined;
|
||
|
||
const onRemove = (link: string) => {
|
||
currentLinks = currentLinks.filter((l) => l !== link);
|
||
refresh();
|
||
void removeLinkFromSection(this.app, path, section, link).then(() =>
|
||
this.onChanged(),
|
||
);
|
||
};
|
||
|
||
const refresh = () => {
|
||
linksEl.empty();
|
||
this.renderLinkList(
|
||
linksEl,
|
||
currentLinks,
|
||
path,
|
||
onRemove,
|
||
onItemClick,
|
||
onRollClick,
|
||
);
|
||
};
|
||
|
||
refresh();
|
||
|
||
// ── Dropdown logic ─────────────────────────────────────────────────────
|
||
let isOpen = false;
|
||
|
||
const getFiltered = (query: string): TFile[] => {
|
||
const files = this.getFilesForDropdown(sourceFolder, filterType);
|
||
if (!query) return files;
|
||
const q = query.toLowerCase();
|
||
return files.filter((f) => f.basename.toLowerCase().includes(q));
|
||
};
|
||
|
||
const populateDropdown = (query: string) => {
|
||
dropdown.empty();
|
||
const trimmed = query.trim();
|
||
const files = getFiltered(trimmed);
|
||
|
||
if (files.length === 0 && !trimmed) {
|
||
dropdown.createDiv({
|
||
cls: "duckmage-link-combo-empty",
|
||
text: "No files in folder",
|
||
});
|
||
}
|
||
|
||
for (const file of files) {
|
||
const item = dropdown.createDiv({ cls: "duckmage-link-combo-item" });
|
||
item.textContent = file.basename;
|
||
item.addEventListener("mousedown", (e) => {
|
||
e.preventDefault();
|
||
void selectFile(file);
|
||
});
|
||
}
|
||
|
||
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",
|
||
});
|
||
createItem.textContent = `+ Create "${trimmed}"`;
|
||
createItem.addEventListener("mousedown", (e) => {
|
||
e.preventDefault();
|
||
void createAndLink(trimmed);
|
||
});
|
||
}
|
||
};
|
||
|
||
const openDropdown = () => {
|
||
isOpen = true;
|
||
populateDropdown(input.value);
|
||
dropdown.show();
|
||
};
|
||
|
||
const closeDropdown = () => {
|
||
isOpen = false;
|
||
dropdown.hide();
|
||
};
|
||
|
||
const selectFile = async (file: TFile) => {
|
||
closeDropdown();
|
||
input.value = "";
|
||
const hexFile = await this.ensureHexNote();
|
||
if (!hexFile) {
|
||
new Notice("Could not create hex note.");
|
||
return;
|
||
}
|
||
const linkPath = this.app.metadataCache.fileToLinktext(file, path);
|
||
currentLinks = [...currentLinks, linkPath];
|
||
refresh();
|
||
void addLinkToSection(this.app, path, section, `[[${linkPath}]]`);
|
||
void addBacklinkToFile(this.app, file.path, path);
|
||
this.onChanged();
|
||
};
|
||
|
||
const createAndLink = async (name: string) => {
|
||
closeDropdown();
|
||
input.value = "";
|
||
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}: ${String(err)}`);
|
||
return;
|
||
}
|
||
}
|
||
if (!(file instanceof TFile)) return;
|
||
const hexFile = await this.ensureHexNote();
|
||
if (!hexFile) {
|
||
new Notice("Could not create hex note.");
|
||
return;
|
||
}
|
||
const linkPath = this.app.metadataCache.fileToLinktext(file, path);
|
||
currentLinks = [...currentLinks, linkPath];
|
||
refresh();
|
||
void addLinkToSection(this.app, path, section, `[[${linkPath}]]`);
|
||
void addBacklinkToFile(this.app, file.path, path);
|
||
this.onChanged();
|
||
};
|
||
|
||
input.addEventListener("focus", () => openDropdown());
|
||
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 === "Enter") {
|
||
const trimmed = input.value.trim();
|
||
if (!trimmed) return;
|
||
const files = getFiltered(trimmed);
|
||
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);
|
||
}
|
||
});
|
||
|
||
arrowBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||
arrowBtn.addEventListener("click", () => {
|
||
if (isOpen) closeDropdown();
|
||
else {
|
||
input.focus();
|
||
openDropdown();
|
||
}
|
||
});
|
||
}
|
||
|
||
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, (file) => {
|
||
void (async () => {
|
||
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 {
|
||
void 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 duckmage-editor-text-section-${section}`,
|
||
});
|
||
const labelRow = sectionEl.createDiv({
|
||
cls: "duckmage-text-section-label-row",
|
||
});
|
||
labelRow.createEl("label", {
|
||
text: label,
|
||
cls: "duckmage-text-section-label",
|
||
});
|
||
|
||
// 📖 button: terrain description table (description section) or section-specific table
|
||
const tablesFolder = normalizeFolder(
|
||
this.plugin.settings.tablesFolder ?? "",
|
||
);
|
||
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`;
|
||
}
|
||
}
|
||
|
||
const textarea = sectionEl.createEl("textarea", {
|
||
cls: "duckmage-text-section-textarea",
|
||
});
|
||
|
||
if (previewTablePath) {
|
||
const btnGroup = labelRow.createDiv({
|
||
cls: "duckmage-text-section-btn-group",
|
||
});
|
||
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,
|
||
(result) => {
|
||
if (textarea.value && !textarea.value.endsWith("\n"))
|
||
textarea.value += "\n";
|
||
textarea.value += result;
|
||
void (async () => {
|
||
const file = await this.ensureHexNote();
|
||
if (!file) return;
|
||
await setSectionContent(this.app, path, section, textarea.value);
|
||
})();
|
||
this.onChanged();
|
||
},
|
||
capturedPath,
|
||
).open();
|
||
});
|
||
}
|
||
textarea.rows = 3;
|
||
textarea.placeholder = `${label}…`;
|
||
textarea.value = initialContent;
|
||
|
||
textarea.addEventListener("blur", () => {
|
||
void (async () => {
|
||
const file = await this.ensureHexNote();
|
||
if (!file) return;
|
||
await setSectionContent(this.app, path, section, textarea.value);
|
||
})();
|
||
});
|
||
}
|
||
|
||
private async ensureHexNote(): Promise<TFile | null> {
|
||
const path = this.plugin.hexPath(this.x, this.y, this.mapName);
|
||
const existing = this.app.vault.getAbstractFileByPath(path);
|
||
if (existing instanceof TFile) return existing;
|
||
return this.plugin.createHexNote(this.x, this.y, this.mapName);
|
||
}
|
||
}
|