web in progress, porting to preact

main
Mari 2 months ago
parent 5b1308bdf3
commit f0a46781de
  1. 3
      .idea/ncc-gen.iml
  2. 86
      package-lock.json
  3. 5
      package.json
  4. 8
      src/build/bundler.ts
  5. 87
      src/client/basic-look.less
  6. 17
      src/client/combined-generator-responses-entrypoint.ts
  7. 65
      src/client/generator-entrypoint.less
  8. 192
      src/client/generator-entrypoint.ts
  9. 2
      src/client/popup.ts
  10. 2
      src/client/pulse.less
  11. 26
      src/client/pulse.ts
  12. 19
      src/client/responses-entrypoint.less
  13. 80
      src/client/responses-entrypoint.ts
  14. 89
      src/client/scraper.ts
  15. 142
      src/client/template.ts
  16. 10
      src/client/tsconfig.json
  17. 16
      src/client/types/customevent.d.ts
  18. 8
      src/common/rolltable.ts
  19. 270
      src/common/template.ts
  20. 39
      src/common/template/Attribution.less
  21. 73
      src/common/template/Attribution.tsx
  22. 0
      src/common/template/AttributionAuthor.less
  23. 28
      src/common/template/AttributionAuthor.tsx
  24. 0
      src/common/template/AttributionSet.less
  25. 26
      src/common/template/AttributionSet.tsx
  26. 33
      src/common/template/Button.less
  27. 33
      src/common/template/Button.tsx
  28. 63
      src/common/template/GeneratedElement.less
  29. 30
      src/common/template/GeneratedElement.tsx
  30. 10
      src/common/template/GeneratedResult.less
  31. 64
      src/common/template/GeneratedResult.tsx
  32. 7
      src/common/template/PageFooter.less
  33. 35
      src/common/template/PageFooter.tsx
  34. 37
      src/common/template/ResultText.less
  35. 38
      src/common/template/ResultText.tsx
  36. 14
      src/common/template/TableHeader.less
  37. 73
      src/common/template/TableHeader.tsx
  38. 2
      src/common/template/util.ts
  39. 9
      src/common/tsconfig.json
  40. 9
      src/server/tsconfig.json
  41. 27
      src/server/web/router.ts
  42. 131
      src/server/web/template.ts

@ -5,6 +5,9 @@
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/.wrangler" />
<excludeFolder url="file://$MODULE_DIR$/src/server/web/bundles" />
<excludePattern pattern="package-lock.json" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

86
package-lock.json generated

@ -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",

@ -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"
}

@ -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<string, Sourc
continue;
}
if (ent.name.endsWith(LESS_SUFFIX)) {
css.set(camelcase(ent.name.substring(0, ent.name.length - LESS_SUFFIX.length)), hashBundled(await processLess(join(inDir, ent.name))));
css.set(camelCase(ent.name.substring(0, ent.name.length - LESS_SUFFIX.length)), hashBundled(await processLess(join(inDir, ent.name))));
} else if (ent.name.endsWith(TS_SUFFIX)) {
const {cache: newCache, bundle} = await processTypescript(join(inDir, ent.name), inDir, cache)
cache = newCache
js.set(camelcase(ent.name.substring(0, ent.name.length - TS_SUFFIX.length)), hashBundled(bundle));
js.set(camelCase(ent.name.substring(0, ent.name.length - TS_SUFFIX.length)), hashBundled(bundle));
} else {
// continue;
}

