sbuffkin_hexmaker/src/random-tables/RandomTableModal.ts
isaprettycoolguy@protonmail.com 1fa2153bf1 chore: address obsidianmd lint and popout-window compat (1.2.1)
- Sentence-case: rephrase "GM" → "Game master"; remove disable comments;
  autofixer lowercases "🎲 Open tables view" / "← Back" to comply
- no-static-styles-assignment: dynamic colors now use CSS custom properties
  (--duckmage-bg / --duckmage-border-color / --duckmage-fg) consumed by
  existing classes; static positions moved to small modifier classes
  (duckmage-rt-roll-btn-spaced, duckmage-wf-steps-list, etc.); terrain
  toolbar uses is-terrain-preview / is-eyedropper-active toggles;
  createIconEl uses new duckmage-masked-icon class
- Popout-window compat: setTimeout / clearTimeout / requestAnimationFrame
  → window.X; document.X → activeDocument.X across all view/modal files
- Unnecessary `as ArrayBuffer`: replace `data.buffer.slice(...) as ArrayBuffer`
  with `new ArrayBuffer + Uint8Array.set` — gets a real ArrayBuffer without
  the assertion (also avoids ArrayBufferLike narrowing)
- Deprecated SettingTab.display(): extract body into private renderSettings();
  display() and post-action refresh both call it
- eslint.config: add activeDocument + activeWindow to globals so the renamed
  identifiers don't trip no-undef

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 23:04:11 -04:00

294 lines
9.2 KiB
TypeScript

