diff --git a/.idea/ncc-gen.iml b/.idea/ncc-gen.iml index 24643cc..16facd0 100644 --- a/.idea/ncc-gen.iml +++ b/.idea/ncc-gen.iml @@ -5,6 +5,9 @@ + + + diff --git a/package-lock.json b/package-lock.json index bac1c25..f379db0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "escape-html": "^1.0.3", "itty-router": "^4.0.26", "markdown-escape": "^2.0.0", + "preact": "^10.19.6", + "preact-iso": "^2.4.0", + "preact-render-to-string": "^6.4.0", "slash-create": "^6.0.2", "slug": "^8.2.3" }, @@ -32,15 +35,12 @@ "@types/less": "^3.0.6", "@types/markdown-escape": "^1.1.3", "@types/slug": "^5.0.7", - "babel": "^6.23.0", - "camelcase": "^8.0.0", + "change-case": "^5.4.2", "clean-css": "^5.3.3", "fast-deep-equal": "^3.1.3", "less": "^4.2.0", "rollup": "^4.9.5", "rollup-plugin-ts": "^3.4.5", - "source-map": "^0.7.4", - "terser": "^5.27.0", "tsx": "^4.7.0", "typescript": "^5.0.4", "wrangler": "^3.0.0" @@ -2910,18 +2910,6 @@ "printable-characters": "^1.0.42" } }, - "node_modules/babel": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/babel/-/babel-6.23.0.tgz", - "integrity": "sha512-ZDcCaI8Vlct8PJ3DvmyqUz+5X2Ylz3ZuuItBe/74yXosk2dwyVo/aN7MCJ8HJzhnnJ+6yP4o+lDgG9MBe91DLA==", - "deprecated": "In 6.x, the babel package has been deprecated in favor of babel-cli. Check https://opencollective.com/babel to support the Babel maintainers", - "dev": true, - "bin": { - "babel": "lib/cli.js", - "babel-external-helpers": "lib/cli.js", - "babel-node": "lib/cli.js" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", @@ -3139,18 +3127,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001578", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001578.tgz", @@ -3204,6 +3180,12 @@ "node": ">=0.8.0" } }, + "node_modules/change-case": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.2.tgz", + "integrity": "sha512-WB3UiTDpT+vrTilAWaJS4gaIH/jc1He4H9f6erQvraUYas90uWT0JOYFkG1imdNv710XJ6gJvqynrgOHc4ihDA==", + "dev": true + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -4149,6 +4131,45 @@ "node": ">=6" } }, + "node_modules/preact": { + "version": "10.19.6", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.6.tgz", + "integrity": "sha512-gympg+T2Z1fG1unB8NH29yHJwnEaCH37Z32diPDku316OTnRPeMbiRV9kTrfZpocXjdfnWuFUl/Mj4BHaf6gnw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-iso": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/preact-iso/-/preact-iso-2.4.0.tgz", + "integrity": "sha512-m/xT9oTvNRZ4K/C05Ha91HdT8OPZCjf6Rs4y9arTALYYb1O1qfmygaJHXzkXFVkpDItHnpXMcisiwLn7CsZoLg==", + "peerDependencies": { + "preact": ">=10", + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/preact-render-to-string": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.4.0.tgz", + "integrity": "sha512-pzDwezZaLbK371OiJjXDsZJwVOALzFX5M1wEh2Kr0pEApq5AV6bRH/DFbA/zNA7Lck/duyREPQLLvzu2G6hEQQ==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", @@ -4534,15 +4555,6 @@ "integrity": "sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==", "dev": true }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", diff --git a/package.json b/package.json index 8e0acf8..2b3ea98 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@types/less": "^3.0.6", "@types/markdown-escape": "^1.1.3", "@types/slug": "^5.0.7", - "camelcase": "^8.0.0", + "change-case": "^5.4.2", "clean-css": "^5.3.3", "fast-deep-equal": "^3.1.3", "less": "^4.2.0", @@ -41,6 +41,9 @@ "escape-html": "^1.0.3", "itty-router": "^4.0.26", "markdown-escape": "^2.0.0", + "preact": "^10.19.6", + "preact-iso": "^2.4.0", + "preact-render-to-string": "^6.4.0", "slash-create": "^6.0.2", "slug": "^8.2.3" } diff --git a/src/build/bundler.ts b/src/build/bundler.ts index 197b372..aa25d99 100644 --- a/src/build/bundler.ts +++ b/src/build/bundler.ts @@ -13,7 +13,7 @@ import typescriptModule from 'typescript'; import { readFile, writeFile, readdir } from 'node:fs/promises'; import { basename, dirname, join, normalize } from 'node:path'; import {createHash} from 'node:crypto'; -import camelcase from 'camelcase'; +import {camelCase} from 'change-case'; import { render as renderLess } from 'less'; import CleanCSS from 'clean-css'; import type { @@ -124,7 +124,7 @@ async function processTypescript(atPath: string, inDir: string, cache?: RollupCa ] }) const {output: [chunk]} = await build.generate({ - name: camelcase(basename(atPath.substring(0, atPath.length - TS_SUFFIX.length))), + name: camelCase(basename(atPath.substring(0, atPath.length - TS_SUFFIX.length))), sourcemap: 'hidden', sourcemapFile: join(inDir, 'sourcemap.map'), format: 'iife', @@ -160,11 +160,11 @@ export async function getBundle(inDir: string): Promise<{ css: Map { + res.addSelectionListener((ev) => { + gen.setActiveResult(ev.detail, true) + }) + gen.addRerollListener((ev) => { + for (const result of ev.detail.changedResults) { + res.setActiveElementForTable(result) + } + }) + console.info("connected generator and response list") +}).catch((e) => { + console.error(e) +}) function updateHash(): void { if (location.hash === "" || location.hash === "#" || !location.hash) { diff --git a/src/client/generator-entrypoint.less b/src/client/generator-entrypoint.less index cc5568a..5fd00c8 100644 --- a/src/client/generator-entrypoint.less +++ b/src/client/generator-entrypoint.less @@ -1,5 +1,4 @@ @import "basic-look"; -@import "attribution"; @import "popup"; @import "pulse"; @@ -26,74 +25,10 @@ #generatedScenario { } -.generatedHead { - user-select: text; - margin: 0.5rem 0 0 0; - display: flex; - flex-flow: row nowrap; -} - -.generatedHead .generatedLabel span { - display: inline; - user-select: text; -} - -.generatedLabel { - flex: 1 1 auto; - display: inline-flex; - flex-flow: row nowrap; - align-items: center; - justify-content: left; - cursor: pointer; - padding-right: 0.2rem; - user-select: text; -} - -.generated { - margin: 0; - padding: 0; - appearance: none; - font: inherit; - outline: 0; - border: 0; -} - -.generatedSelect { - flex: 0 0 auto; - appearance: none; - cursor: pointer; - font-size: 1.5rem; - margin: 0; - transition: filter 0.3s ease, transform 0.3s ease; - width: 2rem; - height: 2rem; - text-align: center; - line-height: 2rem; - border-radius: 1rem; -} - #generator .buttons { margin-left: -0.3rem; } -.generatedHead:hover .generatedSelect, .generatedHead .generatedSelect:focus { - filter: brightness(120%) saturate(80%); - transform: scale(120%); -} - -.generatedHead .generatedSelect:active { - filter: brightness(80%) saturate(110%); - transform: scale(80%); -} - -.generatedSelect::after { - content: '🔒' -} - -.generatedSelect:checked::after { - content: '🎲'; -} - #copyButtons::before { content: "Copy as:"; margin: 0.2rem 0 0 0.3rem diff --git a/src/client/generator-entrypoint.ts b/src/client/generator-entrypoint.ts index 1108a77..36b1309 100644 --- a/src/client/generator-entrypoint.ts +++ b/src/client/generator-entrypoint.ts @@ -2,25 +2,31 @@ import { ExportFormat, exportScenario, type GeneratedState, - generatedStateToString, getResultFrom, + generatedStateToString, + getResultFrom, RolledValues, + rollOn, RollSelections, type RollTable, RollTableDatabase, - type RollTableResult + type RollTableDetailsAndResults, + type RollTableResult, + type RollTableResultFull } from '../common/rolltable'; -import { - buildGeneratedElement, copyBBID, copyEmojiTextID, - copyMDID, copyTextID, - htmlTableIdentifier, rerollAllId, rerollId, - selectAllId, - selectedIdPrefix, - selectNoneId -} from '../common/template'; +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; @@ -44,11 +50,10 @@ export class Generator { } selectAll(): this { - this.selected.clear(); - for (const check of this.scenario.querySelectorAll('input[type=checkbox]') as Iterable) { + for (const check of this.scenario.querySelectorAll('input[type=checkbox]:not(:checked)') as Iterable) { check.checked = true; - pulseElement(check); - const table = this.getTableWithHtmlId(check.id, selectedIdPrefix); + 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); } @@ -58,10 +63,47 @@ export class Generator { selectNone(): this { this.selected.clear(); - for (const check of this.scenario.querySelectorAll('input[type=checkbox]') as Iterable) { + for (const check of this.scenario.querySelectorAll('input[type=checkbox]:checked') as Iterable) { check.checked = false; - pulseElement(check); + 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 } @@ -83,10 +125,31 @@ export class Generator { return this } - attachHandlers(): this { - this.generator.addEventListener('click', (e) => this.clickHandler(e)); - this.generator.addEventListener('change', (e) => this.changeHandler(e)); - 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 { @@ -94,16 +157,23 @@ export class Generator { 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 selectNoneId: + case "selectNone": this.selectNone() break - case selectAllId: + case "selectAll": this.selectAll() break - case copyMDID: + case "copyMD": this.copy(ExportFormat.Markdown) .then(() => showPopup(this.copyButtons, `Copied Markdown to clipboard!`, 'success')) .catch((e) => { @@ -111,7 +181,7 @@ export class Generator { showPopup(this.copyButtons, `Failed to copy Markdown to clipboard`, 'error') }) break - case copyBBID: + case "copyBB": this.copy(ExportFormat.BBCode) .then(() => showPopup(this.copyButtons, `Copied BBCode to clipboard!`, 'success')) .catch((e) => { @@ -119,7 +189,7 @@ export class Generator { showPopup(this.copyButtons, `Failed to copy BBCode to clipboard`, 'error') }) break - case copyEmojiTextID: + case "copyEmojiText": this.copy(ExportFormat.TextEmoji) .then(() => showPopup(this.copyButtons, `Copied text (with emojis) to clipboard!`, 'success')) .catch((e) => { @@ -127,7 +197,7 @@ export class Generator { showPopup(this.copyButtons, `Failed to copy text (with emojis) to clipboard`, 'error') }) break - case copyTextID: + case "copyText": this.copy(ExportFormat.TextOnly) .then(() => showPopup(this.copyButtons, `Copied text to clipboard!`, 'success')) .catch((e) => { @@ -135,70 +205,35 @@ export class Generator { showPopup(this.copyButtons, `Failed to copy text to clipboard`, 'error') }) break - case rerollId: - for (const row of this.scenario.querySelectorAll(".generatedElement")) { - if (row.querySelector("input[type=checkbox]:checked")) { - const text = row.querySelector(".resultText") - if (text) { - pulseElement(text) - } - } - } - showPopup(this.rollButtons, `only pretending to reroll`, 'warning') + case "reroll": + this.reroll(false) break - case rerollAllId: - for (const row of this.scenario.querySelectorAll(".generatedElement")) { - const check = row.querySelector("input[type=checkbox]:checked") - if (check) { - check.checked = false - pulseElement(check) - } - const text = row.querySelector(".resultText") - if (text) { - pulseElement(text) - } - } - showPopup(this.rollButtons, `only pretending to reroll all`, 'warning') + case "rerollAll": + this.reroll(true) break default: - if (e.target.classList.contains("resultText")) { - for (let target: HTMLElement|null = e.target; target && target !== this.generator; target = target.parentElement) { - if (target.classList.contains("generatedElement")) { - const check = target.querySelector(".generatedSelect") - if (check) { - check.click() - } - } - } - } else { - return - } + return } e.preventDefault() } } private changeHandler(e: Event): void { - if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox' && e.target.id.startsWith(selectedIdPrefix)) { + if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox' && e.target.id.startsWith('selected-')) { const check = e.target - const table = this.getTableWithHtmlId(check.id, selectedIdPrefix); + 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) } } } - private animationendHandler(e: AnimationEvent): void { - if (e.animationName === "pulse" && e.target instanceof HTMLElement && e.target.classList.contains("pulse")) { - e.target.classList.remove("pulse") - } - } - constructor(generator: HTMLElement, generatorForm: HTMLUListElement, copyButtons: HTMLElement, rollButtons: HTMLElement, db?: RollTableDatabase) { this.generator = generator; this.scenario = generatorForm; @@ -206,6 +241,21 @@ export class Generator { 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 { @@ -223,7 +273,7 @@ function initGenerator(db?: RollTableDatabase): Generator { } const rollButtons = document.getElementById("rollButtons") if (!rollButtons) { - throw Error('copy buttons were not found') + throw Error('roll buttons were not found') } return new Generator(generatorFound, generatedScenarioFound, copyButtons, rollButtons, db).loadValuesFromDOM().attachHandlers(); } @@ -240,5 +290,5 @@ export async function prepareGenerator(db?: Promise): Promise } DOMLoaded.then(() => pendingGenerator ?? prepareGenerator()) - .then(g => console.info(`loaded generator: ${generatedStateToString(g.state)}`)) + .then(g => console.info(`loaded generator:\n${generatedStateToString(g.state)}`)) .catch(e => console.error('failed to load generator', e)) diff --git a/src/client/popup.ts b/src/client/popup.ts index 28d44b6..026a411 100644 --- a/src/client/popup.ts +++ b/src/client/popup.ts @@ -1,6 +1,6 @@ export function showPopup(parent: HTMLElement, text: string, className?: 'success'|'info'|'warning'|'error'): void { if (!parent.classList.contains("jsPopupHost")) { - console.log(parent, "should be jsPopupHost") + console.warn(parent, "should be jsPopupHost") } const container = parent.ownerDocument.createElement("div") container.classList.add("jsPopupContainer") diff --git a/src/client/pulse.less b/src/client/pulse.less index 7ceb474..e2c9229 100644 --- a/src/client/pulse.less +++ b/src/client/pulse.less @@ -1,12 +1,10 @@ @keyframes pulse-bg { from { - background-color: transparent; } 10% { background-color: #60606060; } to { - background-color: transparent; } } diff --git a/src/client/pulse.ts b/src/client/pulse.ts index e0b99e0..5ddbcfb 100644 --- a/src/client/pulse.ts +++ b/src/client/pulse.ts @@ -1,6 +1,24 @@ +function onPulseEnd(e: AnimationEvent): void { + if (e.animationName === "pulse-bg" && e.target instanceof HTMLElement) { + e.target.classList.remove("pulse") + e.target.removeEventListener("animationend", onPulseEnd) + e.target.removeEventListener("animationcancel", onPulseEnd) + } +} + export function pulseElement(element: HTMLElement) { - element.classList.add("pulse") - element.style.animation = "none"; - getComputedStyle(element).animation - setTimeout(element.style.animation = "") + element.removeEventListener("animationend", onPulseEnd) + element.removeEventListener("animationcancel", onPulseEnd) + if (element.classList.contains("pulse")) { + const anim = + element.getAnimations().find(v => v instanceof CSSAnimation && v.animationName === "pulse-bg") + if (anim) { + anim.finish() + anim.play() + } + } else { + element.classList.add("pulse") + } + element.addEventListener("animationend", onPulseEnd) + element.addEventListener("animationcancel", onPulseEnd) } diff --git a/src/client/responses-entrypoint.less b/src/client/responses-entrypoint.less index 205d147..848aa54 100644 --- a/src/client/responses-entrypoint.less +++ b/src/client/responses-entrypoint.less @@ -79,8 +79,9 @@ .response { margin-top: 0.3rem; display: flex; - align-items: baseline; + align-items: stretch; flex-flow: row nowrap; + scroll-margin-top: 12rem; } .response.active { @@ -88,14 +89,22 @@ min-height: 1.5rem; &::before { - content: "▶"; + width: 1rem; + margin: 0.2rem 0.2rem 0.2rem 0.5rem; + content: ""; flex: 0 0 auto; - font-size: 1.25rem; - margin-right: 0.4rem; - line-height: 1.5rem; + background-image: + linear-gradient(to bottom left, transparent 50%, currentColor 0), + linear-gradient(to bottom right, currentColor 50%, transparent 0); + background-size: 100% 50%; + background-repeat: no-repeat; + background-position: top, bottom; } & .resultText { font-weight: bold; } + & .attribution .button { + display: none; + } } diff --git a/src/client/responses-entrypoint.ts b/src/client/responses-entrypoint.ts index 1709fff..133e4db 100644 --- a/src/client/responses-entrypoint.ts +++ b/src/client/responses-entrypoint.ts @@ -1,16 +1,82 @@ -import type { RollTable, RollTableDatabase } from '../common/rolltable'; +import type { RollTableDatabase, RollTableDetailsAndResults, RollTableResultFull } from '../common/rolltable'; import { DOMLoaded } from './onload'; +import { scrapeResponseLists } from './scraper'; +import { htmlTableIdentifier } from '../common/template'; +import escapeHTML from 'escape-html'; -class ResponseList { +class ResponseLists { readonly db: RollTableDatabase - constructor(db: RollTableDatabase) { + readonly listsElement: HTMLElement + constructor(db: RollTableDatabase, listsElement: HTMLElement) { this.db = db + this.listsElement = listsElement + } + + addSelectionListener(listener: (e: CustomEvent>) => void, options?: boolean|EventListenerOptions): void { + this.listsElement.addEventListener("resultselected", listener, options) + } + + configureHandlers(): this { + this.listsElement.addEventListener("click", (e) => { + if (e.target instanceof HTMLElement && e.target.classList.contains("makeResponseActive")) { + const response = e.target.closest(`.response`) + if (!response) { + console.log("no response") + return + } + const mappingId = response.id && response.id.startsWith("response-") ? parseInt(response.id.substring("response-".length), 10) : NaN + if (isNaN(mappingId)) { + console.log("no mapping ID") + return + } + const result = this.db.mappings.get(mappingId) + if (!result) { + console.log("no result") + return + } + const ev = new CustomEvent>("resultselected", { + bubbles: true, + cancelable: true, + detail: result + }) + if (e.target.dispatchEvent(ev)) { + this.setActiveElementForTable(result) + const button = response.querySelector(`.resultText`) as HTMLElement + if (button) { + button.focus() + } + } + } + }) + return this + } + + setActiveElementForTable(result: RollTableResultFull) { + const oldActive = this.listsElement.querySelector(`#responses-${escapeHTML(htmlTableIdentifier(result.table))} .response.active`) + const newActive = this.listsElement.querySelector(`#response-${escapeHTML(`${result.mappingId}`)}`) + if (!newActive || oldActive === newActive) { + return + } + newActive.classList.add("active") + if (!oldActive) { + return + } + oldActive.classList.remove("active") } } -function initResponseList(): ResponseList { - throw Error("not yet implemented") +function initResponseList(): ResponseLists { + const listsElement = document.querySelector(`#responseLists`) + if (!listsElement) { + throw Error(`can't find #responseLists`) + } + const lists = scrapeResponseLists(listsElement) + if (!lists) { + throw Error(`can't parse #responseLists`) + } + const {db, active} = lists + return new ResponseLists(db, listsElement).configureHandlers() } -export const responseList: Promise = DOMLoaded.then(() => initResponseList()) -export const db: Promise = responseList.then(r => r.db) +export const responseLists: Promise = DOMLoaded.then(() => initResponseList()) +export const db: Promise = responseLists.then(r => r.db) diff --git a/src/client/scraper.ts b/src/client/scraper.ts index fd504f8..db87c70 100644 --- a/src/client/scraper.ts +++ b/src/client/scraper.ts @@ -1,20 +1,26 @@ import { - type InProgressGeneratedState, RolledValues, RollSelections, + type InProgressGeneratedState, + RolledValues, + RollSelections, type RollTableAuthor, + RollTableDatabase, + type RollTableDetailsAndResults, type RollTableDetailsNoResults, type RollTableLimited, type RollTableResult, + type RollTableResultFull, type RollTableResultSet } from '../common/rolltable'; +import { authorIdKey } from '../common/template'; export function asBoolean(s: string|undefined): boolean|undefined { if (typeof s === "undefined") { return } switch (s.toLowerCase()) { - case "true": + case 'true': return true - case "false": + case 'false': return false default: return @@ -70,10 +76,10 @@ export function scrapeAuthor(author: HTMLElement|null): RollTableAuthor|null|und if (!author) { return null } - const id = asInteger(author.dataset["id"]) - const name = textFrom(author.querySelector(".authorName")) + const id = asInteger(author.dataset[authorIdKey]) + const name = textFrom(author.querySelector(`.authorName`)) const url = hrefFrom(author.querySelector("a[href]")) - const relation = textFrom(author.querySelector(".authorRelation")) + const relation = textFrom(author.querySelector(`.authorRelation`)) if (typeof id === "undefined" || typeof name === "undefined" || typeof relation === 'undefined') { return } @@ -91,7 +97,7 @@ export function scrapeResultSet(set: HTMLElement|null): RollTableResultSet|null| return null } const id = asInteger(set.dataset["id"]) - const name = textFrom(set.querySelector(".setName")) + const name = textFrom(set.querySelector(`.setName`)) const global = asBoolean(set.dataset["global"]) if (typeof id === "undefined" || typeof global === "undefined") { return @@ -144,11 +150,11 @@ export function scrapeGeneratedHead(head: HTMLElement|null): {table: RollTableLi if (!head) { return null } - const table = scrapeTableHeader(head.querySelector(".tableHeader")) + const table = scrapeTableHeader(head.querySelector(`.tableHeader`)) if (!table) { return } - const selected = checkedFrom(head.querySelector("input[type=checkbox].generatedSelect")) + const selected = checkedFrom(head.querySelector(`input[type=checkbox].generatedSelect`)) return { table, selected, @@ -187,10 +193,10 @@ export function scrapeGeneratedElement(generated: HTMLElement|null): {result: Ro if (!generated) { return null } - const result = scrapeResultText(generated.querySelector(".resultText")) - const author = scrapeAuthor(generated.querySelector(".author")) - const set = scrapeResultSet(generated.querySelector(".resultSet")) - const header = scrapeGeneratedHead(generated.querySelector(".generatedHead")) + const result = scrapeResultText(generated.querySelector(`.resultText`)) + const author = scrapeAuthor(generated.querySelector(`.author`)) + const set = scrapeResultSet(generated.querySelector(`.resultSet`)) + const header = scrapeGeneratedHead(generated.querySelector(`.generatedHead`)) if (!header || !result) { return } @@ -225,7 +231,7 @@ export function scrapeGeneratedScenario(scenario: HTMLElement|null): InProgressG } const rolls = new RolledValues() const selection = new RollSelections() - for (const item of scenario.querySelectorAll(".generatedElement")) { + for (const item of scenario.querySelectorAll(`.generatedElement`)) { const element = scrapeGeneratedElement(item) if (!element) { return @@ -242,3 +248,58 @@ export function scrapeGeneratedScenario(scenario: HTMLElement|null): InProgressG selected: selection, } } + +export function scrapeResponseList(responseTypeElement: HTMLElement|null, db: RollTableDatabase): [table: RollTableDetailsAndResults, active: RollTableResultFull|null]|null|undefined { + if (!responseTypeElement) { + return null + } + const table = scrapeTableHeader(responseTypeElement.querySelector(`.tableHeader`)) + if (!table || !table.full) { + return + } + const resultTable = db.addTable(table) + let activeResult: RollTableResultFull|null = null + for (const resultElement of responseTypeElement.querySelectorAll(`.response`)) { + const partialResult = scrapeResultText(resultElement.querySelector(`.resultText`)) + const author = scrapeAuthor(resultElement.querySelector(`.author`)) + const set = scrapeResultSet(resultElement.querySelector(`.resultSet`)) + const active = resultElement.classList.contains("active") + + if (!partialResult || !partialResult.full || typeof author === "undefined" || !set) { + return + } + + const result = db.addResult({ + ...partialResult, + set, + author, + table: resultTable, + }) + if (active) { + activeResult = result + } + } + return [resultTable, activeResult] +} + +export function scrapeResponseLists(lists: HTMLElement): {db: RollTableDatabase, active: ReadonlyMap|null>}|undefined +export function scrapeResponseLists(lists: null): null +export function scrapeResponseLists(lists: HTMLElement|null): {db: RollTableDatabase, active: ReadonlyMap|null>}|null|undefined { + if (!lists) { + return null + } + const db = new RollTableDatabase() + const active = new Map|null> + for (const responseTypeElement of lists.querySelectorAll(`.responseType`)) { + const responseType = scrapeResponseList(responseTypeElement, db) + if (!responseType) { + return + } + const [table, activeResult] = responseType + active.set(table, activeResult) + } + return { + db, + active, + } +} diff --git a/src/client/template.ts b/src/client/template.ts new file mode 100644 index 0000000..cd5983c --- /dev/null +++ b/src/client/template.ts @@ -0,0 +1,142 @@ +import { + type ButtonFeatures, + ButtonType, + type CheckboxFeatures, + type ElementFeatures, + type FormFeatures, + HyperlinkDestination, + type HyperlinkFeatures, + type LabelFeatures, + type TemplateBuilder +} from '../common/template'; + +function tag(tagName: T, features: ElementFeatures, contents: Node[]): HTMLElementTagNameMap[T] { + const element = document.createElement(tagName) + if (typeof features.id !== "undefined") { + element.id = features.id + } + if (typeof features.classes !== "undefined") { + if (typeof features.classes === "string") { + element.classList.add(features.classes) + } else { + for (const className of features.classes) { + element.classList.add(className) + } + } + } + if (typeof features.data !== "undefined") { + for (const [key, value] of features.data) { + element.dataset[key] = value + } + } + for (const node of contents) { + element.appendChild(node) + } + return element +} + +class DOMTemplateBuilderImpl implements TemplateBuilder { + makeButton(features: ButtonFeatures, ...contents: Node[]): HTMLButtonElement { + const element = tag('button', features, contents) + element.classList.add("button") + element.type = features.type ?? ButtonType.Button + if (typeof features.name === "string") { + element.name = features.name + } + if (typeof features.value === "string") { + element.value = features.value + } + return element + } + + makeCheckbox(features: CheckboxFeatures, ...contents: Node[]): HTMLInputElement { + const element = tag('input', features, contents) + element.type = "checkbox" + element.name = features.name + if (features.checked) { + element.checked = true + } + if (typeof features.value === "string") { + element.value = features.value + } + return element + } + + makeDiv(features: ElementFeatures, ...contents: Node[]): HTMLDivElement { + return tag('div', features, contents); + } + + makeFooter(features: ElementFeatures, ...contents: Node[]): HTMLElement { + return tag('footer', features, contents); + } + + makeForm(features: FormFeatures, ...contents: Node[]): HTMLFormElement { + const element = tag('form', features, contents) + element.action = features.action + element.method = features.method + return element; + } + + makeHeader(features: ElementFeatures, ...contents: Node[]): HTMLElement { + return tag('header', features, contents) + } + + makeHeading1(features: ElementFeatures, ...contents: Node[]): HTMLHeadingElement { + return tag('h1', features, contents); + } + + makeHeading2(features: ElementFeatures, ...contents: Node[]): HTMLHeadingElement { + return tag('h2', features, contents); + } + + makeHyperlink(features: HyperlinkFeatures, ...contents: Node[]): HTMLAnchorElement { + const element = tag('a', features, contents) + element.href = features.url + if (features.destination === HyperlinkDestination.External) { + element.rel = "external nofollow noreferrer" + } + if (features.asButton) { + element.classList.add("button") + element.draggable = false + } + return element; + } + + makeLabel(features: LabelFeatures, ...contents: Node[]): HTMLLabelElement { + const element = tag('label', features, contents) + if (typeof features.forId === "string") { + element.htmlFor = features.forId + } + return element; + } + + makeListItem(features: ElementFeatures, ...contents: Node[]): HTMLLIElement { + return tag('li', features, contents) + } + + makeNav(features: ElementFeatures, ...contents: Node[]): HTMLElement { + return tag('nav', features, contents) + } + + makeNoscript(features: ElementFeatures, ...contents: Node[]): HTMLElement { + return tag('noscript', features, contents); + } + + makeParagraph(features: ElementFeatures, ...contents: Node[]): HTMLParagraphElement { + return tag('p', features, contents); + } + + makeSpan(features: ElementFeatures, ...contents: Node[]): HTMLSpanElement { + return tag('span', features, contents); + } + + makeText(text: string): Text { + return document.createTextNode(text); + } + + makeUnorderedList(features: ElementFeatures, ...contents: Node[]): HTMLUListElement { + return tag('ul', features, contents); + } +} + +export const DOMTemplateBuilder = new DOMTemplateBuilderImpl() diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index cac7504..2c45b00 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -31,7 +31,7 @@ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + "typeRoots": ["./types"], /* Specify multiple folders that act like `./node_modules/@types`. */ // "types": ["@cloudflare/workers-types/2023-07-01"] /* Specify type package names to be included without being referenced in a source file. */, // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "resolveJsonModule": true /* Enable importing .json files */, @@ -97,6 +97,12 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, /* Skip type checking all .d.ts files. */ + "jsx": "react-jsx", + "jsxImportSource": "preact", + "paths": { + "react": ["./node_modules/preact/compat/"], + "react-dom": ["./node_modules/preact/compat/"] + } } } diff --git a/src/client/types/customevent.d.ts b/src/client/types/customevent.d.ts new file mode 100644 index 0000000..4c2c642 --- /dev/null +++ b/src/client/types/customevent.d.ts @@ -0,0 +1,16 @@ +import type { RollTableDetailsAndResults, RollTableResultFull } from '../../common/rolltable'; +import type { RerollEventDetail } from '../generator-entrypoint'; + +interface CustomEventMap { + "resultselected": CustomEvent>; + "reroll": CustomEvent +} +declare global { + interface HTMLElement { + addEventListener(type: K, + listener: (this: Document, ev: CustomEventMap[K]) => void, + options?: boolean|EventListenerOptions): void; + dispatchEvent(ev: CustomEventMap[K]): boolean; + dispatchEvent(ev: HTMLElementEventMap[K]): boolean; + } +} diff --git a/src/common/rolltable.ts b/src/common/rolltable.ts index bef1ee8..1c34662 100644 --- a/src/common/rolltable.ts +++ b/src/common/rolltable.ts @@ -326,7 +326,7 @@ export class RollTableDatabase implements Iterable { } } - addResult(result: RollTableResultOrLookup|readonly [number, RollTableResultOrLookup]): RollTableResultFull { + addResult(result: RollTableResultOrLookup|readonly [number, RollTableResultOrLookup]): RollTableResultFull { if (isResultArray(result)) { const [, innerResult] = result as [number, RollTableResultOrLookup]; return this.addResult(innerResult); @@ -386,7 +386,7 @@ export class RollTableDatabase implements Iterable { } } -export function rollOn(table: RollTableDetailsAndResults): RollTableResult { +export function rollOn(table: RollTableDetailsAndResults): RollTableResultFull { const results = Array.from(table.resultsById.values()); if (results.length === 0) { throw Error(`no results for table ${table.identifier}`); @@ -453,9 +453,9 @@ export function exportScenario(contents: RollTableResult[], format: ExportFormat export function generatedStateToString(contents: GeneratedState): string { if (contents.final) { - return `Final state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${rollResultToString(value)}`).join(" ::: ")}` + return `Final state:\n${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)}\n\t${rollResultToString(value)}`).join("\n\n")}` } else { - return `Current state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${rollResultToString(value)}`).join(" ::: ")}. Selection: ${Array.from(contents.selected).map(v => `${rollTableToStringShort(v)}`).join(", ")}` + return `Current state:\n${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)}\n\t${rollResultToString(value)}`).join("\n\n")}\n\nSelection:\n\t${contents.selected.size > 0 ? Array.from(contents.selected).map(v => `${rollTableToStringShort(v)}`).join("\n\t") : "none"}` } } diff --git a/src/common/template.ts b/src/common/template.ts index 34ea78a..03da18a 100644 --- a/src/common/template.ts +++ b/src/common/template.ts @@ -1,170 +1,134 @@ import { - type RollTable, type RollTableAuthor, RollTableDatabase, type RollTableDetails, + type RollTable, + type RollTableDetails, type RollTableDetailsAndResults, - type RollTableResult, type RollTableResultFull, type RollTableResultSet + type RollTableResult } from './rolltable'; -import escapeHTML from 'escape-html'; -import slug from 'slug'; -export function htmlTableIdentifier(table: RollTable): string { - if (table.full) { - return slug(table.identifier); - } else { - return slug(table.header); - } -} - -export function buildFooter({ creditsUrl, includesResponses, includesGenerator }: { readonly creditsUrl: string, readonly includesResponses: boolean, readonly includesGenerator: boolean }): string { - return ` - `; -} - -export function buildAuthor({ author }: { readonly author: RollTableAuthor }): string { - if (author.url) { - return `${escapeHTML(author.relation)} ${escapeHTML(author.name)}`; - } else { - return `${escapeHTML(author.relation)} ${escapeHTML(author.name)}`; - } -} - -export function buildSet({ resultSet }: { readonly resultSet: RollTableResultSet }): string { - return `in ${resultSet.name ? 'the' : 'a'} ${resultSet.global ? 'global' : 'server-local'} set${resultSet.name ? ` ${escapeHTML(resultSet.name)}` : ''}`; -} - -export function buildResultAttribution({ result }: { readonly result: RollTableResultFull }): string { - return `${result.author ? buildAuthor({ author: result.author }) : ''}${buildSet({ resultSet: result.set })}`; -} +// TODO: port the rest of these to preact -export const selectedIdPrefix = 'selected-' -export function buildGeneratedElement({ result, selected }: { readonly result: RollTableResult, readonly selected: boolean|null }): string { - return ( -` - ${escapeHTML(result.table.emoji)} ${escapeHTML(result.table.title)}${selected !== null ? `` : ''} - ${escapeHTML(result.text)}${result.full ? buildResultAttribution({ result }) : ''} -`) +export function buildGeneratorPage>( + { results, generatorTargetUrl, clientId, creditsUrl, editable, selected, includesResponses, builder }: + { readonly results: ReadonlyMap, + readonly generatorTargetUrl: string, + readonly clientId: string, + readonly creditsUrl: string, + readonly editable: boolean, + readonly selected: ReadonlySet, + readonly includesResponses: boolean, + readonly builder: BuilderT}): ReturnType { + return builder.makeDiv( + {id: "generator", classes: "page"}, + builder.makeForm({method: FormMethod.Post, action: generatorTargetUrl, id: "generatorWindow", classes: ["window", "readable"]}, + builder.makeHeading2({id: "generatorHead"}, builder.makeText("Your generated scenario")), + builder.makeUnorderedList({id: "generatedScenario"}, + ...Array.from(results.values()).map(result => + buildGeneratedElement({ + result, + selected: (editable && includesResponses && result.table.full === 'results') ? selected.has(result.table) : null, + includesResponses, + builder}))), + builder.makeDiv({id: "generatorControls"}, + builder.makeDiv({id: "copyButtons", classes: ["buttons", "requiresJs", "jsPopupHost"]}, + builder.makeButton({id: "copyMD"}, builder.makeText("Markdown")), + builder.makeButton({id: "copyBB"}, builder.makeText("BBCode")), + builder.makeButton({id: "copyEmojiText"}, builder.makeText("Text + Emoji")), + builder.makeButton({id: "copyText"}, builder.makeText("Text Only")), + ), + ...(editable ? [builder.makeDiv({id: "rollButtons", classes: ["buttons"]}, + builder.makeButton({type: ButtonType.Submit, id: "reroll", name: "submit", value: "reroll"}, builder.makeText("Reroll Selected")), + builder.makeButton({id: "selectAll", classes: "requiresJs"}, builder.makeText("Select All")), + builder.makeButton({id: "selectNone", classes: "requiresJs"}, builder.makeText("Select None")), + )] : []), + builder.makeDiv({id: "scenarioButtons", classes: ["buttons"]}, + ...(editable ? [ + builder.makeHyperlink({id: "rerollAll", url: generatorTargetUrl, destination: HyperlinkDestination.Internal, asButton: true}, builder.makeText("New Scenario")), + builder.makeButton({type: ButtonType.Submit, id: "saveScenario", name: "submit", value: "saveScenario"}, builder.makeText("Get Scenario Link")) + ] : [ + builder.makeHyperlink({url: generatorTargetUrl, destination: HyperlinkDestination.Internal, asButton: true}, builder.makeText("Open in Generator")) + ]) + ), + ...(clientId !== '' || includesResponses ? [builder.makeDiv({id: "generatorLinks", classes: ["buttons"]}, + ...(clientId !== '' ? [builder.makeHyperlink( + { + url: `https://discord.com/api/oauth2/authorize?client_id=${ + encodeURIComponent(clientId)}&permissions=0&scope=applications.commands`, + destination: HyperlinkDestination.External, + asButton: true}, + builder.makeText("Add to Discord"))] : []), + ...(includesResponses ? [builder.makeHyperlink( + { + url: `#responses`, + destination: HyperlinkDestination.Internal, + asButton: true}, + builder.makeText("View Possible Responses"))] : []), + )] : []) + ) + ), + buildFooter({includesResponses, includesGenerator: true, creditsUrl, builder}) + ) as ReturnType } -export const submitName = "submit" -export const rerollId = "reroll" -export const rerollAllId = "rerollAll" -export const saveScenarioId = "saveScenario" -export const selectAllId = "selectAll" -export const selectNoneId = "selectNone" -export const copyMDID = "copyMD" -export const copyBBID = "copyBB" -export const copyEmojiTextID = "copyEmojiText" -export const copyTextID = "copyText" -export function buildGeneratorPage( - { results, generatorTargetUrl, clientId, creditsUrl, editable, selected, includesResponses }: - { readonly results: ReadonlyMap, readonly generatorTargetUrl: string, readonly clientId: string, readonly creditsUrl: string, readonly editable: boolean, readonly selected: ReadonlySet, readonly includesResponses: boolean }): string { - return ` - - - Your generated scenario - ${Array.from(results.values()).map(result => buildGeneratedElement({ result, selected: (editable && includesResponses && result.table.full === 'results') ? selected.has(result.table) : null })).join('')} - - - Markdown - BBCode - Text + Emoji - Text Only - - ${editable ? ` - Reroll Selected - Select All - Select None - ` : ''} - - ${editable - ? `New Scenario - Get Scenario Link` - : `Open in Generator`} - - ${clientId !== '' || includesResponses ? - ` - ${clientId !== '' ? `Add to Discord` : ''} - ${includesResponses ? `View Possible Responses` : ''} - ` : ''} - - - ${buildFooter({ includesResponses: includesResponses, includesGenerator: true, creditsUrl })} -`; +export function buildResponseTypeButton>({table, builder}: {readonly table: RollTableDetails, readonly builder: BuilderT}): ReturnType { + return builder.makeHyperlink({ + url: `#responses-${htmlTableIdentifier(table)}`, + destination: HyperlinkDestination.Internal, + asButton: true, + }, builder.makeText(`${table.emoji} ${table.name}`)) as ReturnType } -export function buildResponseTypeButton({table}: {readonly table: RollTableDetails}) { - return `${escapeHTML(table.emoji)} ${escapeHTML(table.name)}` +export function buildResponse>({result, active, includesGenerator, builder}: {readonly result: RollTableResult, readonly active: boolean, readonly includesGenerator: boolean, readonly builder: BuilderT}): ReturnType { + return builder.makeListItem( + { + id: result.full ? `response-${result.mappingId}` : undefined, + classes: ["response", "jsPopupHost", ...(active ? ["active"] : []), ...(result.full ? ["attributed"] : [])], + }, + builder.makeButton({classes: "resultText", data: buildResultData(result)}, builder.makeText(result.text)), + buildResultAttribution({ + result, + button: result.full && includesGenerator ? builder.makeButton({classes: ["makeResponseActive", "requiresJs"]}, builder.makeText("Set in Generated Scenario")) : undefined, + builder})) as ReturnType } -export function buildResultData(result: RollTableResult): string { - return result.full ? `data-mappingid="${result.mappingId}" data-textid="${result.textId}" data-updated="${result.updated.getTime()}"` : '' +export function buildResponseList>({table, activeResult, includesGenerator, builder}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult, readonly includesGenerator: boolean, readonly builder: BuilderT}): ReturnType { + return builder.makeListItem( + { + classes: ["responseType", "window", "readable"], + id: `responses-${htmlTableIdentifier(table)}` + }, + builder.makeHeading2( + { + classes: ["responseTypeHead", "tableHeader"], + data: buildTableData(table) + }, + builder.makeSpan({classes: "tableEmoji"}, builder.makeText(table.emoji)), + builder.makeText(' '), + builder.makeSpan({classes: "tableTitle"}, builder.makeText(table.title)), + ), + builder.makeUnorderedList({}, ...Array.from(table.resultsById.values()) + .map(result => + buildResponse({result, active: result === activeResult, includesGenerator, builder}))) + ) as ReturnType } -export function buildTableData(table: RollTable): string { - return `data-ordinal="${table.ordinal}" ${table.full - ? `data-id="${table.id}" data-identifier="${escapeHTML(table.identifier)}" data-name="${escapeHTML(table.name)}"` - : ''}` -} - -export function buildResponse({result, active}: {readonly result: RollTableResult, readonly active: boolean}) { - return ` - ${escapeHTML(result.text)} - ${result.full ? buildResultAttribution({result}) : ''} -` -} - -export function buildResponseList({table, activeResult}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult}) { - return ` - ${escapeHTML(table.emoji)} ${escapeHTML(table.title)} - - ${Array.from(table.resultsById.values()).map(result => buildResponse({result, active: result === activeResult})).join('')} - - ` -} - -export function buildResponsesPage( - { tables, results, creditsUrl, includesGenerator }: { +export function buildResponsesPage>( + { tables, results, creditsUrl, includesGenerator, builder }: { readonly tables: Iterable, readonly results?: ReadonlyMap, readonly creditsUrl: string, - readonly includesGenerator: boolean}): string { - return ` - - - Possible Responses - - ${Array.from(tables).map(table => buildResponseTypeButton({table})).join('')} - Return to Generator - - - - ${Array.from(tables).map(table => buildResponseList({table, activeResult: results?.get(table)})).join('')} - - ${buildFooter({ includesResponses: true, includesGenerator, creditsUrl })} - -