@ -6,30 +6,14 @@ body {
}
.window {
background-color: #f8f7e0;
background-color: #f8f7f0;
padding: 1rem;
border: 0.1rem solid black;
border-radius: 0.5rem;
box-sizing: border-box;
}
.tableHeader {
font-size: 1.25rem;
font-weight: bold;
display: flex;
justify-content: stretch;
align-items: baseline;
margin-bottom: 0;
}
.tableEmoji {
font-size: 1.75rem;
padding-right: 0.5rem;
user-select: text;
}
.page {
user-select: contain;
}
.page * {
@ -59,75 +43,6 @@ li {
}
}
.button {
outline: none;
border: none;
padding: 0.5rem;
font-size: 1rem;
text-align: center;
text-decoration: none;
color: inherit;
font-family: inherit;
background-color: lightgray;
cursor: pointer;
user-select: none;
border-radius: 0.8rem 0.4rem;
box-shadow: 0 0 black;
transform: none;
transition: background-color 0.1s ease, transform 0.1s ease, box-shadow 0.1s ease;
&:hover, &:focus {
background-color: darkgray;
box-shadow: -0.2rem 0.2rem black;
transform: translate(0.2rem, -0.2rem);
}
&:active {
box-shadow: 0 0 black;
transform: none;
}
}
footer {
display: block;
margin: 0.75rem 0 0 0;
font-size: 0.75rem;
user-select: none;
}
.resultText {
flex: 1 1 auto;
appearance: none;
background-color: transparent;
color: inherit;
font-size: inherit;
font-family: inherit;
outline: 0;
border: 0;
padding: 0.2rem 0.5rem;
cursor: pointer;
text-align: left;
word-wrap: normal;
width: 100%;
box-sizing: border-box;
white-space: normal;
user-select: text;
transition: background-color 0.2s ease;
border-radius: 0.3rem;
}
.resultText:hover {
background-color: #BFBFBF60;
}
.resultText:active, .resultText:focus {
background-color: #9F9FFF90;
}
footer {
text-align: center;
}
@keyframes popup {
from {
transform: scale(0);

@ -1,4 +1,19 @@
import './generator-entrypoint'
import {responseLists, db} from './responses-entrypoint'
import {prepareGenerator} from './generator-entrypoint'
Promise.all([prepareGenerator(db), responseLists]).then(([gen, res]) => {
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) {

@ -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

@ -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<RollTableResultFull<RollTableDetailsAndResults>>
fullResults: ReadonlyMap<RollTable, RollTableResult>
selections: ReadonlySet<RollTable>
}
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<HTMLInputElement>) {
for (const check of this.scenario.querySelectorAll('input[type=checkbox]:not(:checked)') as Iterable<HTMLInputElement>) {
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<HTMLInputElement>) {
for (const check of this.scenario.querySelectorAll('input[type=checkbox]:checked') as Iterable<HTMLInputElement>) {
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<RollTableDetailsAndResults>[] = []
for (const row of this.scenario.querySelectorAll(`.generatedElement`)) {
const check = row.querySelector<HTMLInputElement>(`input.generatedSelect:checked`)
const text = row.querySelector<HTMLElement>(`.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<RerollEventDetail>("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<HTMLButtonElement>(`.resultText`)!
pulseElement(button)
this.rolled.add(result)
}
private changeSelection(generatedElement: HTMLElement, selected: boolean) {
const check = generatedElement.querySelector<HTMLInputElement>(`.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<void> {
@ -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<HTMLButtonElement>(`#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<HTMLElement>(".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<HTMLInputElement>("input[type=checkbox]:checked")
if (check) {
check.checked = false
pulseElement(check)
}
const text = row.querySelector<HTMLElement>(".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<HTMLInputElement>(".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<HTMLButtonElement>(`#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<RollTableDetailsAndResults>, 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<RerollEventDetail>) => 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<RollTableDatabase>): 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))

@ -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")

@ -1,12 +1,10 @@
@keyframes pulse-bg {
from {
background-color: transparent;
}
10% {
background-color: #60606060;
}
to {
background-color: transparent;
}
}

@ -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)
}

@ -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;
}
}

@ -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<RollTableResultFull<RollTableDetailsAndResults>>) => 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<RollTableResultFull<RollTableDetailsAndResults>>("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<RollTableDetailsAndResults>) {
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<HTMLElement>(`#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<ResponseList> = DOMLoaded.then(() => initResponseList())
export const db: Promise<RollTableDatabase> = responseList.then(r => r.db)
export const responseLists: Promise<ResponseLists> = DOMLoaded.then(() => initResponseList())
export const db: Promise<RollTableDatabase> = responseLists.then(r => r.db)

@ -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<HTMLAnchorElement>("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<HTMLElement>(".generatedElement")) {
for (const item of scenario.querySelectorAll<HTMLElement>(`.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<RollTableDetailsAndResults>|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<RollTableDetailsAndResults>|null = null
for (const resultElement of responseTypeElement.querySelectorAll<HTMLElement>(`.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<RollTableDetailsAndResults, RollTableResultFull<RollTableDetailsAndResults>|null>}|undefined
export function scrapeResponseLists(lists: null): null
export function scrapeResponseLists(lists: HTMLElement|null): {db: RollTableDatabase, active: ReadonlyMap<RollTableDetailsAndResults, RollTableResultFull<RollTableDetailsAndResults>|null>}|null|undefined {
if (!lists) {
return null
}
const db = new RollTableDatabase()
const active = new Map<RollTableDetailsAndResults, RollTableResultFull<RollTableDetailsAndResults>|null>
for (const responseTypeElement of lists.querySelectorAll<HTMLElement>(`.responseType`)) {
const responseType = scrapeResponseList(responseTypeElement, db)
if (!responseType) {
return
}
const [table, activeResult] = responseType
active.set(table, activeResult)
}
return {
db,
active,
}
}

@ -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<T extends keyof HTMLElementTagNameMap>(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<Node> {
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()

@ -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/"]
}
}
}

@ -0,0 +1,16 @@
import type { RollTableDetailsAndResults, RollTableResultFull } from '../../common/rolltable';
import type { RerollEventDetail } from '../generator-entrypoint';
interface CustomEventMap {
"resultselected": CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>;
"reroll": CustomEvent<RerollEventDetail>
}
declare global {
interface HTMLElement {
addEventListener<K extends keyof CustomEventMap>(type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void,
options?: boolean|EventListenerOptions): void;
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): boolean;
dispatchEvent<K extends keyof HTMLElementEventMap>(ev: HTMLElementEventMap[K]): boolean;
}
}

@ -326,7 +326,7 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
}
}
addResult(result: RollTableResultOrLookup<RollTableDetailsOrInput>|readonly [number, RollTableResultOrLookup<RollTableDetailsOrInput>]): RollTableResultFull {
addResult(result: RollTableResultOrLookup<RollTableDetailsOrInput>|readonly [number, RollTableResultOrLookup<RollTableDetailsOrInput>]): RollTableResultFull<RollTableDetailsAndResults> {
if (isResultArray(result)) {
const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>];
return this.addResult(innerResult);
@ -386,7 +386,7 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
}
}
export function rollOn(table: RollTableDetailsAndResults): RollTableResult<RollTableDetailsAndResults> {
export function rollOn(table: RollTableDetailsAndResults): RollTableResultFull<RollTableDetailsAndResults> {
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"}`
}
}

@ -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 `
<footer>
${includesGenerator ? `<noscript><p>⚠ Certain features - copy, select-all/select-none${ includesResponses ? ', reroll offline, select response' : ''} - are currently disabled because JavaScript is disabled.</p></noscript>` : '' }
${includesGenerator && includesResponses ? `<p class="requiresJs">💡 You can save this page to be able to generate scenarios offline!</p>` : ''}
<p>
<a href="${encodeURI(creditsUrl)}" rel="external help noreferrer">Project credits/instructions/source code</a>
</p>
</footer>`;
}
export function buildAuthor({ author }: { readonly author: RollTableAuthor }): string {
if (author.url) {
return `<div class="author" data-id="${escapeHTML(`${author.id}`)}"><span class="authorRelation">${escapeHTML(author.relation)}</span> <span class="authorName"><a href="${encodeURI(author.url)}" rel="external nofollow noreferrer">${escapeHTML(author.name)}</a></span></div>`;
} else {
return `<div class="author" data-id="${escapeHTML(`${author.id}`)}"><span class="authorRelation">${escapeHTML(author.relation)}</span> <span class="authorName">${escapeHTML(author.name)}</span></div>`;
}
}
export function buildSet({ resultSet }: { readonly resultSet: RollTableResultSet }): string {
return `<div class="resultSet" data-id="${escapeHTML(`${resultSet.id}`)}" data-global="${resultSet.global ? 'true' : 'false'}"><span class="setRelation">in ${resultSet.name ? 'the' : 'a'} ${resultSet.global ? 'global' : 'server-local'} set</span>${resultSet.name ? ` <span class="setName">${escapeHTML(resultSet.name)}</span>` : ''}</div>`;
}
export function buildResultAttribution({ result }: { readonly result: RollTableResultFull<RollTable> }): string {
return `<div class="attribution"><div class="attributionBubble">${result.author ? buildAuthor({ author: result.author }) : ''}${buildSet({ resultSet: result.set })}</div></div>`;
}
// 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 (
`<li class="generatedElement">
<h2 class="generatedHead"><label class="generatedLabel tableHeader" ${buildTableData(result.table)} ${result.table.full === 'results' ? `for="${selectedIdPrefix}${htmlTableIdentifier(result.table)}"` : ''}><span class="tableEmoji">${escapeHTML(result.table.emoji)}</span> <span class="tableTitle">${escapeHTML(result.table.title)}</span></label>${selected !== null ? `<input class="generatedSelect" id="${selectedIdPrefix}${htmlTableIdentifier(result.table)}" name="${selectedIdPrefix}${htmlTableIdentifier(result.table)}" type="checkbox" ${selected ? 'checked' : ''} />` : ''}</h2>
<div class="generated${result.full ? ' attributed' : ''}"><button type="button" class="resultText" ${buildResultData(result)}>${escapeHTML(result.text)}</button>${result.full ? buildResultAttribution({ result }) : ''}</div>
</li>`)
export function buildGeneratorPage<T, BuilderT extends TemplateBuilder<T>>(
{ results, generatorTargetUrl, clientId, creditsUrl, editable, selected, includesResponses, builder }:
{ readonly results: ReadonlyMap<RollTable, RollTableResult>,
readonly generatorTargetUrl: string,
readonly clientId: string,
readonly creditsUrl: string,
readonly editable: boolean,
readonly selected: ReadonlySet<RollTable>,
readonly includesResponses: boolean,
readonly builder: BuilderT}): ReturnType<BuilderT["makeDiv"]> {
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<BuilderT["makeDiv"]>
}
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<RollTable, RollTableResult>, readonly generatorTargetUrl: string, readonly clientId: string, readonly creditsUrl: string, readonly editable: boolean, readonly selected: ReadonlySet<RollTable>, readonly includesResponses: boolean }): string {
return `
<div id="generator" class="page">
<form method="post" action="${encodeURI(generatorTargetUrl)}" id="generatorWindow" class="window readable">
<h2 id="generatorHead">Your generated scenario</h2>
<ul id="generatedScenario">${Array.from(results.values()).map(result => buildGeneratedElement({ result, selected: (editable && includesResponses && result.table.full === 'results') ? selected.has(result.table) : null })).join('')}</ul>
<div id="generatorControls">
<div id="copyButtons" class="buttons requiresJs jsPopupHost">
<button type="button" class="button" id="${copyMDID}">Markdown</button>
<button type="button" class="button" id="${copyBBID}">BBCode</button>
<button type="button" class="button" id="${copyEmojiTextID}">Text + Emoji</button>
<button type="button" class="button" id="${copyTextID}">Text Only</button>
</div>
${editable ? `<div id="rollButtons" class="buttons jsPopupHost">
<button type="submit" class="button" id="${rerollId}" name="${submitName}" value="${rerollId}">Reroll Selected</button>
<button type="button" class="button requiresJs" id="${selectAllId}">Select All</button>
<button type="button" class="button requiresJs" id="${selectNoneId}">Select None</button>
</div>` : ''}
<div id="scenarioButtons" class="buttons jsPopupHost">
${editable
? `<a href="${encodeURI(generatorTargetUrl)}" class="button" id="${rerollAllId}" draggable="false">New Scenario</a>
<button type="submit" class="button" id="${saveScenarioId}" name="${submitName}" value="${saveScenarioId}">Get Scenario Link</button>`
: `<a href="${encodeURI(generatorTargetUrl)}" class="button" draggable="false">Open in Generator</a>`}
</div>
${clientId !== '' || includesResponses ?
`<div id="generatorLinks" class="buttons jsPopupHost">
${clientId !== '' ? `<a href="https://discord.com/api/oauth2/authorize?client_id=${encodeURIComponent(clientId)}&permissions=0&scope=applications.commands" class="button" rel="external nofollow noreferrer" draggable="false">Add to Discord</a>` : ''}
${includesResponses ? `<a href="#responses" class="button" id="responsesLink" draggable="false">View Possible Responses</a>` : ''}
</div>` : ''}
</div>
</form>
${buildFooter({ includesResponses: includesResponses, includesGenerator: true, creditsUrl })}
</div>`;
export function buildResponseTypeButton<T, BuilderT extends TemplateBuilder<T>>({table, builder}: {readonly table: RollTableDetails, readonly builder: BuilderT}): ReturnType<BuilderT["makeHyperlink"]> {
return builder.makeHyperlink({
url: `#responses-${htmlTableIdentifier(table)}`,
destination: HyperlinkDestination.Internal,
asButton: true,
}, builder.makeText(`${table.emoji} ${table.name}`)) as ReturnType<BuilderT["makeHyperlink"]>
}
export function buildResponseTypeButton({table}: {readonly table: RollTableDetails}) {
return `<a href="#responses-${htmlTableIdentifier(table)}" class="button" draggable="false">${escapeHTML(table.emoji)} ${escapeHTML(table.name)}</a>`
export function buildResponse<T, BuilderT extends TemplateBuilder<T>>({result, active, includesGenerator, builder}: {readonly result: RollTableResult, readonly active: boolean, readonly includesGenerator: boolean, readonly builder: BuilderT}): ReturnType<BuilderT["makeListItem"]> {
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<BuilderT["makeListItem"]>
}
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<T, BuilderT extends TemplateBuilder<T>>({table, activeResult, includesGenerator, builder}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult, readonly includesGenerator: boolean, readonly builder: BuilderT}): ReturnType<BuilderT["makeListItem"]> {
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<BuilderT["makeListItem"]>
}
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 `<li class="response${active ? ' active' : ''}${result.full ? ' attributed' : ''} jsPopupHost">
<button type="button" class="resultText" ${buildResultData(result)}>${escapeHTML(result.text)}</button>
${result.full ? buildResultAttribution({result}) : ''}
</li>`
}
export function buildResponseList({table, activeResult}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult}) {
return `<li class="responseType window readable" id="responses-${htmlTableIdentifier(table)}">
<h2 class="responseTypeHead tableHeader" ${buildTableData(table)}><span class="tableEmoji">${escapeHTML(table.emoji)}</span> <span class="tableTitle">${escapeHTML(table.title)}</span></h2>
<ul>
${Array.from(table.resultsById.values()).map(result => buildResponse({result, active: result === activeResult})).join('')}
</ul>
</li>`
}
export function buildResponsesPage(
{ tables, results, creditsUrl, includesGenerator }: {
export function buildResponsesPage<T, BuilderT extends TemplateBuilder<T>>(
{ tables, results, creditsUrl, includesGenerator, builder }: {
readonly tables: Iterable<RollTableDetailsAndResults>,
readonly results?: ReadonlyMap<RollTable, RollTableResult>,
readonly creditsUrl: string,
readonly includesGenerator: boolean}): string {
return `
<div id="responses" class="page">
<header id="responsesHeader" class="window head">
<h1 id="responsesHead">Possible Responses</h1>
<nav class="buttons" id="responsesHeaderNav">
${Array.from(tables).map(table => buildResponseTypeButton({table})).join('')}
<a id="returnToGenerator" href="#generator" class="button" draggable="false">Return to Generator</a>
</nav>
</header>
<ul id="responseLists">
${Array.from(tables).map(table => buildResponseList({table, activeResult: results?.get(table)})).join('')}
</ul>
${buildFooter({ includesResponses: true, includesGenerator, creditsUrl })}
</div>
</body>
</html>`;
}
export function wrapPage(
{ title, bodyContent, script, styles, noscriptStyles }:
{ readonly title: string, readonly bodyContent: string, readonly script: string, readonly styles: string, readonly noscriptStyles: string }): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${title}</title>
<script>${script}</script>
<style>${styles}</style>
<noscript><style>${noscriptStyles}</style></noscript>
</head>
<body>
${bodyContent}
</body>
</html>`;
readonly includesGenerator: boolean,
readonly builder: BuilderT}): ReturnType<BuilderT["makeDiv"]> {
return builder.makeDiv({id: "responses", classes: "page"},
builder.makeHeader({id: "responsesHeader", classes: "window"},
builder.makeHeading1({id: "responsesHead"}, builder.makeText("Possible Responses")),
builder.makeNav({id: "responsesHeaderNav", classes: "buttons"},
...Array.from(tables).map(table => buildResponseTypeButton({table, builder})),
builder.makeHyperlink({url: `#generator`, destination: HyperlinkDestination.Internal, asButton: true, id: "returnToGenerator"}, builder.makeText("Return to Generator"))
),
),
builder.makeUnorderedList({id: "responseLists"},
...Array.from(tables).map(table =>
buildResponseList({table, activeResult: results?.get(table), includesGenerator, builder}))),
buildFooter({builder, creditsUrl, includesResponses: true, includesGenerator}),
) as ReturnType<BuilderT["makeDiv"]>
}