import { App, TFile } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import { normalizeFolder } from "../utils";
import { parseRandomTable, rollOnTable, getDieRanges } from "./randomTable";
import { VIEW_TYPE_RANDOM_TABLES } from "../constants";
import { RandomTableEditorModal } from "./RandomTableEditorModal";
/**
* Lightweight inline roll modal — used by the 🎲 button inside HexEditorModal
* so the user can roll on a table without leaving the hex editor context.
*
* When `initialFilePath` is supplied the dropdown is skipped and that table is
* loaded immediately (used for terrain description tables via the 📖 button).
* When `onInsert` is absent the result shows a Copy button instead of "Use result".
*/
export class RandomTableModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private onInsert?: (result: string) => void,
private initialFilePath?: string,
) {
super(app);
}
onOpen(): void {
this.titleEl.setText("Roll on table");
const { contentEl } = this;
contentEl.addClass("duckmage-roll-modal");
this.makeDraggable();
if (this.initialFilePath) {
void this.loadTable(contentEl, this.initialFilePath);
return;
}
// ── Table selector ────────────────────────────────────────────────
const folder = normalizeFolder(this.plugin.settings.tablesFolder);
let files = this.app.vault
.getMarkdownFiles()
.filter((f) => !folder || f.path.startsWith(folder + "/"))
.filter((f) => !f.basename.startsWith("_"));
files = this.plugin.filterTableFiles(
files,
"roll-filter",
this.plugin.settings.rollTableExcludedFolders,
);
files = files.sort((a, b) => a.basename.localeCompare(b.basename));
if (files.length === 0) {
contentEl.createDiv({
cls: "duckmage-rt-empty",
text: `No tables found in "${this.plugin.settings.tablesFolder}".`,
});
return;
}
const select = contentEl.createEl("select", {
cls: "duckmage-roll-modal-select",
});
select.createEl("option", { value: "", text: "— choose a table —" });
for (const file of files) {
select.createEl("option", { value: file.path, text: file.basename });
}
// "Open in roller" link — hidden until a table is selected
const openLink = contentEl.createEl("a", {
text: "Open in roller view",
cls: "duckmage-roll-modal-open-link",
});
openLink.hide();
const tableContainer = contentEl.createDiv({
cls: "duckmage-roll-modal-table-wrap",
});
const resultBox = this.buildResultBox(contentEl);
const rollBtn = contentEl.createEl("button", {
text: "Roll",
cls: "duckmage-rt-roll-btn mod-cta",
});
rollBtn.disabled = true;
select.addEventListener("change", () => {
void (async () => {
tableContainer.empty();
resultBox.el.hide();
rollBtn.disabled = true;
const path = select.value;
if (!path) {
openLink.hide();
return;
}
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return;
openLink.show();
openLink.onclick = () => {
this.openInRoller(file.path);
};
await this.renderOddsTable(tableContainer, resultBox, rollBtn, file);
})();
});
contentEl.appendChild(rollBtn);
}
private async loadTable(
contentEl: HTMLElement,
filePath: string,
): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) {
contentEl.createDiv({
cls: "duckmage-rt-empty",
text: "Table file not found.",
});
return;
}
// Header row: name + edit link + open-in-roller link
const headerRow = contentEl.createDiv({
cls: "duckmage-roll-modal-header-row",
});
headerRow.createEl("strong", { text: file.basename });
const editLink = headerRow.createEl("a", {
text: "Edit",
cls: "duckmage-roll-modal-edit-link",
});
editLink.addEventListener("click", () => {
void (async () => {
const content = await this.app.vault.read(file);
new RandomTableEditorModal(
this.app,
this.plugin,
file,
() => {
void (async () => {
// Reload the table in place after saving
tableContainer.empty();
resultBox.el.hide();
rollBtn.disabled = true;
await this.renderOddsTable(tableContainer, resultBox, rollBtn, file);
})();
},
content,
).open();
})();
});
const openLink = headerRow.createEl("a", {
text: "Open in roller view",
cls: "duckmage-roll-modal-open-link",
});
openLink.addEventListener("click", () => {
this.openInRoller(file.path);
});
const tableContainer = contentEl.createDiv({
cls: "duckmage-roll-modal-table-wrap",
});
const resultBox = this.buildResultBox(contentEl);
const rollBtn = contentEl.createEl("button", {
text: "Roll",
cls: "duckmage-rt-roll-btn mod-cta",
});
rollBtn.disabled = true;
await this.renderOddsTable(tableContainer, resultBox, rollBtn, file);
contentEl.appendChild(rollBtn);
}
private openInRoller(filePath: 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 { openTable?: (path: string) => void }
).openTable?.(filePath);
} else {
void this.app.workspace.getLeaf("tab").setViewState({
type: VIEW_TYPE_RANDOM_TABLES,
state: { filePath },
});
}
this.close();
}
private buildResultBox(contentEl: HTMLElement): {
el: HTMLElement;
textarea: HTMLTextAreaElement;
} {
const resultBox = contentEl.createDiv({ cls: "duckmage-roll-result" });
resultBox.hide();
const resultTextarea = resultBox.createEl("textarea", {
cls: "duckmage-roll-result-textarea",
});
const resultBtns = resultBox.createDiv({
cls: "duckmage-roll-result-btns",
});
if (this.onInsert) {
const useBtn = resultBtns.createEl("button", {
text: "Use result",
cls: "mod-cta",
});
useBtn.addEventListener("click", () => {
this.onInsert!(resultTextarea.value);
this.close();
});
} else {
const copyBtn = resultBtns.createEl("button", {
text: "Copy",
cls: "mod-cta",
});
copyBtn.addEventListener("click", () => {
void navigator.clipboard.writeText(resultTextarea.value);
});
}
return { el: resultBox, textarea: resultTextarea };
}
private async renderOddsTable(
tableContainer: HTMLElement,
resultBox: { el: HTMLElement; textarea: HTMLTextAreaElement },
rollBtn: HTMLButtonElement,
file: TFile,
): Promise<void> {
const content = await this.app.vault.read(file);
const table = parseRandomTable(content);
const ranges = table.dice > 0 ? getDieRanges(table) : null;
if (table.entries.length === 0) {
tableContainer.createSpan({
text: "No entries found.",
cls: "duckmage-rt-empty",
});
return;
}
const tableEl = tableContainer.createEl("table", {
cls: "duckmage-random-table",
});
const thead = tableEl.createEl("thead");
const headerRow = thead.createEl("tr");
if (ranges) headerRow.createEl("th", { text: `d${table.dice}` });
headerRow.createEl("th", { text: "Result" });
headerRow.createEl("th", { text: "Odds" });
headerRow.createEl("th", { cls: "duckmage-rt-copy-col-header" });
const tbody = tableEl.createEl("tbody");
const total = table.entries.reduce((s, e) => s + e.weight, 0);
table.entries.forEach((entry, i) => {
const tr = tbody.createEl("tr");
tr.dataset.index = String(i);
if (ranges)
tr.createEl("td", { text: ranges[i], cls: "duckmage-rt-range-cell" });
tr.createEl("td", { text: entry.result });
const pct = `${Math.round((entry.weight / total) * 100)}%`;
tr.createEl("td", { text: pct, cls: "duckmage-rt-odds-cell" });
const copyTd = tr.createEl("td", { cls: "duckmage-rt-entry-copy-cell" });
const copyBtn = copyTd.createEl("button", {
text: "⎘",
cls: "duckmage-rt-entry-copy-btn",
});
copyBtn.title = "Copy entry";
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
void navigator.clipboard.writeText(entry.result);
copyBtn.setText("✓");
window.setTimeout(() => copyBtn.setText("⎘"), 1200);
});
});
rollBtn.disabled = false;
rollBtn.onclick = () => {
const rolled = rollOnTable(table);
if (!rolled) return;
tbody.querySelectorAll("tr").forEach((tr) => {
tr.toggleClass(
"is-rolled",
tr.textContent?.includes(rolled.result) || false,
);
});
resultBox.el.show();
resultBox.textarea.value = rolled.result;
};
}
onClose(): void {
this.contentEl.empty();
}
}