parent
f0a46781de
commit
ae1c05270f
@ -1,66 +0,0 @@ |
||||
body { |
||||
background-color: deepskyblue; |
||||
font-family: sans-serif; |
||||
padding: 0; |
||||
margin: 0; |
||||
} |
||||
|
||||
.window { |
||||
background-color: #f8f7f0; |
||||
padding: 1rem; |
||||
border: 0.1rem solid black; |
||||
border-radius: 0.5rem; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
.page { |
||||
} |
||||
|
||||
.page * { |
||||
user-select: none; |
||||
} |
||||
|
||||
.readable { |
||||
width: 35rem; |
||||
} |
||||
|
||||
ul { |
||||
padding: 0; |
||||
} |
||||
|
||||
li { |
||||
list-style: none; |
||||
} |
||||
|
||||
.buttons { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
align-items: center; |
||||
justify-content: stretch; |
||||
& > * { |
||||
flex: 1 0 auto; |
||||
margin: 0.2rem 0 0 0.3rem |
||||
} |
||||
} |
||||
|
||||
@keyframes popup { |
||||
from { |
||||
transform: scale(0); |
||||
opacity: 0; |
||||
} |
||||
|
||||
10% { |
||||
transform: none; |
||||
opacity: 100%; |
||||
} |
||||
|
||||
75% { |
||||
transform: none; |
||||
opacity: 100%; |
||||
} |
||||
|
||||
to { |
||||
transform: scale(0); |
||||
opacity: 0; |
||||
} |
||||
} |
@ -1,5 +1,5 @@ |
||||
import {responseLists, db} from './responses-entrypoint' |
||||
import {prepareGenerator} from './generator-entrypoint' |
||||
import {responseLists, db} from './responses-entrypoint-old' |
||||
import {prepareGenerator} from './generator-entrypoint-old' |
||||
|
||||
Promise.all([prepareGenerator(db), responseLists]).then(([gen, res]) => { |
||||
res.addSelectionListener((ev) => { |
@ -1,35 +1,3 @@ |
||||
@import "basic-look"; |
||||
@import "popup"; |
||||
@import "pulse"; |
||||
|
||||
#generator { |
||||
position: absolute; |
||||
top: 0; |
||||
min-height: 100dvh; |
||||
left: 0; |
||||
right: 0; |
||||
margin: 0; |
||||
padding: 2rem; |
||||
display: flex; |
||||
box-sizing: border-box; |
||||
flex-flow: column nowrap; |
||||
justify-content: center; |
||||
align-items: center; |
||||
} |
||||
|
||||
#generatorHead { |
||||
margin-top: 0; |
||||
user-select: text; |
||||
} |
||||
|
||||
#generatedScenario { |
||||
} |
||||
|
||||
#generator .buttons { |
||||
margin-left: -0.3rem; |
||||
} |
||||
|
||||
#copyButtons::before { |
||||
content: "Copy as:"; |
||||
margin: 0.2rem 0 0 0.3rem |
||||
} |
||||
@import "../common/client/GeneratorPage"; |
||||
@import "../common/client/Page"; |
||||
@import "../common/client/PageFooter"; |
||||
|
@ -1,19 +0,0 @@ |
||||
export function showPopup(parent: HTMLElement, text: string, className?: 'success'|'info'|'warning'|'error'): void { |
||||
if (!parent.classList.contains("jsPopupHost")) { |
||||
console.warn(parent, "should be jsPopupHost") |
||||
} |
||||
const container = parent.ownerDocument.createElement("div") |
||||
container.classList.add("jsPopupContainer") |
||||
parent.appendChild(container) |
||||
const popup = parent.ownerDocument.createElement("div") |
||||
popup.classList.add("jsPopup") |
||||
if (className) { |
||||
popup.classList.add(className) |
||||
} |
||||
popup.innerText = text |
||||
container.appendChild(popup) |
||||
popup.addEventListener('animationend', () => { |
||||
container.removeChild(popup) |
||||
parent.removeChild(container) |
||||
}) |
||||
} |
@ -1,110 +1,3 @@ |
||||
@import "basic-look"; |
||||
@import "attribution"; |
||||
@import "popup"; |
||||
|
||||
#responsesHeader { |
||||
position: sticky; |
||||
display: flex; |
||||
flex-flow: column; |
||||
align-items: center; |
||||
border-top: 0; |
||||
border-left: 0; |
||||
border-right: 0; |
||||
border-radius: 0; |
||||
margin: 0; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
height: 9.5rem; |
||||
z-index: 2; |
||||
} |
||||
|
||||
#responsesHeader .buttons { |
||||
display: flex; |
||||
flex-flow: row wrap; |
||||
padding-top: 0.2rem; |
||||
padding-left: 0.3rem; |
||||
padding-right: 0.3rem; |
||||
margin: 0; |
||||
overflow-y: auto; |
||||
overflow-x: visible; |
||||
} |
||||
|
||||
#returnToGenerator { |
||||
flex-basis: 50%; |
||||
} |
||||
|
||||
.responseNavEmoji { |
||||
margin-right: 0.2rem; |
||||
} |
||||
|
||||
#responsesHead { |
||||
margin-top: 0; |
||||
margin-bottom: 0; |
||||
font-size: 1.5rem; |
||||
} |
||||
|
||||
#responseLists { |
||||
display: flex; |
||||
flex-flow: row wrap; |
||||
padding: 0.1rem; |
||||
justify-content: center; |
||||
} |
||||
|
||||
.responseType { |
||||
list-style: none; |
||||
padding: 1rem; |
||||
scroll-margin-top: 10rem; |
||||
margin-top: 0.5rem; |
||||
margin-left: 1rem; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.responseType > h2 { |
||||
margin-top: 0; |
||||
} |
||||
|
||||
.responseTypeHead { |
||||
position: sticky; |
||||
top: 9.4rem; |
||||
background-color: inherit; |
||||
z-index: 1; |
||||
padding-bottom: 0.2rem; |
||||
} |
||||
|
||||
.responseTypeTitle { |
||||
flex: 1 1 auto; |
||||
} |
||||
|
||||
.response { |
||||
margin-top: 0.3rem; |
||||
display: flex; |
||||
align-items: stretch; |
||||
flex-flow: row nowrap; |
||||
scroll-margin-top: 12rem; |
||||
} |
||||
|
||||
.response.active { |
||||
position: relative; |
||||
min-height: 1.5rem; |
||||
|
||||
&::before { |
||||
width: 1rem; |
||||
margin: 0.2rem 0.2rem 0.2rem 0.5rem; |
||||
content: ""; |
||||
flex: 0 0 auto; |
||||
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; |
||||
} |
||||
} |
||||
@import "../common/client/ResponsesPage"; |
||||
@import "../common/client/Page"; |
||||
@import "../common/client/PageFooter"; |
||||
|
@ -1,305 +0,0 @@ |
||||
import { |
||||
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': |
||||
return true |
||||
case 'false': |
||||
return false |
||||
default: |
||||
return |
||||
} |
||||
} |
||||
|
||||
export function asInteger(s: string|undefined): number|undefined { |
||||
if (typeof s === "undefined") { |
||||
return |
||||
} |
||||
const result = parseInt(s) |
||||
if (Number.isNaN(result)) { |
||||
return |
||||
} |
||||
return result |
||||
} |
||||
|
||||
export function asTimestamp(s: string|undefined): Date|undefined { |
||||
const i = asInteger(s) |
||||
if (typeof i === "undefined") { |
||||
return |
||||
} |
||||
const date = new Date(i) |
||||
if (Number.isNaN(date.valueOf())) { |
||||
return |
||||
} |
||||
return date |
||||
} |
||||
|
||||
export function textFrom(e: HTMLElement|null): string|undefined { |
||||
if (!e) { |
||||
return |
||||
} |
||||
return e.innerText.trim() |
||||
} |
||||
|
||||
export function hrefFrom(e: HTMLAnchorElement|null): string | null { |
||||
if (!e) { |
||||
return null |
||||
} |
||||
return e.href |
||||
} |
||||
|
||||
export function checkedFrom(e: HTMLInputElement|null): boolean | null { |
||||
if (!e) { |
||||
return null |
||||
} |
||||
return e.checked |
||||
} |
||||
|
||||
// element to find here is .author
|
||||
export function scrapeAuthor(author: HTMLElement|null): RollTableAuthor|null|undefined { |
||||
if (!author) { |
||||
return null |
||||
} |
||||
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`)) |
||||
if (typeof id === "undefined" || typeof name === "undefined" || typeof relation === 'undefined') { |
||||
return |
||||
} |
||||
return { |
||||
id, |
||||
name, |
||||
url, |
||||
relation |
||||
} |
||||
} |
||||
|
||||
// element to find here is .resultSet
|
||||
export function scrapeResultSet(set: HTMLElement|null): RollTableResultSet|null|undefined { |
||||
if (!set) { |
||||
return null |
||||
} |
||||
const id = asInteger(set.dataset["id"]) |
||||
const name = textFrom(set.querySelector(`.setName`)) |
||||
const global = asBoolean(set.dataset["global"]) |
||||
if (typeof id === "undefined" || typeof global === "undefined") { |
||||
return |
||||
} |
||||
return { |
||||
id, |
||||
name: name ?? null, |
||||
description: null, |
||||
global, |
||||
} |
||||
} |
||||
|
||||
// element to find here is .tableHeader
|
||||
export function scrapeTableHeader(head: HTMLElement|null): RollTableLimited|RollTableDetailsNoResults|null|undefined { |
||||
if (!head) { |
||||
return null |
||||
} |
||||
const emoji = textFrom(head.querySelector(".tableEmoji")) |
||||
const title = textFrom(head.querySelector(".tableTitle")) |
||||
const ordinal = asInteger(head.dataset["ordinal"]) |
||||
const id = asInteger(head.dataset["id"]) |
||||
const identifier = head.dataset["identifier"] |
||||
const name = head.dataset["name"] |
||||
if (typeof emoji === 'undefined' || typeof title === 'undefined' || typeof ordinal === 'undefined') { |
||||
return |
||||
} |
||||
const header = `${emoji} ${title}` |
||||
if (typeof id === 'undefined' || typeof identifier === 'undefined' || typeof name === 'undefined') { |
||||
return { |
||||
full: false, |
||||
emoji, |
||||
title, |
||||
header, |
||||
ordinal, |
||||
} |
||||
} |
||||
return { |
||||
full: 'details', |
||||
id, |
||||
identifier, |
||||
emoji, |
||||
title, |
||||
header, |
||||
ordinal, |
||||
name, |
||||
} |
||||
} |
||||
|
||||
export function scrapeGeneratedHead(head: HTMLElement|null): {table: RollTableLimited|RollTableDetailsNoResults, selected: boolean|null}|null|undefined { |
||||
if (!head) { |
||||
return null |
||||
} |
||||
const table = scrapeTableHeader(head.querySelector(`.tableHeader`)) |
||||
if (!table) { |
||||
return |
||||
} |
||||
const selected = checkedFrom(head.querySelector(`input[type=checkbox].generatedSelect`)) |
||||
return { |
||||
table, |
||||
selected, |
||||
} |
||||
} |
||||
|
||||
// element to find here is .resultText
|
||||
export function scrapeResultText(result: HTMLElement|null): {full: false, text: string}|{full: true, mappingId: number, textId: number, updated: Date, text: string}|undefined|null { |
||||
if (!result) { |
||||
return null |
||||
} |
||||
const text = textFrom(result) |
||||
const mappingId = asInteger(result.dataset["mappingid"]) |
||||
const textId = asInteger(result.dataset["textid"]) |
||||
const updated = asTimestamp(result.dataset["updated"]) |
||||
if (typeof text === 'undefined') { |
||||
return |
||||
} |
||||
if (typeof mappingId === 'undefined' || typeof textId === 'undefined' || typeof updated == 'undefined') { |
||||
return { |
||||
full: false, |
||||
text, |
||||
} |
||||
} |
||||
return { |
||||
full: true, |
||||
text, |
||||
textId, |
||||
mappingId, |
||||
updated: new Date(updated) |
||||
} |
||||
} |
||||
|
||||
// element to find here is .generatedElement
|
||||
export function scrapeGeneratedElement(generated: HTMLElement|null): {result: RollTableResult, selected: boolean|null}|null|undefined { |
||||
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`)) |
||||
if (!header || !result) { |
||||
return |
||||
} |
||||
const {table, selected} = header |
||||
if (!set || typeof author === "undefined" || !result.full) { |
||||
return { |
||||
result: { |
||||
full: false, |
||||
table, |
||||
text: result.text, |
||||
}, |
||||
selected |
||||
} |
||||
} |
||||
return { |
||||
result: { |
||||
...result, |
||||
author, |
||||
set, |
||||
table, |
||||
}, |
||||
selected, |
||||
} |
||||
} |
||||
|
||||
export function scrapeGeneratedScenario(scenario: HTMLElement): InProgressGeneratedState|undefined |
||||
export function scrapeGeneratedScenario(scenario: null): null |
||||
// element to find here is #generatedScenario
|
||||
export function scrapeGeneratedScenario(scenario: HTMLElement|null): InProgressGeneratedState|null|undefined { |
||||
if (!scenario) { |
||||
return null |
||||
} |
||||
const rolls = new RolledValues() |
||||
const selection = new RollSelections() |
||||
for (const item of scenario.querySelectorAll<HTMLElement>(`.generatedElement`)) { |
||||
const element = scrapeGeneratedElement(item) |
||||
if (!element) { |
||||
return |
||||
} |
||||
const {result, selected} = element |
||||
rolls.add(result) |
||||
if (selected) { |
||||
selection.add(result.table) |
||||
} |
||||
} |
||||
return { |
||||
final: false, |
||||
rolled: rolls, |
||||
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, |
||||
} |
||||
} |
@ -1,142 +0,0 @@ |
||||
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() |
@ -1,16 +0,0 @@ |
||||
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; |
||||
} |
||||
} |
@ -0,0 +1,46 @@ |
||||
import type { PropsWithChildren } from 'preact/compat'; |
||||
|
||||
export interface LinkButtonProps { |
||||
"class"?: string |
||||
id?: string |
||||
href: string |
||||
external?: boolean |
||||
onClick?: (ev: Event) => void |
||||
} |
||||
|
||||
export interface FormButtonProps { |
||||
"class"?: string |
||||
id?: string |
||||
type?: HTMLButtonElement["type"] |
||||
href?: null |
||||
external?: null |
||||
name?: string |
||||
value?: string |
||||
disabled?: boolean |
||||
onClick?: (ev: Event) => void |
||||
} |
||||
|
||||
export function LinkButton({"class": className, id, href, external, onClick, children}: LinkButtonProps & PropsWithChildren) { |
||||
return <a |
||||
class={`button${className ? " " + className : ""}`} |
||||
href={href} |
||||
{...(external ? {rel: "external nofollow noreferrer"} : {})} |
||||
{...(id ? {id} : {})} |
||||
{...(onClick ? {onClick} : {})} |
||||
draggable={false}> |
||||
{children} |
||||
</a> |
||||
} |
||||
|
||||
export function FormButton({"class": className, id, name, value, disabled, type = "button", onClick, children}: FormButtonProps & PropsWithChildren) { |
||||
return <button |
||||
type={type} |
||||
{...(id ? {id} : {})} |
||||
{...(onClick ? {onClick} : {})} |
||||
{...(name ? {name} : {})} |
||||
{...(value ? {value} : {})} |
||||
{...(typeof disabled === "boolean" ? {disabled} : {})} |
||||
class={`button${className ? " " + className : ""}`}> |
||||
{children} |
||||
</button> |
||||
} |
@ -0,0 +1,103 @@ |
||||
import { |
||||
reconstituteTable, |
||||
TableEmoji, |
||||
TableHeaderDataset, |
||||
tableIdentifier, |
||||
type TableProps, |
||||
TableTitle |
||||
} from './TableHeader'; |
||||
import { |
||||
GeneratedResult, |
||||
type GeneratedResultProps, type GeneratedResultPropsFull, |
||||
type PartialGeneratedResultProps, type PartialGeneratedResultPropsFull, |
||||
reconstituteGeneratedResult |
||||
} from './GeneratedResult'; |
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; |
||||
import { pulseElement } from './pulseElement'; |
||||
|
||||
export type GeneratedElementPropsBase = { |
||||
table: TableProps |
||||
selected: boolean|null |
||||
} & GeneratedResultProps |
||||
|
||||
export type GeneratedElementEditableProps = GeneratedElementPropsBase & { selected: boolean } & GeneratedResultPropsFull |
||||
export type GeneratedElementReadonlyProps = GeneratedElementPropsBase & { selected: null } & GeneratedResultProps |
||||
|
||||
export type GeneratedElementProps = GeneratedElementEditableProps|GeneratedElementReadonlyProps |
||||
|
||||
export type PartialGeneratedElementProps = PartialGeneratedElementEditableProps|PartialGeneratedElementReadonlyProps |
||||
|
||||
export type PartialGeneratedElementEditableProps = { |
||||
table?: Partial<TableProps> |
||||
selected?: boolean |
||||
} & PartialGeneratedResultPropsFull |
||||
|
||||
export type PartialGeneratedElementReadonlyProps = { |
||||
table?: Partial<TableProps> |
||||
selected?: null |
||||
} & PartialGeneratedResultProps |
||||
|
||||
export function reconstituteGeneratedElement(element: HTMLLIElement, partial?: PartialGeneratedElementProps): GeneratedElementProps { |
||||
const result = reconstituteGeneratedResult(element.querySelector(".generatedResult")!, partial) |
||||
const selected = typeof partial?.selected !== 'undefined' |
||||
? partial.selected |
||||
: (element.querySelector<HTMLInputElement>('.generatedSelect')?.checked) ?? null |
||||
const table = reconstituteTable(element.querySelector(".tableHeader")!, partial?.table) |
||||
if (result.set) { |
||||
return { |
||||
...result, |
||||
table, |
||||
selected, |
||||
} |
||||
} else { |
||||
return { |
||||
...result, |
||||
table, |
||||
selected: null, |
||||
} |
||||
} |
||||
} |
||||
|
||||
export interface GeneratedElementEvents { |
||||
onSelectionChange?: (tableId: number, selected: boolean) => void |
||||
} |
||||
|
||||
export function GeneratedElement({ onSelectionChange, ...props }: GeneratedElementProps & GeneratedElementEvents) { |
||||
const ref = useRef<HTMLInputElement>(null); |
||||
const selected = props.selected |
||||
const [lastSelected, setLastSelected] = useState(selected) |
||||
const tableId = tableIdentifier(props.table) |
||||
const checkId = `selected-${tableId}` |
||||
const changeCallback = useCallback((ev: Event) => { |
||||
if (onSelectionChange && typeof props.table.id === "number") { |
||||
onSelectionChange(props.table.id, (ev.currentTarget as HTMLInputElement).checked) |
||||
} |
||||
}, [onSelectionChange]) |
||||
useEffect(() => { |
||||
if (selected !== lastSelected) { |
||||
setLastSelected(selected) |
||||
if (ref.current) { |
||||
pulseElement(ref.current) |
||||
} |
||||
} |
||||
}, [ref, selected, lastSelected, setLastSelected]) |
||||
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> |
||||
<input type="checkbox" class={`generatedSelect${props.selected === null ? " unselectable" : ""}`} |
||||
id={checkId} |
||||
name={checkId} |
||||
checked={props.selected ?? false} |
||||
onChange={changeCallback} |
||||
ref={ref} /> |
||||
</h2> |
||||
<GeneratedResult {...props} /> |
||||
</li> |
||||
} |
@ -0,0 +1,37 @@ |
||||
@import "Page"; |
||||
@import "Button"; |
||||
@import "usePopup"; |
||||
|
||||
#generator { |
||||
position: absolute; |
||||
top: 0; |
||||
min-height: 100dvh; |
||||
left: 0; |
||||
right: 0; |
||||
margin: 0; |
||||
padding: 2rem; |
||||
display: flex; |
||||
box-sizing: border-box; |
||||
flex-flow: column nowrap; |
||||
justify-content: center; |
||||
align-items: center; |
||||
} |
||||
|
||||
#generatorHead { |
||||
margin-top: 0; |
||||
user-select: text; |
||||
} |
||||
|
||||
#generatedScenario { |
||||
padding: 0; |
||||
} |
||||
|
||||
#generator .buttons { |
||||
margin-left: -0.3rem; |
||||
} |
||||
|
||||
|
||||
#copyButtons::before { |
||||
content: "Copy as:"; |
||||
margin: 0.2rem 0 0 0.3rem |
||||
} |
@ -0,0 +1,172 @@ |
||||
|
||||
import { FormButton, LinkButton } from './Button'; |
||||
import { createContext, Fragment } from 'preact'; |
||||
import { usePopup } from './usePopup'; |
||||
import { useCallback, useContext } from 'preact/hooks'; |
||||
import { ExportFormat, exportFormatToString } from '../rolltable'; |
||||
import { |
||||
GeneratedElement, |
||||
type GeneratedElementProps, |
||||
reconstituteGeneratedElement, |
||||
} from './GeneratedElement'; |
||||
import { IncludesResponses } from './ResponsesPage'; |
||||
import { tableIdentifier } from './TableHeader'; |
||||
|
||||
export const IncludesGenerator = createContext(false) |
||||
|
||||
export interface GeneratorProps { |
||||
generatorTargetUrl: string |
||||
elements: GeneratedElementProps[] |
||||
addToDiscordUrl: string|null |
||||
editable: boolean |
||||
} |
||||
|
||||
export enum GeneratorSelect { |
||||
All = "all", |
||||
None = "none", |
||||
} |
||||
|
||||
export enum GeneratorReroll { |
||||
All = "all", |
||||
Selected = "selected", |
||||
} |
||||
|
||||
export function reconstituteGenerator(element: HTMLDivElement, partial?: Partial<GeneratorProps>): GeneratorProps { |
||||
const addToDiscordUrl = partial?.addToDiscordUrl ?? element.querySelector<HTMLAnchorElement>("#addToDiscord")?.href ?? null |
||||
const editable = partial?.editable ?? !!element.querySelector("#rollButtons") |
||||
const elements = partial?.elements ?? |
||||
Array.from(element.querySelector<HTMLUListElement>("#generatedScenario")!.children) |
||||
.map(li => reconstituteGeneratedElement(li as HTMLLIElement, editable ? {} : {selected: null})) |
||||
|
||||
const generatorTargetUrl = partial?.generatorTargetUrl ?? element.querySelector<HTMLFormElement>("#generatorWindow")!.action |
||||
return { |
||||
addToDiscordUrl, |
||||
editable, |
||||
generatorTargetUrl, |
||||
elements: elements, |
||||
} |
||||
} |
||||
|
||||
export interface GeneratorEvents { |
||||
onCopy?: (format: ExportFormat) => Promise<void> |
||||
onReroll?: (which: GeneratorReroll) => Promise<void> |
||||
onSelect?: (which: GeneratorSelect) => void |
||||
onSelectionChange?: (tableId: number, selected: boolean) => void |
||||
} |
||||
|
||||
enum GeneratorSelectionState { |
||||
All = "All", |
||||
Partial = "Some", |
||||
None = "None" |
||||
} |
||||
|
||||
export function GeneratorPage({ editable, generatorTargetUrl, addToDiscordUrl, onSelectionChange, onSelect, onReroll, onCopy, elements }: GeneratorProps & GeneratorEvents) { |
||||
const includesResponses = useContext(IncludesResponses); |
||||
const [copyPopupHost, showCopyPopup] = usePopup<HTMLDivElement>() |
||||
const [rerollPopupHost, showRerollPopup] = usePopup<HTMLDivElement>() |
||||
const copyWrapper = useCallback((format: ExportFormat) => { |
||||
if (!onCopy) { |
||||
console.error("No copy handler") |
||||
return showCopyPopup(`Failed to copy ${exportFormatToString(format)} to clipboard`, 'error') |
||||
} |
||||
onCopy(format).then(() => { |
||||
return showCopyPopup(`Copied ${exportFormatToString(format)} to clipboard`) |
||||
}).catch((ex: unknown) => { |
||||
console.error(ex) |
||||
return showCopyPopup(`Failed to copy ${exportFormatToString(format)} to clipboard`, 'error') |
||||
}).catch((ex: unknown) => { |
||||
console.error(ex) |
||||
}) |
||||
}, [showCopyPopup, onCopy]) |
||||
const md = useCallback(() => copyWrapper(ExportFormat.Markdown), [copyWrapper]) |
||||
const bb = useCallback(() => copyWrapper(ExportFormat.BBCode), [copyWrapper]) |
||||
const emoji = useCallback(() => copyWrapper(ExportFormat.TextEmoji), [copyWrapper]) |
||||
const text = useCallback(() => copyWrapper(ExportFormat.TextOnly), [copyWrapper]) |
||||
const selected = elements.reduce<null|GeneratorSelectionState>((current, next) => { |
||||
if (next.selected === null) { |
||||
return current |
||||
} |
||||
switch (current) { |
||||
case GeneratorSelectionState.Partial: |
||||
return GeneratorSelectionState.Partial |
||||
case GeneratorSelectionState.None: |
||||
return next.selected ? GeneratorSelectionState.Partial : GeneratorSelectionState.None |
||||
case GeneratorSelectionState.All: |
||||
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.Partial |
||||
case null: |
||||
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.None |
||||
} |
||||
}, null) |
||||
const selectAll = useCallback((ev: Event) => { |
||||
if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { |
||||
return |
||||
} |
||||
onSelect(GeneratorSelect.All) |
||||
}, [onSelect, showRerollPopup]) |
||||
const selectNone = useCallback((ev: Event) => { |
||||
if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { |
||||
return |
||||
} |
||||
onSelect(GeneratorSelect.None) |
||||
}, [onSelect, showRerollPopup]) |
||||
const rerollSelected = useCallback((ev: Event) => { |
||||
if (!onReroll || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { |
||||
return |
||||
} |
||||
onReroll(GeneratorReroll.Selected).then(() => {}).catch((ex: unknown) => { |
||||
console.error(ex) |
||||
return showRerollPopup(`Failed to reroll`, 'error') |
||||
}).catch((ex: unknown) => { |
||||
console.error(ex) |
||||
}) |
||||
}, [onReroll, showRerollPopup]) |
||||
const rerollAll = useCallback((ev: Event) => { |
||||
if (!onReroll || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { |
||||
return |
||||
} |
||||
onReroll(GeneratorReroll.All).then(() => {}).catch((ex: unknown) => { |
||||
console.error(ex) |
||||
return showRerollPopup(`Failed to reroll all`, 'error') |
||||
}).catch((ex: unknown) => { |
||||
console.error(ex) |
||||
}) |
||||
}, [onReroll, showRerollPopup]) |
||||
|
||||
return <div id="generator" class="page"> |
||||
<form method="post" action={generatorTargetUrl} id="generatorWindow" class="window readable"> |
||||
<h2 id="generatorHead">Your generated scenario</h2> |
||||
<ul id="generatedScenario"> |
||||
{elements.map(i => <GeneratedElement key={tableIdentifier(i.table)} {...i} onSelectionChange={onSelectionChange} />)} |
||||
</ul> |
||||
<div id="generatorControls"> |
||||
<div ref={copyPopupHost} id="copyButtons" className="buttons requiresJs jsPopupHost"> |
||||
<FormButton id="copyMD" type="button" onClick={onCopy && md} disabled={!onCopy}>Markdown</FormButton> |
||||
<FormButton id="copyBB" type="button" onClick={onCopy && bb} disabled={!onCopy}>BBCode</FormButton> |
||||
<FormButton id="copyEmojiText" type="button" onClick={onCopy && emoji} disabled={!onCopy}>Text + Emoji</FormButton> |
||||
<FormButton id="copyText" type="button" onClick={onCopy && text} disabled={!onCopy}>Text Only</FormButton> |
||||
</div> |
||||
{editable ? <div ref={rerollPopupHost} id="rollButtons" class="buttons jsPopupHost"> |
||||
<FormButton type="submit" id="reroll" name="submit" |
||||
value="reroll" disabled={onReroll && (!selected || selected === GeneratorSelectionState.None)} onClick={onReroll && rerollSelected}> |
||||
Reroll {selected === GeneratorSelectionState.All ? 'All' : 'Selected'} |
||||
</FormButton> |
||||
<FormButton type="button" id="selectAll" class="requiresJs" onClick={selectAll} disabled={!onSelect || selected === GeneratorSelectionState.All}>Select All</FormButton> |
||||
<FormButton type="button" id="selectNone" class="requiresJs" onClick={selectNone} disabled={!onSelect || !selected || selected === GeneratorSelectionState.None}>Select None</FormButton> |
||||
</div> : null} |
||||
<div id="scenarioButtons" class="buttons"> |
||||
{editable ? <Fragment> |
||||
<LinkButton id="rerollAll" href={generatorTargetUrl} external={false} onClick={onReroll && rerollAll}>New Scenario</LinkButton> |
||||
<FormButton id="saveScenario" name="submit" value="saveScenario" type="submit">Get Scenario Link</FormButton> |
||||
</Fragment> : <Fragment> |
||||
<LinkButton id="openInGenerator" href={generatorTargetUrl} external={false}>Open in Generator</LinkButton> |
||||
</Fragment>} |
||||
</div> |
||||
{ addToDiscordUrl || includesResponses ? |
||||
<div id="generatorLinks" class="buttons"> |
||||
{addToDiscordUrl && <LinkButton external={true} id="addToDiscord" href={addToDiscordUrl}>Add to Discord</LinkButton>} |
||||
{includesResponses ? <LinkButton external={false} id="goToResponses" href="#responses">View Possible Responses</LinkButton> : null} |
||||
</div> : null} |
||||
</div> |
||||
</form> |
||||
</div> |
||||
} |
@ -0,0 +1,6 @@ |
||||
body { |
||||
background-color: deepskyblue; |
||||
font-family: sans-serif; |
||||
padding: 0; |
||||
margin: 0; |
||||
} |
@ -0,0 +1,3 @@ |
||||
@import "./Main"; |
||||
@import "./GeneratorPage"; |
||||
@import "./PageFooter"; |
@ -0,0 +1,93 @@ |
||||
import { GeneratorPage, GeneratorSelect, IncludesGenerator } from './GeneratorPage'; |
||||
import { PageFooter } from './PageFooter'; |
||||
import type { GeneratedElementProps } from './GeneratedElement'; |
||||
import { useCallback, useMemo, useState } from 'preact/hooks'; |
||||
import { ExportFormat, exportScenario, RollSelections, type RollTable, type RollTableResult } from '../rolltable'; |
||||
|
||||
export interface GeneratorMainProps { |
||||
editable: boolean |
||||
|
||||
generatorTargetUrl: string |
||||
addToDiscordUrl: string |
||||
creditsUrl: string |
||||
|
||||
initialResults: ReadonlyMap<RollTable, RollTableResult> |
||||
initialSelected?: ReadonlySet<RollTable> |
||||
} |
||||
|
||||
export interface GeneratorMainEvents { |
||||
copyText?: (text: string) => Promise<void> |
||||
} |
||||
|
||||
// TODO: add a "reconstitute" function for MainGeneratorOnly
|
||||
// TODO: add the other two top-level pages (MainResponsesOnly, MainGeneratorResponses) with "reconstitute" functions
|
||||
// TODO: add the entry points that reconstitute and hydrate each of the respective top-level pages
|
||||
|
||||
function MainGeneratorOnly({ |
||||
editable, generatorTargetUrl, addToDiscordUrl, |
||||
creditsUrl, initialResults, initialSelected, copyText}: GeneratorMainProps & GeneratorMainEvents) { |
||||
const [results, ] = |
||||
useState<ReadonlyMap<RollTable, RollTableResult>>(initialResults) |
||||
const [selected, setSelected] = |
||||
useState<ReadonlySet<RollTable>|null>(initialSelected ?? null) |
||||
const onCopy = useCallback(async (format: ExportFormat) => { |
||||
if (!copyText) { |
||||
return Promise.reject(Error("Copy functionality is not implemented")) |
||||
} |
||||
return copyText(exportScenario(Array.from(results.values()), format)) |
||||
}, [copyText, results]) |
||||
const onSelectionChange = useCallback((tableId: number, state: boolean) => { |
||||
const table = Array.from(initialResults.keys()).find(table => table.full && table.id === tableId) |
||||
if (!table) { |
||||
return |
||||
} |
||||
const newSelection = new RollSelections(selected) |
||||
if (state) { |
||||
newSelection.add(table) |
||||
} else { |
||||
newSelection.delete(table) |
||||
} |
||||
setSelected(newSelection) |
||||
}, [initialResults, selected, setSelected]) |
||||
const onSelect = useCallback((select: GeneratorSelect) => { |
||||
switch (select) { |
||||
case GeneratorSelect.All: |
||||
setSelected(new RollSelections(initialResults.keys())); |
||||
break; |
||||
case GeneratorSelect.None: |
||||
setSelected(new RollSelections()); |
||||
break; |
||||
} |
||||
}, [initialResults, setSelected]) |
||||
const elements = useMemo(() => { |
||||
const output: GeneratedElementProps[] = [] |
||||
for (const result of results.values()) { |
||||
if (result.full) { |
||||
output.push({ |
||||
...result, |
||||
selected: selected === null ? null : selected.has(result.table) |
||||
}) |
||||
} else { |
||||
output.push({ |
||||
...result, |
||||
selected: null, |
||||
}) |
||||
} |
||||
} |
||||
return output |
||||
}, [results, selected]) |
||||
return <IncludesGenerator.Provider value={true}> |
||||
<GeneratorPage |
||||
generatorTargetUrl={generatorTargetUrl} |
||||
elements={elements} |
||||
addToDiscordUrl={addToDiscordUrl} |
||||
editable={editable} |
||||
onCopy={onCopy} |
||||
// TODO: implement onReroll using JSON fetch
|
||||
// specifically: POST to the target URL as if you're submitting the form,
|
||||
// _but_ add Accept: text/json to indicate you want it for API purposes and not as a page
|
||||
onSelect={selected ? onSelect : undefined} |
||||
onSelectionChange={selected ? onSelectionChange : undefined} /> |
||||
<PageFooter creditsUrl={creditsUrl} /> |
||||
</IncludesGenerator.Provider> |
||||
} |
@ -0,0 +1,20 @@ |
||||
.window { |
||||
background-color: #f8f7f0; |
||||
padding: 1rem; |
||||
border: 0.1rem solid black; |
||||
border-radius: 0.5rem; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
.page { |
||||
} |
||||
|
||||
.page * { |
||||
user-select: none; |
||||
} |
||||
|
||||
.readable { |
||||
width: 35rem; |
||||
} |
||||
|
||||
|
@ -1,20 +1,21 @@ |
||||
import { Fragment } from 'preact'; |
||||
import { IncludesResponses } from './ResponsesPage'; |
||||
import { IncludesGenerator } from './GeneratorPage'; |
||||
import { useContext } from 'preact/hooks'; |
||||
|
||||
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) { |
||||
export function PageFooter({creditsUrl}: PageFooterProps) { |
||||
const includesGenerator = useContext(IncludesGenerator) |
||||
const includesResponses = useContext(IncludesResponses) |
||||
return <footer> |
||||
{includesGenerator |
||||
? <Fragment> |
@ -0,0 +1,36 @@ |
||||
@import "ResultText"; |
||||
@import "Attribution"; |
||||
|
||||
.response { |
||||
margin-top: 0.3rem; |
||||
display: flex; |
||||
align-items: stretch; |
||||
flex-flow: row nowrap; |
||||
scroll-margin-top: 12rem; |
||||
list-style: none; |
||||
} |
||||
|
||||
.response.active { |
||||
position: relative; |
||||
min-height: 1.5rem; |
||||
|
||||
&::before { |
||||
width: 1rem; |
||||
margin: 0.2rem 0.2rem 0.2rem 0.5rem; |
||||
content: ""; |
||||
flex: 0 0 auto; |
||||
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; |
||||
} |
||||
} |
@ -0,0 +1,68 @@ |
||||
import { reconstituteResultText, ResultText, type ResultTextPropsFull } from './ResultText'; |
||||
import { |
||||
Attribution, |
||||
type AttributionPropsFull, |
||||
type PartialAttributionPropsFull, reconstituteAttribution |
||||
} from './Attribution'; |
||||
import { FormButton } from './Button'; |
||||
import { IncludesGenerator } from './GeneratorPage'; |
||||
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'; |
||||
import { type Context, createContext, createRef } from 'preact'; |
||||
import { pulseElement } from './pulseElement'; |
||||
|
||||
export const CurrentSelectedResponse: Context<number|null> = createContext<number|null>(null) |
||||
|
||||
export interface ResponseElementProps { |
||||
attribution: AttributionPropsFull |
||||
result: ResultTextPropsFull |
||||
selected: boolean |
||||
} |
||||
|
||||
export interface ResponseElementEvents { |
||||
onSelected?: (mappingId: number) => void |
||||
} |
||||
|
||||
export interface PartialResponseElementProps { |
||||
attribution?: PartialAttributionPropsFull |
||||
result?: Partial<ResultTextPropsFull> |
||||
selected?: boolean |
||||
} |
||||
|
||||
export function reconstituteResponseElement(element: HTMLLIElement, partial?: PartialResponseElementProps): ResponseElementProps { |
||||
const result = reconstituteResultText(element.querySelector<HTMLButtonElement>(".resultText")!, partial?.result) |
||||
const attribution = reconstituteAttribution(element.querySelector<HTMLDivElement>(".attribution")!, partial?.attribution) |
||||
return { |
||||
result: result as ResultTextPropsFull, |
||||
attribution: attribution as AttributionPropsFull, |
||||
selected: partial?.selected ?? element.classList.contains("active") |
||||
} |
||||
} |
||||
|
||||
export const responseIdPrefix="response-" |
||||
|
||||
export function ResponseElement({attribution, result, selected, onSelected}: ResponseElementProps & ResponseElementEvents) { |
||||
const includesGenerator = useContext(IncludesGenerator); |
||||
const [lastSelected, setLastSelected] = useState(selected) |
||||
const onSelect = useCallback(() => { |
||||
if (onSelected) { |
||||
onSelected(result.mappingId) |
||||
} |
||||
}, [attribution, result, onSelected]) |
||||
const ref = createRef<HTMLLIElement>() |
||||
useEffect(() => { |
||||
if (lastSelected !== selected) { |
||||
setLastSelected(selected) |
||||
if (ref.current) { |
||||
pulseElement(ref.current) |
||||
} |
||||
} |
||||
}, [selected, lastSelected, setLastSelected, ref]); |
||||
return <li ref={ref} id={responseIdPrefix + result.mappingId} class={`response attributed${selected ? " active" : ""}`}> |
||||
<ResultText {...result} /> |
||||
<Attribution {...attribution}> |
||||
{includesGenerator |
||||
? <FormButton type={"button"} class="makeResponseActive requiresJs" onClick={onSelect}>Set in Generated Scenario</FormButton> |
||||
: null} |
||||
</Attribution> |
||||
</li> |
||||
} |
@ -0,0 +1,32 @@ |
||||
@import "ResponseElement"; |
||||
@import "Page"; |
||||
@import "TableHeader"; |
||||
|
||||
.responseType { |
||||
list-style: none; |
||||
padding: 1rem; |
||||
scroll-margin-top: 10rem; |
||||
margin-top: 0.5rem; |
||||
margin-left: 1rem; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.responseType > h2 { |
||||
margin-top: 0; |
||||
} |
||||
|
||||
.responseTypeHead { |
||||
position: sticky; |
||||
top: 9.4rem; |
||||
background-color: inherit; |
||||
z-index: 1; |
||||
padding-bottom: 0.2rem; |
||||
} |
||||
|
||||
.responseTypeTitle { |
||||
flex: 1 1 auto; |
||||
} |
||||
|
||||
.responseTypeList { |
||||
padding: 0; |
||||
} |
@ -0,0 +1,79 @@ |
||||
import { |
||||
reconstituteTable, |
||||
TableEmoji, |
||||
type TableFullProps, |
||||
TableHeaderDataset, |
||||
tableIdentifier, |
||||
TableTitle |
||||
} from './TableHeader'; |
||||
import { |
||||
reconstituteResponseElement, |
||||
ResponseElement, |
||||
type ResponseElementProps, responseIdPrefix |
||||
} from './ResponseElement'; |
||||
import { useCallback } from 'preact/hooks'; |
||||
|
||||
export interface ResponseTypeProps { |
||||
table: TableFullProps, |
||||
selectedMappingId: number | null, |
||||
contents: Omit<ResponseElementProps, 'selected'>[], |
||||
} |
||||
|
||||
export interface PartialResponseTypeProps { |
||||
table?: Partial<TableFullProps>; |
||||
selectedMappingId?: number | null; |
||||
contents?: Omit<ResponseElementProps, 'selected'>[]; |
||||
} |
||||
|
||||
export function reconstituteResponseType(element: HTMLLIElement, partial?: PartialResponseTypeProps): ResponseTypeProps { |
||||
const table = reconstituteTable(element.querySelector('.tableHeader')!, partial?.table) as TableFullProps; |
||||
let selected: number | null | undefined = partial?.selectedMappingId ?? null, |
||||
contents: Omit<ResponseElementProps, 'selected'>[] | undefined = partial?.contents; |
||||
if (!contents) { |
||||
contents = []; |
||||
for (const child of Array.from(element.querySelector('.responseTypeList')!.children) as HTMLLIElement[]) { |
||||
const childContents = reconstituteResponseElement(child); |
||||
if (typeof selected === 'undefined' && childContents.selected) { |
||||
selected = childContents.result.mappingId; |
||||
} |
||||
contents.push(childContents); |
||||
} |
||||
if (typeof selected === 'undefined') { |
||||
selected = null; |
||||
} |
||||
} else if (typeof selected === 'undefined') { |
||||
const active = element.querySelector('.response.active'); |
||||
selected = active ? parseInt(active.id.substring(responseIdPrefix.length)) : null; |
||||
} |
||||
return { |
||||
table, |
||||
selectedMappingId: selected, |
||||
contents |
||||
}; |
||||
} |
||||
|
||||
export interface ResponseTypeEvents { |
||||
onSelectResponse?: (tableId: number, mappingId: number) => void |
||||
} |
||||
|
||||
export const responseListIdPrefix = 'responses-'; |
||||
|
||||
export function ResponseType({ table, selectedMappingId, contents, onSelectResponse }: ResponseTypeProps & ResponseTypeEvents) { |
||||
const onSelectChild = useCallback((mappingId: number) => { |
||||
if (onSelectResponse) { |
||||
onSelectResponse(table.id, mappingId) |
||||
} |
||||
}, [onSelectResponse]); |
||||
return <li id={responseListIdPrefix + tableIdentifier(table)} class="responseType window readable"> |
||||
<h2 class="responseTypeHead tableHeader" {...TableHeaderDataset(table)}> |
||||
<TableEmoji emoji={table.emoji} />{' '}<TableTitle title={table.title} /> |
||||
</h2> |
||||
<ul class="responseTypeList"> |
||||
{contents.map(result => |
||||
<ResponseElement key={result.result.mappingId} |
||||
selected={result.result.mappingId === selectedMappingId} |
||||
onSelected={onSelectChild} |
||||
{...result} />)} |
||||
</ul> |
||||
</li>; |
||||
} |
@ -0,0 +1,51 @@ |
||||
@import "Page"; |
||||
@import "ResponseType"; |
||||
|
||||
#responsesHeader { |
||||
position: sticky; |
||||
display: flex; |
||||
flex-flow: column; |
||||
align-items: center; |
||||
border-top: 0; |
||||
border-left: 0; |
||||
border-right: 0; |
||||
border-radius: 0; |
||||
margin: 0; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
height: 9.5rem; |
||||
z-index: 2; |
||||
} |
||||
|
||||
#responsesHeader .buttons { |
||||
display: flex; |
||||
flex-flow: row wrap; |
||||
padding-top: 0.2rem; |
||||
padding-left: 0.3rem; |
||||
padding-right: 0.3rem; |
||||
margin: 0; |
||||
overflow-y: auto; |
||||
overflow-x: visible; |
||||
} |
||||
|
||||
#returnToGenerator { |
||||
flex-basis: 50%; |
||||
} |
||||
|
||||
.responseNavEmoji { |
||||
margin-right: 0.2rem; |
||||
} |
||||
|
||||
#responsesHead { |
||||
margin-top: 0; |
||||
margin-bottom: 0; |
||||
font-size: 1.5rem; |
||||
} |
||||
|
||||
#responseLists { |
||||
display: flex; |
||||
flex-flow: row wrap; |
||||
padding: 0.1rem; |
||||
justify-content: center; |
||||
} |
@ -0,0 +1,42 @@ |
||||
import { createContext } from 'preact'; |
||||
import { responseListIdPrefix, ResponseType, type ResponseTypeProps } from './ResponseType'; |
||||
import { IncludesGenerator } from './GeneratorPage'; |
||||
import { LinkButton } from './Button'; |
||||
import { TableEmoji, tableIdentifier, TableName } from './TableHeader'; |
||||
import { useContext } from 'preact/hooks'; |
||||
|
||||
export const IncludesResponses = createContext(false) |
||||
|
||||
export interface ResponsesProps { |
||||
types: ResponseTypeProps[] |
||||
} |
||||
|
||||
export interface ResponsesEvents { |
||||
onSelectResponse: (tableId: number, mappingId: number) => void |
||||
} |
||||
|
||||
// TODO: add a "reconstitute" function for ResponsesPage
|
||||
|
||||
export function ResponsesPage({ types, onSelectResponse }: ResponsesProps & ResponsesEvents) { |
||||
const includesGenerator = useContext(IncludesGenerator); |
||||
return <div id="responses" class="page"> |
||||
<header id="responsesHeader" class="window"> |
||||
<h1 id="responsesHead">Possible Responses</h1> |
||||
<nav id="responsesHeaderNav" class="buttons"> |
||||
{types.map(type => |
||||
<LinkButton key={tableIdentifier(type.table)} href={`#${responseListIdPrefix}${tableIdentifier(type.table)}`} external={false}> |
||||
<TableEmoji emoji={type.table.emoji} />{' '}<TableName name={type.table.name} /> |
||||
</LinkButton>)} |
||||
{includesGenerator |
||||
? <LinkButton href={"#generator"} external={false} id="returnToGenerator">Return to Generator</LinkButton> |
||||
: null} |
||||
</nav> |
||||
</header> |
||||
<ul id="responseLists"> |
||||
{types.map(type => |
||||
<ResponseType key={tableIdentifier(type.table)} |
||||
onSelectResponse={onSelectResponse} |
||||
{...type} />)} |
||||
</ul> |
||||
</div> |
||||
} |
@ -0,0 +1,34 @@ |
||||
import { type MutableRef, useCallback, useRef } from 'preact/hooks'; |
||||
|
||||
export function usePopup<T extends HTMLElement>(): [hostRef: MutableRef<T|null>, triggerPopup: (text: string, className?: 'success'|'info'|'warning'|'error') => Promise<boolean>] { |
||||
const hostRef = useRef<T|null>(null) |
||||
const showPopup = useCallback(async (text: string, className?: 'success'|'info'|'warning'|'error'): Promise<boolean> => { |
||||
const host = hostRef.current |
||||
if (!host) { |
||||
return false |
||||
} |
||||
if (!host.classList.contains("jsPopupHost")) { |
||||
throw Error("host must be jsPopupHost") |
||||
} |
||||
const container = host.ownerDocument.createElement("div") |
||||
container.classList.add("jsPopupContainer") |
||||
host.appendChild(container) |
||||
const popup = host.ownerDocument.createElement("div") |
||||
popup.classList.add("jsPopup") |
||||
if (className) { |
||||
popup.classList.add(className) |
||||
} |
||||
popup.innerText = text |
||||
container.appendChild(popup) |
||||
return new Promise((resolve) => { |
||||
const removePopup = () => { |
||||
container.removeChild(popup) |
||||
host.removeChild(container) |
||||
resolve(true) |
||||
} |
||||
popup.addEventListener('animationend', removePopup) |
||||
popup.addEventListener('animationcancel', removePopup) |
||||
}) |
||||
}, [hostRef]) |
||||
return [hostRef, showPopup] |
||||
} |
@ -0,0 +1 @@ |
||||
|
@ -1,134 +0,0 @@ |
||||
import { |
||||
type RollTable, |
||||
type RollTableDetails, |
||||
type RollTableDetailsAndResults, |
||||
type RollTableResult |
||||
} from './rolltable'; |
||||
|
||||
// TODO: port the rest of these to preact
|
||||
|
||||
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 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 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 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 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, |
||||
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,33 +0,0 @@ |
||||
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> |
||||
} |
||||
} |
@ -1,30 +0,0 @@ |
||||
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> |
||||
} |
@ -1,2 +0,0 @@ |
||||
// TODO: move this to the response file
|
||||
export const responseIdPrefix = "response-" |
@ -1,131 +0,0 @@ |
||||
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…
Reference in new issue