@ -1,3 +1,6 @@
@import "AttributionAuthor";
@import "AttributionSet";
.attributed {
position: relative;
}
@ -23,13 +26,15 @@
position: relative;
margin-bottom: 0.5rem;
font-size: 1rem;
padding: 0.5rem;
padding: 0.75rem;
border-radius: 0.5rem;
box-sizing: border-box;
transform: scale(0);
transform-origin: bottom center;
transition: opacity 0.25s ease, transform 0.25s ease;
transition-delay: 250ms;
transition-property: opacity, transform;
transition-duration: 250ms, 250ms;
transition-timing-function: ease, ease;
transition-delay: 0ms, 0ms;
pointer-events: initial;
user-select: none;
}
@ -37,6 +42,10 @@
user-select: none;
}
.attribution .button {
margin-top: 0.5rem;
}
.attributionBubble::after {
content: "";
position: absolute;
@ -48,44 +57,36 @@
border-color: black transparent transparent transparent;
}
.attributed:hover, .attributed:focus-within {
.attributed:focus-within {
user-select: text;
}
.attributed:hover .attributionBubble {
transition-delay: 1.0s;
}
.attributed:focus-within .attributionBubble {
transition-delay: 0s;
}
.attributed:hover .attributionBubble, .attributed:focus-within .attributionBubble {
.attributed:focus-within .attributionBubble, .attributed .attributionBubble:hover {
opacity: 100%;
transform: none;
user-select: text;
}
.attributed:hover .attributionBubble *, .attributed:focus-within .attributionBubble * {
.attributed:focus-within .attributionBubble *, .attributed .attributionBubble:hover * {
user-select: text;
}
.attributionBubble a {
.attributionBubble a:not(.button) {
transition: color 300ms ease;
}
.attributionBubble a:link {
.attributionBubble a:not(.button):link {
color: aquamarine;
}
.attributionBubble a:visited {
.attributionBubble a:not(.button):visited {
color: mediumaquamarine;
}
.attributionBubble a:focus, .attributionBubble a:hover {
.attributionBubble a:not(.button):focus, .attributionBubble a:not(.button):hover {
color: lightcyan;
}
.attributionBubble a:active {
.attributionBubble a:not(.button):active {
color: aqua;
}

@ -0,0 +1,73 @@
import type {
RollTableAuthor,
} from '../rolltable';
import {
AttributionAuthor,
reconstituteAttributionAuthor,
reconstituteAttributionAuthorIfExists
} from './AttributionAuthor';
import { Fragment } from 'preact';
import {
AttributionSet,
type AttributionSetProps,
reconstituteAttributionSet,
reconstituteAttributionSetIfExists
} from './AttributionSet';
import type { PropsWithChildren } from 'preact/compat';
export interface AttributionPropsFull {
author: RollTableAuthor|null
set: AttributionSetProps
}
export interface AttributionPropsEmpty {
author?: null
set?: null
}
export type AttributionProps = AttributionPropsFull|AttributionPropsEmpty
export interface PartialAttributionPropsFull {
author?: Partial<RollTableAuthor>|null
set?: Partial<AttributionSetProps>
}
export interface PartialAttributionPropsEmpty {
author?: null
set?: null
}
export type PartialAttributionProps = PartialAttributionPropsFull|PartialAttributionPropsEmpty
export function reconstituteAttribution(div: HTMLDivElement, partial?: PartialAttributionProps): AttributionProps {
const set = reconstituteAttributionSetIfExists(
div.querySelector<HTMLParagraphElement>(".resultSet"),
partial?.set)
const author = reconstituteAttributionAuthorIfExists(
div.querySelector<HTMLParagraphElement>(".author"),
partial?.author)
if (!set) {
return {}
} else {
return {
set: set,
author: author,
}
}
}
export function Attribution({author, set, children}: AttributionProps & PropsWithChildren) {
return <div class="attribution">
<div class="attributionBubble">
{set
? <Fragment>
{author && <AttributionAuthor {...author} />}
<AttributionSet {...set} />
</Fragment>
: <p>
<span>Authorship unknown</span>
</p>}
{children}
</div>
</div>
}

@ -0,0 +1,28 @@
import type { RollTableAuthor } from '../rolltable';
export function reconstituteAttributionAuthorIfExists(element: HTMLParagraphElement | null, partial?: Partial<RollTableAuthor>|null): RollTableAuthor|null {
if (!element || partial === null) {
return null
}
return reconstituteAttributionAuthor(element, partial)
}
export function reconstituteAttributionAuthor(p: HTMLParagraphElement, partial?: Partial<RollTableAuthor>): RollTableAuthor {
return {
id: partial?.id ?? parseInt(p.dataset.id!!),
name: partial?.name ?? p.querySelector<HTMLElement>(".authorName")!.innerText,
url: typeof partial?.url !== "undefined" ? partial.url : (p.querySelector<HTMLAnchorElement>(".authorUrl")?.href ?? null),
relation: partial?.relation ?? p.querySelector<HTMLElement>(".authorRelation")!.innerText,
}
}
export function AttributionAuthor({ relation, id, url, name }: RollTableAuthor) {
return <p class="author" data-id={id}>
<span class="authorRelation">{relation}</span>
{" "}
<span class="authorName">{url
? <a class="authorUrl" href={url} rel="external nofollow noreferrer">{name}</a>
: name
}</span>
</p>
}

@ -0,0 +1,26 @@
import type { RollTableResultSet } from '../rolltable';
import { Fragment } from 'preact';
export type AttributionSetProps = Pick<RollTableResultSet, "name"|"id"|"global">
export function reconstituteAttributionSetIfExists(element: HTMLParagraphElement | null, partial?: Partial<AttributionSetProps>|null): AttributionSetProps|null {
if (!element || partial === null) {
return null
}
return reconstituteAttributionSet(element, partial)
}
export function reconstituteAttributionSet(p: HTMLParagraphElement, partial?: Partial<AttributionSetProps>): AttributionSetProps {
return {
id: partial?.id ?? parseInt(p.dataset.id!!),
name: partial?.name ?? p.querySelector<HTMLElement>(".setName")?.innerText ?? null,
global: partial?.global ?? p.classList.contains('global'),
}
}
export function AttributionSet({global, name, id}: AttributionSetProps) {
return <p class={`resultSet${global ? ' global' : ''}`} data-id={id}>
<span class="setRelation">in {name ? 'the' : 'a'} {global ? 'global' : 'server-local'} set</span>
{name && <Fragment>{' '}<span class="setName">{name}</span></Fragment>}
</p>
}

@ -0,0 +1,33 @@
.button {
border: none;
padding: 0.5rem;
font-size: 1rem;
text-align: center;
text-decoration: none;
color: black;
font-family: inherit;
background-color: lightgray;
cursor: pointer;
user-select: none;
border-radius: 0.8rem 0.4rem;
box-shadow: 0 0 black;
transform: none;
transition: background-color 0.1s ease, transform 0.1s ease, box-shadow 0.1s ease;
&:disabled {
background-color: slategray;
color: #333;
cursor: inherit;
}
&:not(:disabled):hover, &:not(:disabled):focus {
background-color: darkgray;
box-shadow: -0.2rem 0.2rem black;
transform: translate(0.2rem, -0.2rem);
}
&:not(:disabled):active {
box-shadow: 0 0 black;
transform: none;
}
}

@ -0,0 +1,33 @@
import type { PropsWithChildren } from 'preact/compat';
export interface LinkButtonProps {
"class"?: string
type?: "link"
href: string
external?: boolean
}
export interface FormButtonProps {
"class"?: string
type: HTMLButtonElement["type"]
href?: null
external?: null
}
export type ButtonProps = LinkButtonProps|FormButtonProps
export function Button({"class": className, type, href, external, children}: ButtonProps & PropsWithChildren) {
if (href) {
return <a
class={`button${className ? " " + className : ""}`}
href={href}
{...(external ? {rel: "external nofollow noreferrer"} : {})}
draggable={false}>
{children}
</a>
} else {
return <button type={type} class={`button${className ? " " + className : ""}`}>
{children}
</button>
}
}

@ -0,0 +1,63 @@
@import "TableHeader";
@import "GeneratedResult";
.generatedElement {
list-style: none;
}
.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;
}
.generatedHead:hover .generatedSelect, .generatedHead .generatedSelect:focus {
background-color: #90909030;
border: 0.1rem solid darkgray;
filter: brightness(105%) saturate(105%);
transform: scale(120%) rotate(15deg);
}
.generatedHead .generatedSelect:active {
filter: brightness(80%) saturate(80%);
transform: scale(80%) rotate(30deg);
}
.generatedSelect {
flex: 0 0 auto;
appearance: none;
cursor: pointer;
font-size: 1.5rem;
margin: 0;
transition: filter 0.3s ease, transform 0.3s ease, border-bottom-width 0.3s ease, border-top-width 0.3s ease, border-left-width 0.3s ease, border-right-width 0.3s ease;
width: 2rem;
height: 2rem;
text-align: center;
line-height: 2rem;
border-radius: 1rem;
&::after {
content: '🔒'
}
&:checked::after {
content: '🎲';
}
}

@ -0,0 +1,30 @@
import { TableEmoji, TableHeaderDataset, tableIdentifier, type TableProps, TableTitle } from './TableHeader';
import { GeneratedResult, type GeneratedResultProps } from './GeneratedResult';
export type GeneratedElementProps = {
table: TableProps
selected: boolean|null
} & GeneratedResultProps
// TODO: reconstitute this
// TODO: get a callback for checkbox value changes
export function GeneratedElement(props: GeneratedElementProps) {
const checkId = `selected-${tableIdentifier(props.table)}`
return <li class="generatedElement" id={`generated-${tableIdentifier(props.table)}`}>
<h2 class="generatedHead">
<label
class="generatedLabel tableHeader"
{...(props.selected !== null ? {"for": checkId} : {})}
{...TableHeaderDataset(props.table)}>
<TableEmoji {...props.table} />
{' '}
<TableTitle {...props.table} />
</label>
{props.selected !== null
? <input type="checkbox" class="generatedSelect" id={checkId} name={checkId} checked={props.selected} />
: null}
</h2>
<GeneratedResult {...props} />
</li>
}

@ -0,0 +1,10 @@
@import "Attribution";
@import "ResultText";
.generatedResult {
margin: 0;
padding: 0;
appearance: none;
font: inherit;
border: 0;
}

@ -0,0 +1,64 @@
import {
Attribution,
type AttributionPropsEmpty, type AttributionPropsFull,
type PartialAttributionProps, type PartialAttributionPropsEmpty,
reconstituteAttribution,
} from './Attribution';
import { responseIdPrefix } from './util';
import {
reconstituteResultText,
ResultText,
type ResultTextPropsFull,
type ResultTextPropsLimited
} from './ResultText';
import { Button } from './Button';
export type GeneratedResultPropsFull = {
includesResponses: boolean
} & AttributionPropsFull & ResultTextPropsFull
export type GeneratedResultPropsLimited = {
includesResponses?: null
} & AttributionPropsEmpty & ResultTextPropsLimited
export type GeneratedResultProps = GeneratedResultPropsFull|GeneratedResultPropsLimited
export type PartialGeneratedResultPropsFull = {
includesResponses?: boolean
} & PartialAttributionProps & Partial<ResultTextPropsFull>
export type PartialGeneratedResultPropsLimited = {
includesResponses?: null
} & PartialAttributionPropsEmpty & Partial<ResultTextPropsLimited>
export type PartialGeneratedResultProps = PartialGeneratedResultPropsFull|PartialGeneratedResultPropsLimited
export function reconstituteGeneratedResult(div: HTMLDivElement, partial?: PartialGeneratedResultProps): GeneratedResultProps {
const result =
reconstituteResultText(div.querySelector(".resultText")!, partial)
const attribution =
reconstituteAttribution(div.querySelector(".attribution")!, partial)
if (result.updated && attribution.set) {
return {
includesResponses: !!div.querySelector(".jumpToResponse"),
...attribution,
...result,
}
} else {
return {
text: result.text
}
}
}
export function GeneratedResult(props: GeneratedResultProps) {
return <div class="generatedResult attributed">
<ResultText {...props} />
<Attribution {...props}>
{props.includesResponses
? <p><Button class={"jumpToResponse"} href={`#${responseIdPrefix}${props.mappingId}`}>Jump to Result in List</Button></p>
: null}
</Attribution>
</div>
}

@ -0,0 +1,7 @@
footer {
display: block;
margin: 0.75rem 0 0 0;
font-size: 0.75rem;
user-select: none;
text-align: center;
}

@ -0,0 +1,35 @@
import { Fragment } from 'preact';
export interface PageFooterProps {
creditsUrl: string
includesResponses: boolean
includesGenerator: boolean
}
export function reconstituteFooterProps(footer: HTMLElement, partial: Partial<PageFooterProps> = {}): PageFooterProps {
return {
creditsUrl: partial.creditsUrl ?? footer.querySelector<HTMLAnchorElement>(".creditsLink")!.href,
includesResponses: partial.includesResponses ?? footer.querySelector<HTMLElement>(".jsOffHint") !== null,
includesGenerator: partial.includesGenerator ?? footer.querySelector<HTMLElement>(".saveHint") !== null,
}
}
export function PageFooter({creditsUrl, includesGenerator, includesResponses}: PageFooterProps) {
return <footer>
{includesGenerator
? <Fragment>
<noscript class="jsOffHint">
<p> Certain features - copy, select-all/select-none{
includesResponses ? ", reroll offline, select response" : ""
} - are currently disabled because JavaScript is disabled.</p>
</noscript>
{includesResponses
? <p class="requiresJs saveHint">💡 You can save this page to be able to generate scenarios offline!</p>
: null}
</Fragment>
: null}
<p>
<a class="creditsLink" href={creditsUrl} rel="external nofollow noreferrer">Project credits/instructions/source code/license</a>
</p>
</footer>
}

@ -0,0 +1,37 @@
.resultText {
flex: 1 1 auto;
appearance: none;
background-color: transparent;
color: inherit;
font-size: inherit;
font-family: inherit;
text-decoration: none;
border: 0;
padding: 0.2rem 0.5rem;
cursor: pointer;
text-align: left;
word-wrap: normal;
display: block;
width: 100%;
box-sizing: border-box;
white-space: normal;
user-select: text;
transition: background-color 0.2s ease;
border-radius: 0.3rem;
&:hover:not(:active) {
background-color: #BFBFBF60;
}
&:focus:not(:active) {
background-color: #9F9FFF90;
}
&:focus:hover:not(:active) {
background-color: #8F8FDF90;
}
&:active {
background-color: #3F3FFFA0;
}
}

@ -0,0 +1,38 @@
export interface ResultTextPropsBase {
text: string
}
export interface ResultTextPropsFull extends ResultTextPropsBase {
mappingId: number
textId: number
updated: Date
}
export interface ResultTextPropsLimited extends ResultTextPropsBase {
mappingId?: null
textId?: null
updated?: null
}
export function reconstituteResultText(button: HTMLButtonElement, partial: Partial<ResultTextProps> = {}): ResultTextProps {
const text = button.innerText
if (typeof partial.mappingId ?? button.dataset["mappingId"] === "undefined") {
return {text}
} else {
return {
text,
mappingId: partial.mappingId ?? parseInt(button.dataset["mappingId"]!),
textId: partial.textId ?? parseInt(button.dataset["textId"]!),
updated: partial.updated ?? new Date(parseInt(button.dataset["updated"]!))
}
}
}
export type ResultTextProps = ResultTextPropsFull|ResultTextPropsLimited
export function ResultText({text, mappingId, textId, updated}: ResultTextProps) {
return <button className="resultText"
{...(updated
? { "data-mapping-id": mappingId, "data-text-id": textId, "data-updated": updated.getTime() }
: {})}>{text}</button>
}

@ -0,0 +1,14 @@
.tableHeader {
font-size: 1.25rem;
font-weight: bold;
display: flex;
justify-content: stretch;
align-items: baseline;
margin-bottom: 0;
}
.tableEmoji {
font-size: 1.75rem;
padding-right: 0.5rem;
user-select: text;
}

@ -0,0 +1,73 @@
import slug from 'slug';
// TODO: reconstitute the three things here
export type TableFullProps = TableIdentifierFullProps & TableHeaderFullProps & TableEmojiProps & TableTitleProps
export type TableLimitedProps = TableIdentifierLimitedProps & TableHeaderLimitedProps & TableEmojiProps & TableTitleProps
export type TableProps = TableFullProps|TableLimitedProps
export interface TableIdentifierFullProps {
identifier: string
title: string
}
export interface TableIdentifierLimitedProps {
identifier?: null
title: string
}
export type TableIdentifierProps = TableIdentifierFullProps|TableIdentifierLimitedProps
export function tableIdentifier({ identifier, title }: TableIdentifierProps): string {
if (typeof identifier === 'string') {
return slug(identifier);
} else {
return slug(title);
}
}
export interface TableHeaderFullProps {
ordinal: number,
id: number,
name: string,
identifier: string
}
export interface TableHeaderLimitedProps {
ordinal: number,
id?: null,
name?: null,
identifier?: null
}
export type TableHeaderProps = TableHeaderFullProps|TableHeaderLimitedProps
export function TableHeaderDataset({ ordinal, id, name, identifier }: TableHeaderProps): Record<`data-${string}`, string> {
if (typeof identifier === "string") {
return {
"data-ordinal": `${ordinal}`,
"data-id": `${id}`,
"data-name": name,
"data-identifier": identifier
}
} else {
return {
"data-ordinal": `${ordinal}`,
}
}
}
export interface TableEmojiProps {
emoji: string
}
export function TableEmoji({emoji}: TableEmojiProps) {
return <span class="tableEmoji">{emoji}</span>
}
export interface TableTitleProps {
title: string
}
export function TableTitle({title}: TableTitleProps) {
return <span class="tableTitle">{title}</span>
}

@ -0,0 +1,2 @@
// TODO: move this to the response file
export const responseIdPrefix = "response-"

@ -6,6 +6,13 @@
"forceConsistentCasingInFileNames": true,
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true
"skipLibCheck": true,
"lib": ["DOM"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
}
}
}

@ -96,6 +96,13 @@
/* 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/"]
}
}
}

@ -1,11 +1,12 @@
import { type IRequestStrict, Router } from 'itty-router';
import type { Database } from '../db/database';
import { buildGeneratorPage, buildResponsesPage, wrapPage } from '../../common/template';
import { buildGeneratorPage, buildResponsesPage } from '../../common/template';
import { CSS, JS } from './bundles/client.generated';
import type { HashedBundled } from '../../common/bundle';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps';
import { collapseWhiteSpace } from 'collapse-white-space';
import { getQuerySingleton, takeLast } from '../request/query';
import { StringTemplateBuilder } from './template';
interface WebEnv {
readonly BASE_URL: string,
@ -13,7 +14,23 @@ interface WebEnv {
readonly DISCORD_APP_ID: string
}
export function wrapPage(
{ title, bodyContent, script, styles, noscriptStyles }:
{ readonly title: string, readonly bodyContent: string, readonly script: string, readonly styles: string, readonly noscriptStyles: string }): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${title}</title>
<script>${script}</script>
<style>${styles}</style>
<noscript><style>${noscriptStyles}</style></noscript>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
export function webRouter(base: string) {
function getSourceMappedJS(name: keyof typeof JS) {
const { bundled, hash }: HashedBundled = JS[name];
@ -35,13 +52,15 @@ export function webRouter(base: string) {
results: results.rolled,
editable: !results.final,
selected: results.selected,
includesResponses: true
includesResponses: true,
builder: StringTemplateBuilder,
})
const responses = buildResponsesPage({
tables: Array.from(results.db.tables.values()),
results: results.rolled,
creditsUrl: env.CREDITS_URL,
includesGenerator: true
includesGenerator: true,
builder: StringTemplateBuilder,
})
const wrapped = wrapPage({
title: 'Vore Scenario Generator',

@ -0,0 +1,131 @@
import {
type ButtonFeatures,
ButtonType,
type CheckboxFeatures,
type ElementFeatures, extendClasses,
type FormFeatures,
HyperlinkDestination,
type HyperlinkFeatures,
type LabelFeatures,
type TemplateBuilder
} from '../../common/template';
import escapeHTML from 'escape-html';
import { kebabCase } from 'change-case';
function tag(tagName: string, features: ElementFeatures, attributes: string[], contents: string[]): string {
if (typeof features.id !== "undefined") {
attributes.push(`id="${escapeHTML(features.id)}"`)
}
if (typeof features.classes !== "undefined") {
attributes.push(`class="${typeof features.classes === "string"
? escapeHTML(features.classes)
: Array.from(features.classes).map(escapeHTML).join(" ")}"`)
}
if (typeof features.data !== "undefined") {
for (const [key, value] of features.data) {
attributes.push(`data-${escapeHTML(kebabCase(key))}="${escapeHTML(value)}"`)
}
}
return `<${tagName}${attributes.length === 0 ? "" : " " + attributes.join(" ")}>${contents.join("")}</${tagName}>`
}
class StringTemplateBuilderImpl implements TemplateBuilder<string> {
makeButton(features: ButtonFeatures, ...contents: string[]): string {
const attributes = [
`type="${escapeHTML(features.type ?? ButtonType.Button)}"`,
]
if (typeof features.name === "string") {
attributes.push(`name="${escapeHTML(features.name)}"`)
}
if (typeof features.value === "string") {
attributes.push(`value="${escapeHTML(features.value)}"`)
}
return tag('button', {...features, classes: extendClasses(features.classes, "button")}, attributes, contents)
}
makeCheckbox(features: CheckboxFeatures, ...contents: string[]): string {
const attributes = [`type="checkbox"`, `name="${escapeHTML(features.name)}"`]
if (features.checked) {
attributes.push("checked")
}
if (typeof features.value === "string") {
attributes.push(`value="${escapeHTML(features.value)}"`)
}
return tag('input', features, attributes, contents);
}
makeDiv(features: ElementFeatures, ...contents: string[]): string {
return tag('div', features, [], contents);
}
makeFooter(features: ElementFeatures, ...contents: string[]): string {
return tag('footer', features, [], contents);
}
makeForm(features: FormFeatures, ...contents: string[]): string {
const attributes = [`action="${escapeHTML(features.action)}"`, `method="${escapeHTML(features.method)}"`]
return tag('form', features, attributes, contents);
}
makeHeader(features: ElementFeatures, ...contents: string[]): string {
return tag('header', features, [], contents)
}
makeHeading1(features: ElementFeatures, ...contents: string[]): string {
return tag('h1', features, [], contents);
}
makeHeading2(features: ElementFeatures, ...contents: string[]): string {
return tag('h2', features, [], contents);
}
makeHyperlink(features: HyperlinkFeatures, ...contents: string[]): string {
const attributes = [`href="${escapeHTML(features.url)}"`]
if (features.destination === HyperlinkDestination.External) {
attributes.push(`rel="external nofollow noreferrer"`)
}
if (features.asButton) {
attributes.push(`draggable="false"`)
}
return tag('a', {...features, classes: extendClasses(features.classes, features.asButton ? ["button"] : [])}, attributes, contents);
}
makeLabel(features: LabelFeatures, ...contents: string[]): string {
const attributes = []
if (typeof features.forId === "string") {
attributes.push(`for="${escapeHTML(features.forId)}"`)
}
return tag('label', features, attributes, contents);
}
makeListItem(features: ElementFeatures, ...contents: string[]): string {
return tag('li', features, [], contents)
}
makeNav(features: ElementFeatures, ...contents: string[]): string {
return tag('nav', features, [], contents)
}
makeNoscript(features: ElementFeatures, ...contents: string[]): string {
return tag('noscript', features, [], contents);
}
makeParagraph(features: ElementFeatures, ...contents: string[]): string {
return tag('p', features, [], contents);
}
makeSpan(features: ElementFeatures, ...contents: string[]): string {
return tag('span', features, [], contents);
}
makeText(text: string): string {
return escapeHTML(text);
}
makeUnorderedList(features: ElementFeatures, ...contents: string[]): string {
return tag('ul', features, [], contents);
}
}
export const StringTemplateBuilder = new StringTemplateBuilderImpl()
Loading…
Cancel
Save