set up random table view to enable the ability to generate folders with notes based on the tables. ux fixes

This commit is contained in:
isaprettycoolguy@protonmail.com 2026-03-14 19:51:32 -04:00
parent 4cf513585f
commit c283ffe9c8
7 changed files with 943 additions and 407 deletions

View file

@ -112,15 +112,18 @@ export class HexEditorModal extends Modal {
const paletteEntry = directTerrain
? (this.plugin.settings.terrainPalette ?? []).find(p => p.name === directTerrain)
: undefined;
if (paletteEntry) {
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" });
swatch.style.backgroundColor = paletteEntry.color;
if (paletteEntry.icon) {
if (paletteEntry) swatch.style.backgroundColor = paletteEntry.color;
if (iconToShow) {
const img = swatch.createEl("img");
img.src = getIconUrl(this.plugin, paletteEntry.icon);
img.src = getIconUrl(this.plugin, iconToShow);
}
if (paletteEntry) {
preview.createSpan({ text: paletteEntry.name, cls: "duckmage-terrain-header-name" });
}
preview.createSpan({ text: paletteEntry.name, cls: "duckmage-terrain-header-name" });
}
this.renderTerrainSection(terrainBody, path, directTerrain, directIcon);

View file

@ -60,38 +60,43 @@ class TerrainFilterModal extends Modal {
lbl.toggleClass("duckmage-terrain-filter-excluded", exc);
};
for (const entry of this.palette) {
const addRow = (name: string, label: string, color?: string) => {
const lbl = list.createEl("label", { cls: "duckmage-terrain-filter-row" });
const cb = lbl.createEl("input") as HTMLInputElement;
cb.type = "checkbox";
applyRowState(lbl, cb, entry.name);
applyRowState(lbl, cb, name);
const swatch = lbl.createSpan({ cls: "duckmage-hex-table-swatch" });
swatch.style.backgroundColor = entry.color;
lbl.createSpan({ text: entry.name });
if (color) swatch.style.backgroundColor = color;
lbl.createSpan({ text: label });
cb.addEventListener("change", () => {
if (cb.checked) {
this.selected.add(entry.name);
this.excluded.delete(entry.name);
this.selected.add(name);
this.excluded.delete(name);
} else {
this.selected.delete(entry.name);
this.selected.delete(name);
}
applyRowState(lbl, cb, entry.name);
applyRowState(lbl, cb, name);
this.onChange(new Set(this.selected), new Set(this.excluded));
});
lbl.addEventListener("contextmenu", (e) => {
e.preventDefault();
if (this.excluded.has(entry.name)) {
this.excluded.delete(entry.name);
if (this.excluded.has(name)) {
this.excluded.delete(name);
} else {
this.excluded.add(entry.name);
this.selected.delete(entry.name);
this.excluded.add(name);
this.selected.delete(name);
}
applyRowState(lbl, cb, entry.name);
applyRowState(lbl, cb, name);
this.onChange(new Set(this.selected), new Set(this.excluded));
});
};
addRow("", "No terrain");
for (const entry of this.palette) {
addRow(entry.name, entry.name, entry.color);
}
const btnRow = contentEl.createDiv({ cls: "duckmage-terrain-filter-btns" });

View file

@ -1,6 +1,8 @@
import { App, Modal, TFile } from "obsidian";
import { parseRandomTable } from "./randomTable";
import type { RandomTableEntry } from "./randomTable";
import type DuckmagePlugin from "./DuckmagePlugin";
import { normalizeFolder } from "./utils";
/**
* Modal editor for a random table file.
@ -14,6 +16,7 @@ export class RandomTableEditorModal extends Modal {
constructor(
app: App,
private plugin: DuckmagePlugin,
private file: TFile,
private onSaved?: () => void,
) {
@ -33,6 +36,56 @@ export class RandomTableEditorModal extends Modal {
// Working copy so edits don't mutate until Save
const entries: RandomTableEntry[] = table.entries.map(e => ({ ...e }));
// If linked to a folder, silently drop entries whose note no longer exists
if (table.linkedFolder) {
const lf = normalizeFolder(table.linkedFolder);
for (let i = entries.length - 1; i >= 0; i--) {
if (!this.app.vault.getAbstractFileByPath(`${lf}/${entries[i].result}.md`)) {
entries.splice(i, 1);
}
}
}
// Track each entry's original result by object identity (survives drag-reorder)
const entryOriginalResult = new WeakMap<RandomTableEntry, string>();
entries.forEach(e => entryOriginalResult.set(e, e.result));
// Snapshot of original results so deleted entries can be retired on save
const originalResults = new Set(entries.map(e => e.result));
// ── Name (rename) ────────────────────────────────────────────────
const nameRow = contentEl.createDiv({ cls: "duckmage-table-editor-name-row" });
nameRow.createEl("label", { text: "Name", cls: "duckmage-table-editor-name-label" });
const nameInput = nameRow.createEl("input", { type: "text", cls: "duckmage-table-editor-name-input" });
nameInput.value = this.file.basename;
const doRename = async () => {
const newName = nameInput.value.trim();
if (!newName || newName === this.file.basename) return;
const dir = this.file.path.slice(0, this.file.path.length - this.file.name.length);
const newPath = dir + newName + ".md";
try {
await this.app.fileManager.renameFile(this.file, newPath);
this.titleEl.setText(`Edit: ${this.file.basename}`);
this.onSaved?.();
} catch (e) {
nameInput.value = this.file.basename; // revert on error
}
};
nameInput.addEventListener("blur", doRename);
nameInput.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter") { e.preventDefault(); nameInput.blur(); }
if (e.key === "Escape") { nameInput.value = this.file.basename; nameInput.blur(); }
});
// ── Linked folder ─────────────────────────────────────────────────
const folderRow = contentEl.createDiv({ cls: "duckmage-table-editor-folder-row" });
folderRow.createEl("label", { text: "Linked folder", cls: "duckmage-table-editor-folder-label" });
const folderInput = folderRow.createEl("input", { type: "text", cls: "duckmage-table-editor-folder-input" });
folderInput.value = table.linkedFolder ?? "";
folderInput.placeholder = "world/towns (leave blank for none)";
// ── Filter settings ───────────────────────────────────────────────
const filterSection = contentEl.createDiv({ cls: "duckmage-table-editor-filter-section" });
const rollFilterRow = filterSection.createDiv({ cls: "duckmage-table-editor-filter-row" });
@ -156,6 +209,13 @@ export class RandomTableEditorModal extends Modal {
rollFilterCb.checked ? false : undefined);
updatedFm = this.setFrontmatterBool(updatedFm, "encounter-filter",
encFilterCb.checked ? false : undefined);
const linkedFolder = normalizeFolder(folderInput.value.trim());
updatedFm = this.setFrontmatterString(updatedFm, "linked-folder", linkedFolder || undefined);
if (linkedFolder) {
await this.renameUpdatedEntries(entries, entryOriginalResult, linkedFolder);
await this.retireDeletedEntries(originalResults, entries, linkedFolder);
await this.syncLinkedFolder(entries, linkedFolder);
}
const newContent = this.buildContent(updatedFm, preamble, entries);
try {
await this.app.vault.modify(this.file, newContent);
@ -177,6 +237,81 @@ export class RandomTableEditorModal extends Modal {
this.makeDraggable();
}
/** Sync entries ↔ notes in linkedFolder. Mutates entries in-place. */
private async syncLinkedFolder(entries: RandomTableEntry[], folderPath: string): Promise<void> {
// Ensure folder exists
if (!this.app.vault.getAbstractFileByPath(folderPath)) {
try { await this.app.vault.createFolder(folderPath); } catch { /* may already exist */ }
}
// Notes currently in the folder
const existing = this.app.vault.getMarkdownFiles()
.filter(f => f.parent?.path === folderPath && !f.basename.startsWith("_"));
const existingNames = new Set(existing.map(f => f.basename));
// For each entry: create note if missing, ensure backlink
for (const entry of entries) {
const notePath = `${folderPath}/${entry.result}.md`;
let noteFile = this.app.vault.getAbstractFileByPath(notePath) as TFile | null;
if (!noteFile) {
try {
noteFile = await this.app.vault.create(notePath, `# ${entry.result}\n`) as TFile;
} catch { continue; }
}
await this.ensureTableBacklink(noteFile as TFile);
}
// For each note without a matching entry: add entry + backlink
const entryNames = new Set(entries.map(e => e.result));
for (const noteFile of existing) {
if (!entryNames.has(noteFile.basename)) {
entries.push({ result: noteFile.basename, weight: 1 });
await this.ensureTableBacklink(noteFile);
}
}
}
/** Rename notes whose entry result text was changed, instead of creating a new note. */
private async renameUpdatedEntries(entries: RandomTableEntry[], originalResults: WeakMap<RandomTableEntry, string>, folderPath: string): Promise<void> {
for (const entry of entries) {
const orig = originalResults.get(entry);
if (!orig || orig === entry.result) continue;
const oldPath = `${folderPath}/${orig}.md`;
const newPath = `${folderPath}/${entry.result}.md`;
const noteFile = this.app.vault.getAbstractFileByPath(oldPath);
if (!(noteFile instanceof TFile)) continue;
if (this.app.vault.getAbstractFileByPath(newPath)) continue; // target already exists
try { await this.app.fileManager.renameFile(noteFile, newPath); } catch { /* best-effort */ }
}
}
/** Prepend "_" to notes whose entries were deleted, so they are excluded from future syncs. */
private async retireDeletedEntries(originalResults: Set<string>, currentEntries: RandomTableEntry[], folderPath: string): Promise<void> {
const currentNames = new Set(currentEntries.map(e => e.result));
for (const result of originalResults) {
if (currentNames.has(result)) continue;
const notePath = `${folderPath}/${result}.md`;
const noteFile = this.app.vault.getAbstractFileByPath(notePath);
if (!(noteFile instanceof TFile)) continue;
const newPath = `${folderPath}/_${result}.md`;
try { await this.app.fileManager.renameFile(noteFile, newPath); } catch { /* already renamed or missing */ }
}
}
/** Append the roller backlink for this table file to a note, if not already present. */
private async ensureTableBacklink(noteFile: TFile): Promise<void> {
const vault = encodeURIComponent(this.app.vault.getName());
const tableEnc = encodeURIComponent(this.file.path);
const marker = `duckmage-roll?vault=${vault}&file=${tableEnc}`;
const content = await this.app.vault.read(noteFile);
if (content.includes(marker)) return;
const link = `[🎲 Open in Duckmage Roller](obsidian://${marker})`;
await this.app.vault.modify(
noteFile,
content.trimEnd() + (content.trim() ? "\n\n" : "") + link + "\n",
);
}
private makeDraggable(): void {
if (this.dragInitialized) return;
this.dragInitialized = true;
@ -251,6 +386,19 @@ export class RandomTableEditorModal extends Modal {
return frontmatter.replace(/\n---$/, `\n${line}\n---`);
}
/** Set, remove, or update a string key in a frontmatter block string. */
private setFrontmatterString(frontmatter: string, key: string, value: string | undefined): string {
const lineRegex = new RegExp(`^${key}:.*$`, "m");
const hasKey = lineRegex.test(frontmatter);
if (!value) {
if (!hasKey) return frontmatter;
return frontmatter.replace(new RegExp(`^${key}:.*\\n?`, "m"), "");
}
const line = `${key}: ${value}`;
if (hasKey) return frontmatter.replace(lineRegex, line);
return frontmatter.replace(/\n---$/, `\n${line}\n---`);
}
private extractFrontmatter(content: string): string {
const match = content.match(/^---\n[\s\S]*?\n---/);
return match ? match[0] : "";

View file

@ -91,7 +91,7 @@ export class RandomTableModal extends Modal {
headerRow.createEl("strong", { text: file.basename });
const editLink = headerRow.createEl("a", { text: "Edit", cls: "duckmage-roll-modal-edit-link" });
editLink.addEventListener("click", () => {
new RandomTableEditorModal(this.app, file, async () => {
new RandomTableEditorModal(this.app, this.plugin, file, async () => {
// Reload the table in place after saving
tableContainer.empty();
resultBox.el.style.display = "none";

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ export interface RandomTableEntry {
export interface RandomTable {
dice: number; // 0 = no die mapping, show % only
entries: RandomTableEntry[];
linkedFolder?: string;
}
/** Parse a random-table markdown file into a RandomTable. */
@ -13,9 +14,12 @@ export function parseRandomTable(content: string): RandomTable {
// Extract dice from YAML frontmatter
let dice = 0;
const fmMatch = /^---\s*\n([\s\S]*?)\n---/.exec(content);
let linkedFolder: string | undefined;
if (fmMatch) {
const diceMatch = /^dice:\s*(\d+)\s*$/m.exec(fmMatch[1]);
if (diceMatch) dice = parseInt(diceMatch[1], 10);
const lfMatch = /^linked-folder:\s*(.+)$/m.exec(fmMatch[1]);
if (lfMatch) linkedFolder = lfMatch[1].trim();
}
// Find first markdown table — lines that start with |
@ -50,7 +54,7 @@ export function parseRandomTable(content: string): RandomTable {
if (result) entries.push({ result, weight });
}
return { dice, entries };
return { dice, entries, linkedFolder };
}
/** Weighted random selection. Returns a random entry. */

View file

@ -845,7 +845,7 @@
/* While actively painting terrain (terrain selected, not eyedropper) */
.duckmage-hex-map-viewport.duckmage-terrain-paint .duckmage-hex {
cursor: cell;
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath d='M16 2 L18 4 L6 16 L2 18 L4 14 Z' fill='%23333'/%3E%3Ccircle cx='3' cy='17' r='2' fill='%235080ff'/%3E%3C/svg%3E") 2 17, crosshair;
}
/* When the SVG overlay is active it renders all labels — hide the HTML copies */
@ -1719,6 +1719,12 @@
.duckmage-rt-list-footer {
padding: 6px 8px;
border-top: 1px solid var(--background-modifier-border);
display: flex;
flex-direction: column;
gap: 4px;
}
.duckmage-rt-new-row {
display: flex;
gap: 4px;
}
@ -2151,6 +2157,26 @@ tr:hover .duckmage-rt-entry-copy-btn:hover {
opacity: 1;
}
/* ── Random table editor — name row ────────────────────────────────────────── */
.duckmage-table-editor-name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.duckmage-table-editor-name-label {
font-size: 0.85em;
color: var(--text-muted);
white-space: nowrap;
}
.duckmage-table-editor-name-input {
flex: 1;
font-size: 1em;
}
/* ── Random table editor — filter section ──────────────────────────────────── */
.duckmage-table-editor-filter-section {
@ -2190,3 +2216,41 @@ tr:hover .duckmage-rt-entry-copy-btn:hover {
opacity: 0.6;
user-select: none;
}
/* ── Random table editor — linked folder row ───────────────────────────────── */
.duckmage-table-editor-folder-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.duckmage-table-editor-folder-label {
font-size: 0.85em;
color: var(--text-muted);
white-space: nowrap;
}
.duckmage-table-editor-folder-input {
flex: 1;
}
/* ── Random table view — clickable entry links ─────────────────────────────── */
.duckmage-rt-entry-link {
cursor: pointer;
text-decoration: underline;
color: var(--link-color);
}
.duckmage-rt-entry-link:hover {
color: var(--link-color-hover);
}
/* ── Random table view — from-folder input ─────────────────────────────────── */
.duckmage-rt-from-folder-input {
width: 100%;
font-size: 0.85em;
}