web in progress, porting to preact

main
Mari 9 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. 190
      src/client/generator-entrypoint.ts
  9. 2
      src/client/popup.ts
  10. 2
      src/client/pulse.less
  11. 24
      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$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" /> <excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" /> <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> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

86
package-lock.json generated

@ -13,6 +13,9 @@
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"itty-router": "^4.0.26", "itty-router": "^4.0.26",
"markdown-escape": "^2.0.0", "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", "slash-create": "^6.0.2",
"slug": "^8.2.3" "slug": "^8.2.3"
}, },
@ -32,15 +35,12 @@
"@types/less": "^3.0.6", "@types/less": "^3.0.6",
"@types/markdown-escape": "^1.1.3", "@types/markdown-escape": "^1.1.3",
"@types/slug": "^5.0.7", "@types/slug": "^5.0.7",
"babel": "^6.23.0", "change-case": "^5.4.2",
"camelcase": "^8.0.0",
"clean-css": "^5.3.3", "clean-css": "^5.3.3",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"less": "^4.2.0", "less": "^4.2.0",
"rollup": "^4.9.5", "rollup": "^4.9.5",
"rollup-plugin-ts": "^3.4.5", "rollup-plugin-ts": "^3.4.5",
"source-map": "^0.7.4",
"terser": "^5.27.0",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"wrangler": "^3.0.0" "wrangler": "^3.0.0"
@ -2910,18 +2910,6 @@
"printable-characters": "^1.0.42" "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": { "node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.8", "version": "0.4.8",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", "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" "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": { "node_modules/caniuse-lite": {
"version": "1.0.30001578", "version": "1.0.30001578",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001578.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001578.tgz",
@ -3204,6 +3180,12 @@
"node": ">=0.8.0" "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": { "node_modules/chokidar": {
"version": "3.5.3", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@ -4149,6 +4131,45 @@
"node": ">=6" "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": { "node_modules/printable-characters": {
"version": "1.0.42", "version": "1.0.42",
"resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",
@ -4534,15 +4555,6 @@
"integrity": "sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==", "integrity": "sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==",
"dev": true "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": { "node_modules/source-map-support": {
"version": "0.5.21", "version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "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/less": "^3.0.6",
"@types/markdown-escape": "^1.1.3", "@types/markdown-escape": "^1.1.3",
"@types/slug": "^5.0.7", "@types/slug": "^5.0.7",
"camelcase": "^8.0.0", "change-case": "^5.4.2",
"clean-css": "^5.3.3", "clean-css": "^5.3.3",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"less": "^4.2.0", "less": "^4.2.0",
@ -41,6 +41,9 @@
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"itty-router": "^4.0.26", "itty-router": "^4.0.26",
"markdown-escape": "^2.0.0", "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", "slash-create": "^6.0.2",
"slug": "^8.2.3" "slug": "^8.2.3"
} }

@ -13,7 +13,7 @@ import typescriptModule from 'typescript';
import { readFile, writeFile, readdir } from 'node:fs/promises'; import { readFile, writeFile, readdir } from 'node:fs/promises';
import { basename, dirname, join, normalize } from 'node:path'; import { basename, dirname, join, normalize } from 'node:path';
import {createHash} from 'node:crypto'; import {createHash} from 'node:crypto';
import camelcase from 'camelcase'; import {camelCase} from 'change-case';
import { render as renderLess } from 'less'; import { render as renderLess } from 'less';
import CleanCSS from 'clean-css'; import CleanCSS from 'clean-css';
import type { import type {
@ -124,7 +124,7 @@ async function processTypescript(atPath: string, inDir: string, cache?: RollupCa
] ]
}) })
const {output: [chunk]} = await build.generate({ 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', sourcemap: 'hidden',
sourcemapFile: join(inDir, 'sourcemap.map'), sourcemapFile: join(inDir, 'sourcemap.map'),
format: 'iife', format: 'iife',
@ -160,11 +160,11 @@ export async function getBundle(inDir: string): Promise<{ css: Map<string, Sourc
continue; continue;
} }
if (ent.name.endsWith(LESS_SUFFIX)) { 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)) { } else if (ent.name.endsWith(TS_SUFFIX)) {
const {cache: newCache, bundle} = await processTypescript(join(inDir, ent.name), inDir, cache) const {cache: newCache, bundle} = await processTypescript(join(inDir, ent.name), inDir, cache)
cache = newCache 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 { } else {
// continue; // continue;
} }

@ -6,30 +6,14 @@ body {
} }
.window { .window {
background-color: #f8f7e0; background-color: #f8f7f0;
padding: 1rem; padding: 1rem;
border: 0.1rem solid black; border: 0.1rem solid black;
border-radius: 0.5rem; border-radius: 0.5rem;
box-sizing: border-box; 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 { .page {
user-select: contain;
} }
.page * { .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 { @keyframes popup {
from { from {
transform: scale(0); 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 { function updateHash(): void {
if (location.hash === "" || location.hash === "#" || !location.hash) { if (location.hash === "" || location.hash === "#" || !location.hash) {

@ -1,5 +1,4 @@
@import "basic-look"; @import "basic-look";
@import "attribution";
@import "popup"; @import "popup";
@import "pulse"; @import "pulse";
@ -26,74 +25,10 @@
#generatedScenario { #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 { #generator .buttons {
margin-left: -0.3rem; 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 { #copyButtons::before {
content: "Copy as:"; content: "Copy as:";
margin: 0.2rem 0 0 0.3rem margin: 0.2rem 0 0 0.3rem

@ -2,25 +2,31 @@ import {
ExportFormat, ExportFormat,
exportScenario, exportScenario,
type GeneratedState, type GeneratedState,
generatedStateToString, getResultFrom, generatedStateToString,
getResultFrom,
RolledValues, RolledValues,
rollOn,
RollSelections, RollSelections,
type RollTable, type RollTable,
RollTableDatabase, RollTableDatabase,
type RollTableResult type RollTableDetailsAndResults,
type RollTableResult,
type RollTableResultFull
} from '../common/rolltable'; } from '../common/rolltable';
import { import { buildGenerated, htmlTableIdentifier } from '../common/template';
buildGeneratedElement, copyBBID, copyEmojiTextID,
copyMDID, copyTextID,
htmlTableIdentifier, rerollAllId, rerollId,
selectAllId,
selectedIdPrefix,
selectNoneId
} from '../common/template';
import { DOMLoaded } from './onload'; import { DOMLoaded } from './onload';
import { scrapeGeneratedScenario } from './scraper'; import { scrapeGeneratedScenario } from './scraper';
import { showPopup } from './popup'; import { showPopup } from './popup';
import { pulseElement } from './pulse'; 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 { export class Generator {
readonly generator: HTMLElement; readonly generator: HTMLElement;
@ -44,11 +50,10 @@ export class Generator {
} }
selectAll(): this { selectAll(): this {
this.selected.clear(); for (const check of this.scenario.querySelectorAll('input[type=checkbox]:not(:checked)') as Iterable<HTMLInputElement>) {
for (const check of this.scenario.querySelectorAll('input[type=checkbox]') as Iterable<HTMLInputElement>) {
check.checked = true; check.checked = true;
pulseElement(check); check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false}))
const table = this.getTableWithHtmlId(check.id, selectedIdPrefix); const table = this.getTableWithHtmlId(check.id, 'selected-');
if (table) { if (table) {
this.selected.add(table); this.selected.add(table);
} }
@ -58,10 +63,47 @@ export class Generator {
selectNone(): this { selectNone(): this {
this.selected.clear(); 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; 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 return this
} }
@ -83,10 +125,31 @@ export class Generator {
return this return this
} }
attachHandlers(): this { private getGeneratedElementForTable(table: RollTable): HTMLElement|null {
this.generator.addEventListener('click', (e) => this.clickHandler(e)); return this.scenario.querySelector(`#generated-${escapeHTML(htmlTableIdentifier(table))}`)
this.generator.addEventListener('change', (e) => this.changeHandler(e)); }
return this;
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> { async copy(format: ExportFormat): Promise<void> {
@ -94,16 +157,23 @@ export class Generator {
return navigator.clipboard.writeText(exported) 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 { private clickHandler(e: Event): void {
if (e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement) { if (e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement) {
switch (e.target.id) { switch (e.target.id) {
case selectNoneId: case "selectNone":
this.selectNone() this.selectNone()
break break
case selectAllId: case "selectAll":
this.selectAll() this.selectAll()
break break
case copyMDID: case "copyMD":
this.copy(ExportFormat.Markdown) this.copy(ExportFormat.Markdown)
.then(() => showPopup(this.copyButtons, `Copied Markdown to clipboard!`, 'success')) .then(() => showPopup(this.copyButtons, `Copied Markdown to clipboard!`, 'success'))
.catch((e) => { .catch((e) => {
@ -111,7 +181,7 @@ export class Generator {
showPopup(this.copyButtons, `Failed to copy Markdown to clipboard`, 'error') showPopup(this.copyButtons, `Failed to copy Markdown to clipboard`, 'error')
}) })
break break
case copyBBID: case "copyBB":
this.copy(ExportFormat.BBCode) this.copy(ExportFormat.BBCode)
.then(() => showPopup(this.copyButtons, `Copied BBCode to clipboard!`, 'success')) .then(() => showPopup(this.copyButtons, `Copied BBCode to clipboard!`, 'success'))
.catch((e) => { .catch((e) => {
@ -119,7 +189,7 @@ export class Generator {
showPopup(this.copyButtons, `Failed to copy BBCode to clipboard`, 'error') showPopup(this.copyButtons, `Failed to copy BBCode to clipboard`, 'error')
}) })
break break
case copyEmojiTextID: case "copyEmojiText":
this.copy(ExportFormat.TextEmoji) this.copy(ExportFormat.TextEmoji)
.then(() => showPopup(this.copyButtons, `Copied text (with emojis) to clipboard!`, 'success')) .then(() => showPopup(this.copyButtons, `Copied text (with emojis) to clipboard!`, 'success'))
.catch((e) => { .catch((e) => {
@ -127,7 +197,7 @@ export class Generator {
showPopup(this.copyButtons, `Failed to copy text (with emojis) to clipboard`, 'error') showPopup(this.copyButtons, `Failed to copy text (with emojis) to clipboard`, 'error')
}) })
break break
case copyTextID: case "copyText":
this.copy(ExportFormat.TextOnly) this.copy(ExportFormat.TextOnly)
.then(() => showPopup(this.copyButtons, `Copied text to clipboard!`, 'success')) .then(() => showPopup(this.copyButtons, `Copied text to clipboard!`, 'success'))
.catch((e) => { .catch((e) => {
@ -135,70 +205,35 @@ export class Generator {
showPopup(this.copyButtons, `Failed to copy text to clipboard`, 'error') showPopup(this.copyButtons, `Failed to copy text to clipboard`, 'error')
}) })
break break
case rerollId: case "reroll":
for (const row of this.scenario.querySelectorAll(".generatedElement")) { this.reroll(false)
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')
break break
case rerollAllId: case "rerollAll":
for (const row of this.scenario.querySelectorAll(".generatedElement")) { this.reroll(true)
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')
break break
default: 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() e.preventDefault()
} }
} }
private changeHandler(e: Event): void { 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 check = e.target
const table = this.getTableWithHtmlId(check.id, selectedIdPrefix); const table = this.getTableWithHtmlId(check.id, 'selected-');
if (table) { if (table) {
if (check.checked) { if (check.checked) {
this.selected.add(table); this.selected.add(table);
} else { } else {
this.selected.delete(table); this.selected.delete(table);
} }
this.generator.querySelector<HTMLButtonElement>(`#reroll`)!.disabled = (this.selected.size === 0)
pulseElement(check) 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) { constructor(generator: HTMLElement, generatorForm: HTMLUListElement, copyButtons: HTMLElement, rollButtons: HTMLElement, db?: RollTableDatabase) {
this.generator = generator; this.generator = generator;
this.scenario = generatorForm; this.scenario = generatorForm;
@ -206,6 +241,21 @@ export class Generator {
this.rollButtons = rollButtons; this.rollButtons = rollButtons;
this.db = db; 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 { function initGenerator(db?: RollTableDatabase): Generator {
@ -223,7 +273,7 @@ function initGenerator(db?: RollTableDatabase): Generator {
} }
const rollButtons = document.getElementById("rollButtons") const rollButtons = document.getElementById("rollButtons")
if (!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(); 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()) 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)) .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 { export function showPopup(parent: HTMLElement, text: string, className?: 'success'|'info'|'warning'|'error'): void {
if (!parent.classList.contains("jsPopupHost")) { if (!parent.classList.contains("jsPopupHost")) {
console.log(parent, "should be jsPopupHost") console.warn(parent, "should be jsPopupHost")
} }
const container = parent.ownerDocument.createElement("div") const container = parent.ownerDocument.createElement("div")
container.classList.add("jsPopupContainer") container.classList.add("jsPopupContainer")

@ -1,12 +1,10 @@
@keyframes pulse-bg { @keyframes pulse-bg {
from { from {
background-color: transparent;
} }
10% { 10% {
background-color: #60606060; background-color: #60606060;
} }
to { 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) { export function pulseElement(element: HTMLElement) {
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.classList.add("pulse")
element.style.animation = "none"; }
getComputedStyle(element).animation element.addEventListener("animationend", onPulseEnd)
setTimeout(element.style.animation = "") element.addEventListener("animationcancel", onPulseEnd)
} }

@ -79,8 +79,9 @@
.response { .response {
margin-top: 0.3rem; margin-top: 0.3rem;
display: flex; display: flex;
align-items: baseline; align-items: stretch;
flex-flow: row nowrap; flex-flow: row nowrap;
scroll-margin-top: 12rem;
} }
.response.active { .response.active {
@ -88,14 +89,22 @@
min-height: 1.5rem; min-height: 1.5rem;
&::before { &::before {
content: "▶"; width: 1rem;
margin: 0.2rem 0.2rem 0.2rem 0.5rem;
content: "";
flex: 0 0 auto; flex: 0 0 auto;
font-size: 1.25rem; background-image:
margin-right: 0.4rem; linear-gradient(to bottom left, transparent 50%, currentColor 0),
line-height: 1.5rem; linear-gradient(to bottom right, currentColor 50%, transparent 0);
background-size: 100% 50%;
background-repeat: no-repeat;
background-position: top, bottom;
} }
& .resultText { & .resultText {
font-weight: bold; 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 { DOMLoaded } from './onload';
import { scrapeResponseLists } from './scraper';
import { htmlTableIdentifier } from '../common/template';
import escapeHTML from 'escape-html';
class ResponseList { class ResponseLists {
readonly db: RollTableDatabase readonly db: RollTableDatabase
constructor(db: RollTableDatabase) { readonly listsElement: HTMLElement
constructor(db: RollTableDatabase, listsElement: HTMLElement) {
this.db = db 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 { function initResponseList(): ResponseLists {
throw Error("not yet implemented") 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 responseLists: Promise<ResponseLists> = DOMLoaded.then(() => initResponseList())
export const db: Promise<RollTableDatabase> = responseList.then(r => r.db) export const db: Promise<RollTableDatabase> = responseLists.then(r => r.db)

@ -1,20 +1,26 @@
import { import {
type InProgressGeneratedState, RolledValues, RollSelections, type InProgressGeneratedState,
RolledValues,
RollSelections,
type RollTableAuthor, type RollTableAuthor,
RollTableDatabase,
type RollTableDetailsAndResults,
type RollTableDetailsNoResults, type RollTableDetailsNoResults,
type RollTableLimited, type RollTableLimited,
type RollTableResult, type RollTableResult,
type RollTableResultFull,
type RollTableResultSet type RollTableResultSet
} from '../common/rolltable'; } from '../common/rolltable';
import { authorIdKey } from '../common/template';
export function asBoolean(s: string|undefined): boolean|undefined { export function asBoolean(s: string|undefined): boolean|undefined {
if (typeof s === "undefined") { if (typeof s === "undefined") {
return return
} }
switch (s.toLowerCase()) { switch (s.toLowerCase()) {
case "true": case 'true':
return true return true
case "false": case 'false':
return false return false
default: default:
return return
@ -70,10 +76,10 @@ export function scrapeAuthor(author: HTMLElement|null): RollTableAuthor|null|und
if (!author) { if (!author) {
return null return null
} }
const id = asInteger(author.dataset["id"]) const id = asInteger(author.dataset[authorIdKey])
const name = textFrom(author.querySelector(".authorName")) const name = textFrom(author.querySelector(`.authorName`))
const url = hrefFrom(author.querySelector<HTMLAnchorElement>("a[href]")) 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') { if (typeof id === "undefined" || typeof name === "undefined" || typeof relation === 'undefined') {
return return
} }
@ -91,7 +97,7 @@ export function scrapeResultSet(set: HTMLElement|null): RollTableResultSet|null|
return null return null
} }
const id = asInteger(set.dataset["id"]) const id = asInteger(set.dataset["id"])
const name = textFrom(set.querySelector(".setName")) const name = textFrom(set.querySelector(`.setName`))
const global = asBoolean(set.dataset["global"]) const global = asBoolean(set.dataset["global"])
if (typeof id === "undefined" || typeof global === "undefined") { if (typeof id === "undefined" || typeof global === "undefined") {
return return
@ -144,11 +150,11 @@ export function scrapeGeneratedHead(head: HTMLElement|null): {table: RollTableLi
if (!head) { if (!head) {
return null return null
} }
const table = scrapeTableHeader(head.querySelector(".tableHeader")) const table = scrapeTableHeader(head.querySelector(`.tableHeader`))
if (!table) { if (!table) {
return return
} }
const selected = checkedFrom(head.querySelector("input[type=checkbox].generatedSelect")) const selected = checkedFrom(head.querySelector(`input[type=checkbox].generatedSelect`))
return { return {
table, table,
selected, selected,
@ -187,10 +193,10 @@ export function scrapeGeneratedElement(generated: HTMLElement|null): {result: Ro
if (!generated) { if (!generated) {
return null return null
} }
const result = scrapeResultText(generated.querySelector(".resultText")) const result = scrapeResultText(generated.querySelector(`.resultText`))
const author = scrapeAuthor(generated.querySelector(".author")) const author = scrapeAuthor(generated.querySelector(`.author`))
const set = scrapeResultSet(generated.querySelector(".resultSet")) const set = scrapeResultSet(generated.querySelector(`.resultSet`))
const header = scrapeGeneratedHead(generated.querySelector(".generatedHead")) const header = scrapeGeneratedHead(generated.querySelector(`.generatedHead`))
if (!header || !result) { if (!header || !result) {
return return
} }
@ -225,7 +231,7 @@ export function scrapeGeneratedScenario(scenario: HTMLElement|null): InProgressG
} }
const rolls = new RolledValues() const rolls = new RolledValues()
const selection = new RollSelections() const selection = new RollSelections()
for (const item of scenario.querySelectorAll<HTMLElement>(".generatedElement")) { for (const item of scenario.querySelectorAll<HTMLElement>(`.generatedElement`)) {
const element = scrapeGeneratedElement(item) const element = scrapeGeneratedElement(item)
if (!element) { if (!element) {
return return
@ -242,3 +248,58 @@ export function scrapeGeneratedScenario(scenario: HTMLElement|null): InProgressG
selected: selection, 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. */ // "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. */ // "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. */ // "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. */, // "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. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true /* Enable importing .json files */, // "resolveJsonModule": true /* Enable importing .json files */,
@ -97,6 +97,12 @@
/* Completeness */ /* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ // "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)) { if (isResultArray(result)) {
const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>]; const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>];
return this.addResult(innerResult); 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()); const results = Array.from(table.resultsById.values());
if (results.length === 0) { if (results.length === 0) {
throw Error(`no results for table ${table.identifier}`); 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 { export function generatedStateToString(contents: GeneratedState): string {
if (contents.final) { 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 { } 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 { import {
type RollTable, type RollTableAuthor, RollTableDatabase, type RollTableDetails, type RollTable,
type RollTableDetails,
type RollTableDetailsAndResults, type RollTableDetailsAndResults,
type RollTableResult, type RollTableResultFull, type RollTableResultSet type RollTableResult
} from './rolltable'; } from './rolltable';
import escapeHTML from 'escape-html';
import slug from 'slug';
export function htmlTableIdentifier(table: RollTable): string { // TODO: port the rest of these to preact
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>`;
}
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 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({table}: {readonly table: RollTableDetails}) { export function buildGeneratorPage<T, BuilderT extends TemplateBuilder<T>>(
return `<a href="#responses-${htmlTableIdentifier(table)}" class="button" draggable="false">${escapeHTML(table.emoji)} ${escapeHTML(table.name)}</a>` { results, generatorTargetUrl, clientId, creditsUrl, editable, selected, includesResponses, builder }:
} { readonly results: ReadonlyMap<RollTable, RollTableResult>,
readonly generatorTargetUrl: string,
export function buildResultData(result: RollTableResult): string { readonly clientId: string,
return result.full ? `data-mappingid="${result.mappingId}" data-textid="${result.textId}" data-updated="${result.updated.getTime()}"` : '' 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 function buildTableData(table: RollTable): string { export function buildResponseTypeButton<T, BuilderT extends TemplateBuilder<T>>({table, builder}: {readonly table: RollTableDetails, readonly builder: BuilderT}): ReturnType<BuilderT["makeHyperlink"]> {
return `data-ordinal="${table.ordinal}" ${table.full return builder.makeHyperlink({
? `data-id="${table.id}" data-identifier="${escapeHTML(table.identifier)}" data-name="${escapeHTML(table.name)}"` url: `#responses-${htmlTableIdentifier(table)}`,
: ''}` destination: HyperlinkDestination.Internal,
asButton: true,
}, builder.makeText(`${table.emoji} ${table.name}`)) as ReturnType<BuilderT["makeHyperlink"]>
} }
export function buildResponse({result, active}: {readonly result: RollTableResult, readonly active: boolean}) { 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 `<li class="response${active ? ' active' : ''}${result.full ? ' attributed' : ''} jsPopupHost"> return builder.makeListItem(
<button type="button" class="resultText" ${buildResultData(result)}>${escapeHTML(result.text)}</button> {
${result.full ? buildResultAttribution({result}) : ''} id: result.full ? `response-${result.mappingId}` : undefined,
</li>` 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 buildResponseList({table, activeResult}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult}) { 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 `<li class="responseType window readable" id="responses-${htmlTableIdentifier(table)}"> return builder.makeListItem(
<h2 class="responseTypeHead tableHeader" ${buildTableData(table)}><span class="tableEmoji">${escapeHTML(table.emoji)}</span> <span class="tableTitle">${escapeHTML(table.title)}</span></h2> {
<ul> classes: ["responseType", "window", "readable"],
${Array.from(table.resultsById.values()).map(result => buildResponse({result, active: result === activeResult})).join('')} id: `responses-${htmlTableIdentifier(table)}`
</ul> },
</li>` 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 buildResponsesPage( export function buildResponsesPage<T, BuilderT extends TemplateBuilder<T>>(
{ tables, results, creditsUrl, includesGenerator }: { { tables, results, creditsUrl, includesGenerator, builder }: {
readonly tables: Iterable<RollTableDetailsAndResults>, readonly tables: Iterable<RollTableDetailsAndResults>,
readonly results?: ReadonlyMap<RollTable, RollTableResult>, readonly results?: ReadonlyMap<RollTable, RollTableResult>,
readonly creditsUrl: string, readonly creditsUrl: string,
readonly includesGenerator: boolean}): string { readonly includesGenerator: boolean,
return ` readonly builder: BuilderT}): ReturnType<BuilderT["makeDiv"]> {
<div id="responses" class="page"> return builder.makeDiv({id: "responses", classes: "page"},
<header id="responsesHeader" class="window head"> builder.makeHeader({id: "responsesHeader", classes: "window"},
<h1 id="responsesHead">Possible Responses</h1> builder.makeHeading1({id: "responsesHead"}, builder.makeText("Possible Responses")),
<nav class="buttons" id="responsesHeaderNav"> builder.makeNav({id: "responsesHeaderNav", classes: "buttons"},
${Array.from(tables).map(table => buildResponseTypeButton({table})).join('')} ...Array.from(tables).map(table => buildResponseTypeButton({table, builder})),
<a id="returnToGenerator" href="#generator" class="button" draggable="false">Return to Generator</a> builder.makeHyperlink({url: `#generator`, destination: HyperlinkDestination.Internal, asButton: true, id: "returnToGenerator"}, builder.makeText("Return to Generator"))
</nav> ),
</header> ),
<ul id="responseLists"> builder.makeUnorderedList({id: "responseLists"},
${Array.from(tables).map(table => buildResponseList({table, activeResult: results?.get(table)})).join('')} ...Array.from(tables).map(table =>
</ul> buildResponseList({table, activeResult: results?.get(table), includesGenerator, builder}))),
${buildFooter({ includesResponses: true, includesGenerator, creditsUrl })} buildFooter({builder, creditsUrl, includesResponses: true, includesGenerator}),
</div> ) as ReturnType<BuilderT["makeDiv"]>
</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>`;
} }

@ -1,3 +1,6 @@
@import "AttributionAuthor";
@import "AttributionSet";
.attributed { .attributed {
position: relative; position: relative;
} }
@ -23,13 +26,15 @@
position: relative; position: relative;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 1rem; font-size: 1rem;
padding: 0.5rem; padding: 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
box-sizing: border-box; box-sizing: border-box;
transform: scale(0); transform: scale(0);
transform-origin: bottom center; transform-origin: bottom center;
transition: opacity 0.25s ease, transform 0.25s ease; transition-property: opacity, transform;
transition-delay: 250ms; transition-duration: 250ms, 250ms;
transition-timing-function: ease, ease;
transition-delay: 0ms, 0ms;
pointer-events: initial; pointer-events: initial;
user-select: none; user-select: none;
} }
@ -37,6 +42,10 @@
user-select: none; user-select: none;
} }
.attribution .button {
margin-top: 0.5rem;
}
.attributionBubble::after { .attributionBubble::after {
content: ""; content: "";
position: absolute; position: absolute;
@ -48,44 +57,36 @@
border-color: black transparent transparent transparent; border-color: black transparent transparent transparent;
} }
.attributed:hover, .attributed:focus-within { .attributed:focus-within {
user-select: text; user-select: text;
} }
.attributed:hover .attributionBubble { .attributed:focus-within .attributionBubble, .attributed .attributionBubble:hover {
transition-delay: 1.0s;
}
.attributed:focus-within .attributionBubble {
transition-delay: 0s;
}
.attributed:hover .attributionBubble, .attributed:focus-within .attributionBubble {
opacity: 100%; opacity: 100%;
transform: none; transform: none;
user-select: text; user-select: text;
} }
.attributed:hover .attributionBubble *, .attributed:focus-within .attributionBubble * { .attributed:focus-within .attributionBubble *, .attributed .attributionBubble:hover * {
user-select: text; user-select: text;
} }
.attributionBubble a { .attributionBubble a:not(.button) {
transition: color 300ms ease; transition: color 300ms ease;
} }
.attributionBubble a:link { .attributionBubble a:not(.button):link {
color: aquamarine; color: aquamarine;
} }
.attributionBubble a:visited { .attributionBubble a:not(.button):visited {
color: mediumaquamarine; color: mediumaquamarine;
} }
.attributionBubble a:focus, .attributionBubble a:hover { .attributionBubble a:not(.button):focus, .attributionBubble a:not(.button):hover {
color: lightcyan; color: lightcyan;
} }
.attributionBubble a:active { .attributionBubble a:not(.button):active {
color: aqua; 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, "forceConsistentCasingInFileNames": true,
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"strict": true, "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 */ /* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ // "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 IRequestStrict, Router } from 'itty-router';
import type { Database } from '../db/database'; 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 { CSS, JS } from './bundles/client.generated';
import type { HashedBundled } from '../../common/bundle'; import type { HashedBundled } from '../../common/bundle';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps'; import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps';
import { collapseWhiteSpace } from 'collapse-white-space'; import { collapseWhiteSpace } from 'collapse-white-space';
import { getQuerySingleton, takeLast } from '../request/query'; import { getQuerySingleton, takeLast } from '../request/query';
import { StringTemplateBuilder } from './template';
interface WebEnv { interface WebEnv {
readonly BASE_URL: string, readonly BASE_URL: string,
@ -13,7 +14,23 @@ interface WebEnv {
readonly DISCORD_APP_ID: string 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) { export function webRouter(base: string) {
function getSourceMappedJS(name: keyof typeof JS) { function getSourceMappedJS(name: keyof typeof JS) {
const { bundled, hash }: HashedBundled = JS[name]; const { bundled, hash }: HashedBundled = JS[name];
@ -35,13 +52,15 @@ export function webRouter(base: string) {
results: results.rolled, results: results.rolled,
editable: !results.final, editable: !results.final,
selected: results.selected, selected: results.selected,
includesResponses: true includesResponses: true,
builder: StringTemplateBuilder,
}) })
const responses = buildResponsesPage({ const responses = buildResponsesPage({
tables: Array.from(results.db.tables.values()), tables: Array.from(results.db.tables.values()),
results: results.rolled, results: results.rolled,
creditsUrl: env.CREDITS_URL, creditsUrl: env.CREDITS_URL,
includesGenerator: true includesGenerator: true,
builder: StringTemplateBuilder,
}) })
const wrapped = wrapPage({ const wrapped = wrapPage({
title: 'Vore Scenario Generator', 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