mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 14:30:24 +00:00
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:
parent
4cf513585f
commit
c283ffe9c8
7 changed files with 943 additions and 407 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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] : "";
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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. */
|
||||
|
|
|
|||
66
styles.css
66
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue