import { ExportFormat, exportScenario, type GeneratedState, generatedStateToString, getResultFrom, RolledValues, rollOn, RollSelections, type RollTable, RollTableDatabase, type RollTableDetailsAndResults, type RollTableResult, type RollTableResultFull } from '../common/rolltable'; import { buildGenerated, htmlTableIdentifier } from '../common/template'; import { DOMLoaded } from './onload'; import { scrapeGeneratedScenario } from './scraper'; import { showPopup } from './popup'; import { pulseElement } from './pulse'; import { DOMTemplateBuilder } from './template'; import escapeHTML from 'escape-html'; export interface RerollEventDetail { rerolledAll: boolean changedResults: Iterable> fullResults: ReadonlyMap selections: ReadonlySet } export class Generator { readonly generator: HTMLElement; readonly scenario: HTMLUListElement; readonly copyButtons: HTMLElement; readonly rollButtons: HTMLElement; readonly db: RollTableDatabase | undefined; private readonly rolled = new RolledValues(); private readonly selected = new RollSelections(); get state(): GeneratedState { return { final: false, rolled: this.rolled, selected: this.selected, } } getTableWithHtmlId(id: string, prefix?: string): RollTable | undefined { return Array.from(this.rolled.keys()).find(t => id === ((prefix ?? '') + htmlTableIdentifier(t))); } selectAll(): this { for (const check of this.scenario.querySelectorAll('input[type=checkbox]:not(:checked)') as Iterable) { check.checked = true; check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false})) const table = this.getTableWithHtmlId(check.id, 'selected-'); if (table) { this.selected.add(table); } } return this } selectNone(): this { this.selected.clear(); for (const check of this.scenario.querySelectorAll('input[type=checkbox]:checked') as Iterable) { check.checked = false; check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false})) } return this } reroll(all: boolean): this { if (!this.db) { return this } const changes: RollTableResultFull[] = [] for (const row of this.scenario.querySelectorAll(`.generatedElement`)) { const check = row.querySelector(`input.generatedSelect:checked`) const text = row.querySelector(`.resultText`) if ((all || check) && text) { let result = this.db.mappings.get(parseInt(text.dataset["mappingid"] ?? '-1')) if (!result || result.table.resultsById.size === 1) { continue } const origResult = result const table = result.table while (result === origResult) { result = rollOn(table) } this.setActiveResult(result, all) changes.push(result) pulseElement(text) } } this.generator.dispatchEvent(new CustomEvent("reroll", { composed: false, bubbles: true, cancelable: false, detail: { rerolledAll: all, changedResults: changes, fullResults: this.rolled, selections: this.selected, } })) return this } loadValuesFromDOM(): this { this.rolled.clear() this.selected.clear() const scenario = scrapeGeneratedScenario(this.scenario) if (!scenario) { throw Error("Failed to load generated values from DOM") } for (const [scrapedTable, scrapedResult] of scenario.rolled) { const table = this.db?.getTableMatching(scrapedTable) ?? scrapedTable const result = getResultFrom(table, scrapedResult) if (scenario.selected.has(scrapedTable)) { this.selected.add(table) } this.rolled.add(result) } return this } private getGeneratedElementForTable(table: RollTable): HTMLElement|null { return this.scenario.querySelector(`#generated-${escapeHTML(htmlTableIdentifier(table))}`) } private replaceResultInElement(result: RollTableResult, generatedElement: HTMLElement) { // sister function is buildGeneratedElement const generatedDiv = generatedElement.querySelector(`.generated`) if (!generatedDiv) { throw Error(`couldn't find .generated in replaceResultInElement`) } generatedDiv.replaceWith(buildGenerated({result, includesResponses: !!this.db, builder: DOMTemplateBuilder})) const button = generatedElement.querySelector(`.resultText`)! pulseElement(button) this.rolled.add(result) } private changeSelection(generatedElement: HTMLElement, selected: boolean) { const check = generatedElement.querySelector(`.generatedSelect`) if (!check) { return } if (check.checked !== selected) { check.checked = selected check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false})) } } async copy(format: ExportFormat): Promise { const exported = exportScenario(Array.from(this.rolled.values()), format) return navigator.clipboard.writeText(exported) } attachHandlers(): this { this.generator.addEventListener('click', (e) => this.clickHandler(e)); this.generator.addEventListener('change', (e) => this.changeHandler(e)); this.generator.querySelector(`#reroll`)!.disabled = (this.selected.size === 0) return this; } private clickHandler(e: Event): void { if (e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement) { switch (e.target.id) { case "selectNone": this.selectNone() break case "selectAll": this.selectAll() break case "copyMD": this.copy(ExportFormat.Markdown) .then(() => showPopup(this.copyButtons, `Copied Markdown to clipboard!`, 'success')) .catch((e) => { console.error("Failed while copying Markdown:", e) showPopup(this.copyButtons, `Failed to copy Markdown to clipboard`, 'error') }) break case "copyBB": this.copy(ExportFormat.BBCode) .then(() => showPopup(this.copyButtons, `Copied BBCode to clipboard!`, 'success')) .catch((e) => { console.error("Failed while copying BBCode:", e) showPopup(this.copyButtons, `Failed to copy BBCode to clipboard`, 'error') }) break case "copyEmojiText": this.copy(ExportFormat.TextEmoji) .then(() => showPopup(this.copyButtons, `Copied text (with emojis) to clipboard!`, 'success')) .catch((e) => { console.error("Failed while copying text (with emojis):", e) showPopup(this.copyButtons, `Failed to copy text (with emojis) to clipboard`, 'error') }) break case "copyText": this.copy(ExportFormat.TextOnly) .then(() => showPopup(this.copyButtons, `Copied text to clipboard!`, 'success')) .catch((e) => { console.error("Failed while copying text:", e) showPopup(this.copyButtons, `Failed to copy text to clipboard`, 'error') }) break case "reroll": this.reroll(false) break case "rerollAll": this.reroll(true) break default: return } e.preventDefault() } } private changeHandler(e: Event): void { if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox' && e.target.id.startsWith('selected-')) { const check = e.target const table = this.getTableWithHtmlId(check.id, 'selected-'); if (table) { if (check.checked) { this.selected.add(table); } else { this.selected.delete(table); } this.generator.querySelector(`#reroll`)!.disabled = (this.selected.size === 0) pulseElement(check) } } } constructor(generator: HTMLElement, generatorForm: HTMLUListElement, copyButtons: HTMLElement, rollButtons: HTMLElement, db?: RollTableDatabase) { this.generator = generator; this.scenario = generatorForm; this.copyButtons = copyButtons; this.rollButtons = rollButtons; this.db = db; } setActiveResult(result: RollTableResultFull, clearSelection?: boolean) { const tableElement = this.getGeneratedElementForTable(result.table) if (!tableElement) { return } this.replaceResultInElement(result, tableElement) if (clearSelection) { this.changeSelection(tableElement, false) } } addRerollListener(listener: (ev: CustomEvent) => void, options?: boolean|EventListenerOptions): void { this.generator.addEventListener('reroll', listener, options) } } function initGenerator(db?: RollTableDatabase): Generator { const generatorFound = document.getElementById('generator'); if (!generatorFound) { throw Error('generator was not found'); } const generatedScenarioFound = document.getElementById('generatedScenario'); if (!generatedScenarioFound || !(generatedScenarioFound instanceof HTMLUListElement)) { throw Error('generated scenario was not found'); } const copyButtons = document.getElementById("copyButtons") if (!copyButtons) { throw Error('copy buttons were not found') } const rollButtons = document.getElementById("rollButtons") if (!rollButtons) { throw Error('roll buttons were not found') } return new Generator(generatorFound, generatedScenarioFound, copyButtons, rollButtons, db).loadValuesFromDOM().attachHandlers(); } let pendingGenerator: Promise|undefined = undefined export async function prepareGenerator(db?: Promise): Promise { if (pendingGenerator) { throw Error(`prepareGenerator should only be called once`) } pendingGenerator = DOMLoaded.then(() => db) .then((promisedDb) => initGenerator(promisedDb)) return pendingGenerator } DOMLoaded.then(() => pendingGenerator ?? prepareGenerator()) .then(g => console.info(`loaded generator:\n${generatedStateToString(g.state)}`)) .catch(e => console.error('failed to load generator', e))