parent
5b1308bdf3
commit
f0a46781de
@ -1,6 +1,24 @@ |
||||
function onPulseEnd(e: AnimationEvent): void { |
||||
if (e.animationName === "pulse-bg" && e.target instanceof HTMLElement) { |
||||
e.target.classList.remove("pulse") |
||||
e.target.removeEventListener("animationend", onPulseEnd) |
||||
e.target.removeEventListener("animationcancel", onPulseEnd) |
||||
} |
||||
} |
||||
|
||||
export function pulseElement(element: HTMLElement) { |
||||
element.classList.add("pulse") |
||||
element.style.animation = "none"; |
||||
getComputedStyle(element).animation |
||||
setTimeout(element.style.animation = "") |
||||
element.removeEventListener("animationend", onPulseEnd) |
||||
element.removeEventListener("animationcancel", onPulseEnd) |
||||
if (element.classList.contains("pulse")) { |
||||
const anim = |
||||
element.getAnimations().find(v => v instanceof CSSAnimation && v.animationName === "pulse-bg") |
||||
if (anim) { |
||||
anim.finish() |
||||
anim.play() |
||||
} |
||||
} else { |
||||
element.classList.add("pulse") |
||||
} |
||||
element.addEventListener("animationend", onPulseEnd) |
||||
element.addEventListener("animationcancel", onPulseEnd) |
||||
} |
||||
|
@ -1,16 +1,82 @@ |
||||
import type { RollTable, RollTableDatabase } from '../common/rolltable'; |
||||
import type { RollTableDatabase, RollTableDetailsAndResults, RollTableResultFull } from '../common/rolltable'; |
||||
import { DOMLoaded } from './onload'; |
||||
import { scrapeResponseLists } from './scraper'; |
||||
import { htmlTableIdentifier } from '../common/template'; |
||||
import escapeHTML from 'escape-html'; |
||||
|
||||
class ResponseList { |
||||
class ResponseLists { |
||||
readonly db: RollTableDatabase |
||||
constructor(db: RollTableDatabase) { |
||||
readonly listsElement: HTMLElement |
||||
constructor(db: RollTableDatabase, listsElement: HTMLElement) { |
||||
this.db = db |
||||
this.listsElement = listsElement |
||||
} |
||||
|
||||
addSelectionListener(listener: (e: CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>) => void, options?: boolean|EventListenerOptions): void { |
||||
this.listsElement.addEventListener("resultselected", listener, options) |
||||
} |
||||
|
||||
configureHandlers(): this { |
||||
this.listsElement.addEventListener("click", (e) => { |
||||
if (e.target instanceof HTMLElement && e.target.classList.contains("makeResponseActive")) { |
||||
const response = e.target.closest(`.response`) |
||||
if (!response) { |
||||
console.log("no response") |
||||
return |
||||
} |
||||
const mappingId = response.id && response.id.startsWith("response-") ? parseInt(response.id.substring("response-".length), 10) : NaN |
||||
if (isNaN(mappingId)) { |
||||
console.log("no mapping ID") |
||||
return |
||||
} |
||||
const result = this.db.mappings.get(mappingId) |
||||
if (!result) { |
||||
console.log("no result") |
||||
return |
||||
} |
||||
const ev = new CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>("resultselected", { |
||||
bubbles: true, |
||||
cancelable: true, |
||||
detail: result |
||||
}) |
||||
if (e.target.dispatchEvent(ev)) { |
||||
this.setActiveElementForTable(result) |
||||
const button = response.querySelector(`.resultText`) as HTMLElement |
||||
if (button) { |
||||
button.focus() |
||||
} |
||||
} |
||||
} |
||||
}) |
||||
return this |
||||
} |
||||
|
||||
setActiveElementForTable(result: RollTableResultFull<RollTableDetailsAndResults>) { |
||||
const oldActive = this.listsElement.querySelector(`#responses-${escapeHTML(htmlTableIdentifier(result.table))} .response.active`) |
||||
const newActive = this.listsElement.querySelector(`#response-${escapeHTML(`${result.mappingId}`)}`) |
||||
if (!newActive || oldActive === newActive) { |
||||
return |
||||
} |
||||
newActive.classList.add("active") |
||||
if (!oldActive) { |
||||
return |
||||
} |
||||
oldActive.classList.remove("active") |
||||
} |
||||
} |
||||
|
||||
function initResponseList(): ResponseList { |
||||
throw Error("not yet implemented") |
||||
function initResponseList(): ResponseLists { |
||||
const listsElement = document.querySelector<HTMLElement>(`#responseLists`) |
||||
if (!listsElement) { |
||||
throw Error(`can't find #responseLists`) |
||||
} |
||||
const lists = scrapeResponseLists(listsElement) |
||||
if (!lists) { |
||||
throw Error(`can't parse #responseLists`) |
||||
} |
||||
const {db, active} = lists |
||||
return new ResponseLists(db, listsElement).configureHandlers() |
||||
} |
||||
|
||||
export const responseList: Promise<ResponseList> = DOMLoaded.then(() => initResponseList()) |
||||
export const db: Promise<RollTableDatabase> = responseList.then(r => r.db) |
||||
export const responseLists: Promise<ResponseLists> = DOMLoaded.then(() => initResponseList()) |
||||
export const db: Promise<RollTableDatabase> = responseLists.then(r => r.db) |
||||
|
@ -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() |
@ -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; |
||||
} |
||||
} |
@ -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,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-" |
@ -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…
Reference in new issue