diff --git a/src/client/basic-look.less b/src/client/basic-look.less deleted file mode 100644 index 481d066..0000000 --- a/src/client/basic-look.less +++ /dev/null @@ -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; - } -} diff --git a/src/client/combined-generator-responses-entrypoint.ts b/src/client/combined-generator-responses-entrypoint-old.ts similarity index 82% rename from src/client/combined-generator-responses-entrypoint.ts rename to src/client/combined-generator-responses-entrypoint-old.ts index 06cce3f..bab94dd 100644 --- a/src/client/combined-generator-responses-entrypoint.ts +++ b/src/client/combined-generator-responses-entrypoint-old.ts @@ -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) => { diff --git a/src/client/combined-generator-responses-entrypoint.less b/src/client/combined-generator-responses-entrypoint.less index 0c06fd6..1328793 100644 --- a/src/client/combined-generator-responses-entrypoint.less +++ b/src/client/combined-generator-responses-entrypoint.less @@ -1,5 +1,7 @@ -@import "generator-entrypoint"; -@import "responses-entrypoint"; +@import "../common/client/GeneratorPage"; +@import "../common/client/ResponsesPage"; +@import "../common/client/Page"; +@import "../common/client/PageFooter"; #generator:not(:target) { display: none; diff --git a/src/client/generator-entrypoint.ts b/src/client/generator-entrypoint-old.ts similarity index 99% rename from src/client/generator-entrypoint.ts rename to src/client/generator-entrypoint-old.ts index 36b1309..db26757 100644 --- a/src/client/generator-entrypoint.ts +++ b/src/client/generator-entrypoint-old.ts @@ -16,7 +16,7 @@ import { import { buildGenerated, htmlTableIdentifier } from '../common/template'; import { DOMLoaded } from './onload'; import { scrapeGeneratedScenario } from './scraper'; -import { showPopup } from './popup'; +import { showPopup } from './Popup'; import { pulseElement } from './pulse'; import { DOMTemplateBuilder } from './template'; import escapeHTML from 'escape-html'; diff --git a/src/client/generator-entrypoint.less b/src/client/generator-entrypoint.less index 5fd00c8..85e1220 100644 --- a/src/client/generator-entrypoint.less +++ b/src/client/generator-entrypoint.less @@ -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"; diff --git a/src/client/popup.ts b/src/client/popup.ts deleted file mode 100644 index 026a411..0000000 --- a/src/client/popup.ts +++ /dev/null @@ -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) - }) -} diff --git a/src/client/responses-entrypoint.ts b/src/client/responses-entrypoint-old.ts similarity index 99% rename from src/client/responses-entrypoint.ts rename to src/client/responses-entrypoint-old.ts index 133e4db..4a164ba 100644 --- a/src/client/responses-entrypoint.ts +++ b/src/client/responses-entrypoint-old.ts @@ -74,7 +74,7 @@ function initResponseList(): ResponseLists { if (!lists) { throw Error(`can't parse #responseLists`) } - const {db, active} = lists + const {db} = lists return new ResponseLists(db, listsElement).configureHandlers() } diff --git a/src/client/responses-entrypoint.less b/src/client/responses-entrypoint.less index 848aa54..c1e3221 100644 --- a/src/client/responses-entrypoint.less +++ b/src/client/responses-entrypoint.less @@ -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"; diff --git a/src/client/scraper.ts b/src/client/scraper.ts deleted file mode 100644 index db87c70..0000000 --- a/src/client/scraper.ts +++ /dev/null @@ -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("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(`.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|null]|null|undefined { - if (!responseTypeElement) { - return null - } - const table = scrapeTableHeader(responseTypeElement.querySelector(`.tableHeader`)) - if (!table || !table.full) { - return - } - const resultTable = db.addTable(table) - let activeResult: RollTableResultFull|null = null - for (const resultElement of responseTypeElement.querySelectorAll(`.response`)) { - const partialResult = scrapeResultText(resultElement.querySelector(`.resultText`)) - const author = scrapeAuthor(resultElement.querySelector(`.author`)) - const set = scrapeResultSet(resultElement.querySelector(`.resultSet`)) - const active = resultElement.classList.contains("active") - - if (!partialResult || !partialResult.full || typeof author === "undefined" || !set) { - return - } - - const result = db.addResult({ - ...partialResult, - set, - author, - table: resultTable, - }) - if (active) { - activeResult = result - } - } - return [resultTable, activeResult] -} - -export function scrapeResponseLists(lists: HTMLElement): {db: RollTableDatabase, active: ReadonlyMap|null>}|undefined -export function scrapeResponseLists(lists: null): null -export function scrapeResponseLists(lists: HTMLElement|null): {db: RollTableDatabase, active: ReadonlyMap|null>}|null|undefined { - if (!lists) { - return null - } - const db = new RollTableDatabase() - const active = new Map|null> - for (const responseTypeElement of lists.querySelectorAll(`.responseType`)) { - const responseType = scrapeResponseList(responseTypeElement, db) - if (!responseType) { - return - } - const [table, activeResult] = responseType - active.set(table, activeResult) - } - return { - db, - active, - } -} diff --git a/src/client/template.ts b/src/client/template.ts deleted file mode 100644 index cd5983c..0000000 --- a/src/client/template.ts +++ /dev/null @@ -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(tagName: T, features: ElementFeatures, contents: Node[]): HTMLElementTagNameMap[T] { - const element = document.createElement(tagName) - if (typeof features.id !== "undefined") { - element.id = features.id - } - if (typeof features.classes !== "undefined") { - if (typeof features.classes === "string") { - element.classList.add(features.classes) - } else { - for (const className of features.classes) { - element.classList.add(className) - } - } - } - if (typeof features.data !== "undefined") { - for (const [key, value] of features.data) { - element.dataset[key] = value - } - } - for (const node of contents) { - element.appendChild(node) - } - return element -} - -class DOMTemplateBuilderImpl implements TemplateBuilder { - makeButton(features: ButtonFeatures, ...contents: Node[]): HTMLButtonElement { - const element = tag('button', features, contents) - element.classList.add("button") - element.type = features.type ?? ButtonType.Button - if (typeof features.name === "string") { - element.name = features.name - } - if (typeof features.value === "string") { - element.value = features.value - } - return element - } - - makeCheckbox(features: CheckboxFeatures, ...contents: Node[]): HTMLInputElement { - const element = tag('input', features, contents) - element.type = "checkbox" - element.name = features.name - if (features.checked) { - element.checked = true - } - if (typeof features.value === "string") { - element.value = features.value - } - return element - } - - makeDiv(features: ElementFeatures, ...contents: Node[]): HTMLDivElement { - return tag('div', features, contents); - } - - makeFooter(features: ElementFeatures, ...contents: Node[]): HTMLElement { - return tag('footer', features, contents); - } - - makeForm(features: FormFeatures, ...contents: Node[]): HTMLFormElement { - const element = tag('form', features, contents) - element.action = features.action - element.method = features.method - return element; - } - - makeHeader(features: ElementFeatures, ...contents: Node[]): HTMLElement { - return tag('header', features, contents) - } - - makeHeading1(features: ElementFeatures, ...contents: Node[]): HTMLHeadingElement { - return tag('h1', features, contents); - } - - makeHeading2(features: ElementFeatures, ...contents: Node[]): HTMLHeadingElement { - return tag('h2', features, contents); - } - - makeHyperlink(features: HyperlinkFeatures, ...contents: Node[]): HTMLAnchorElement { - const element = tag('a', features, contents) - element.href = features.url - if (features.destination === HyperlinkDestination.External) { - element.rel = "external nofollow noreferrer" - } - if (features.asButton) { - element.classList.add("button") - element.draggable = false - } - return element; - } - - makeLabel(features: LabelFeatures, ...contents: Node[]): HTMLLabelElement { - const element = tag('label', features, contents) - if (typeof features.forId === "string") { - element.htmlFor = features.forId - } - return element; - } - - makeListItem(features: ElementFeatures, ...contents: Node[]): HTMLLIElement { - return tag('li', features, contents) - } - - makeNav(features: ElementFeatures, ...contents: Node[]): HTMLElement { - return tag('nav', features, contents) - } - - makeNoscript(features: ElementFeatures, ...contents: Node[]): HTMLElement { - return tag('noscript', features, contents); - } - - makeParagraph(features: ElementFeatures, ...contents: Node[]): HTMLParagraphElement { - return tag('p', features, contents); - } - - makeSpan(features: ElementFeatures, ...contents: Node[]): HTMLSpanElement { - return tag('span', features, contents); - } - - makeText(text: string): Text { - return document.createTextNode(text); - } - - makeUnorderedList(features: ElementFeatures, ...contents: Node[]): HTMLUListElement { - return tag('ul', features, contents); - } -} - -export const DOMTemplateBuilder = new DOMTemplateBuilderImpl() diff --git a/src/client/types/customevent.d.ts b/src/client/types/customevent.d.ts deleted file mode 100644 index 4c2c642..0000000 --- a/src/client/types/customevent.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { RollTableDetailsAndResults, RollTableResultFull } from '../../common/rolltable'; -import type { RerollEventDetail } from '../generator-entrypoint'; - -interface CustomEventMap { - "resultselected": CustomEvent>; - "reroll": CustomEvent -} -declare global { - interface HTMLElement { - addEventListener(type: K, - listener: (this: Document, ev: CustomEventMap[K]) => void, - options?: boolean|EventListenerOptions): void; - dispatchEvent(ev: CustomEventMap[K]): boolean; - dispatchEvent(ev: HTMLElementEventMap[K]): boolean; - } -} diff --git a/src/common/template/Attribution.less b/src/common/client/Attribution.less similarity index 100% rename from src/common/template/Attribution.less rename to src/common/client/Attribution.less diff --git a/src/common/template/Attribution.tsx b/src/common/client/Attribution.tsx similarity index 96% rename from src/common/template/Attribution.tsx rename to src/common/client/Attribution.tsx index 3840a1c..872a2cf 100644 --- a/src/common/template/Attribution.tsx +++ b/src/common/client/Attribution.tsx @@ -3,14 +3,12 @@ import type { } 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'; diff --git a/src/common/template/AttributionAuthor.less b/src/common/client/AttributionAuthor.less similarity index 100% rename from src/common/template/AttributionAuthor.less rename to src/common/client/AttributionAuthor.less diff --git a/src/common/template/AttributionAuthor.tsx b/src/common/client/AttributionAuthor.tsx similarity index 100% rename from src/common/template/AttributionAuthor.tsx rename to src/common/client/AttributionAuthor.tsx diff --git a/src/common/template/AttributionSet.less b/src/common/client/AttributionSet.less similarity index 100% rename from src/common/template/AttributionSet.less rename to src/common/client/AttributionSet.less diff --git a/src/common/template/AttributionSet.tsx b/src/common/client/AttributionSet.tsx similarity index 100% rename from src/common/template/AttributionSet.tsx rename to src/common/client/AttributionSet.tsx diff --git a/src/common/template/Button.less b/src/common/client/Button.less similarity index 81% rename from src/common/template/Button.less rename to src/common/client/Button.less index 89cf907..021ed1a 100644 --- a/src/common/template/Button.less +++ b/src/common/client/Button.less @@ -31,3 +31,14 @@ transform: none; } } + +.buttons { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: stretch; + & > * { + flex: 1 0 auto; + margin: 0.2rem 0 0 0.3rem + } +} diff --git a/src/common/client/Button.tsx b/src/common/client/Button.tsx new file mode 100644 index 0000000..c489a88 --- /dev/null +++ b/src/common/client/Button.tsx @@ -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 + {children} + +} + +export function FormButton({"class": className, id, name, value, disabled, type = "button", onClick, children}: FormButtonProps & PropsWithChildren) { + return +} diff --git a/src/common/template/GeneratedElement.less b/src/common/client/GeneratedElement.less similarity index 100% rename from src/common/template/GeneratedElement.less rename to src/common/client/GeneratedElement.less diff --git a/src/common/client/GeneratedElement.tsx b/src/common/client/GeneratedElement.tsx new file mode 100644 index 0000000..b6155d4 --- /dev/null +++ b/src/common/client/GeneratedElement.tsx @@ -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 + selected?: boolean +} & PartialGeneratedResultPropsFull + +export type PartialGeneratedElementReadonlyProps = { + table?: Partial + 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('.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(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
  • +

    + + +

    + +
  • +} diff --git a/src/common/template/GeneratedResult.less b/src/common/client/GeneratedResult.less similarity index 100% rename from src/common/template/GeneratedResult.less rename to src/common/client/GeneratedResult.less diff --git a/src/common/template/GeneratedResult.tsx b/src/common/client/GeneratedResult.tsx similarity index 57% rename from src/common/template/GeneratedResult.tsx rename to src/common/client/GeneratedResult.tsx index dc76bf2..c27618f 100644 --- a/src/common/template/GeneratedResult.tsx +++ b/src/common/client/GeneratedResult.tsx @@ -4,33 +4,27 @@ import { type PartialAttributionProps, type PartialAttributionPropsEmpty, reconstituteAttribution, } from './Attribution'; -import { responseIdPrefix } from './util'; import { reconstituteResultText, ResultText, type ResultTextPropsFull, type ResultTextPropsLimited } from './ResultText'; -import { Button } from './Button'; +import { LinkButton } from './Button'; +import { responseIdPrefix } from './ResponseElement'; +import { IncludesResponses } from './ResponsesPage'; +import { useContext } from 'preact/hooks'; -export type GeneratedResultPropsFull = { - includesResponses: boolean -} & AttributionPropsFull & ResultTextPropsFull +export type GeneratedResultPropsFull = AttributionPropsFull & ResultTextPropsFull -export type GeneratedResultPropsLimited = { - includesResponses?: null -} & AttributionPropsEmpty & ResultTextPropsLimited +export type GeneratedResultPropsLimited = AttributionPropsEmpty & ResultTextPropsLimited export type GeneratedResultProps = GeneratedResultPropsFull|GeneratedResultPropsLimited -export type PartialGeneratedResultPropsFull = { - includesResponses?: boolean -} & PartialAttributionProps & Partial +export type PartialGeneratedResultPropsFull = PartialAttributionProps & Partial -export type PartialGeneratedResultPropsLimited = { - includesResponses?: null -} & PartialAttributionPropsEmpty & Partial +export type PartialGeneratedResultPropsLimited = PartialAttributionPropsEmpty & Partial export type PartialGeneratedResultProps = PartialGeneratedResultPropsFull|PartialGeneratedResultPropsLimited @@ -41,23 +35,23 @@ export function reconstituteGeneratedResult(div: HTMLDivElement, partial?: Parti reconstituteAttribution(div.querySelector(".attribution")!, partial) if (result.updated && attribution.set) { return { - includesResponses: !!div.querySelector(".jumpToResponse"), ...attribution, ...result, } } else { return { - text: result.text + ...result as ResultTextPropsLimited, } } } export function GeneratedResult(props: GeneratedResultProps) { + const includesResponses = useContext(IncludesResponses); return
    - {props.includesResponses - ?

    + {includesResponses && props.set + ?

    Jump to Result in List

    : null}
    diff --git a/src/common/client/GeneratorPage.less b/src/common/client/GeneratorPage.less new file mode 100644 index 0000000..c8f7a24 --- /dev/null +++ b/src/common/client/GeneratorPage.less @@ -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 +} diff --git a/src/common/client/GeneratorPage.tsx b/src/common/client/GeneratorPage.tsx new file mode 100644 index 0000000..c477698 --- /dev/null +++ b/src/common/client/GeneratorPage.tsx @@ -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 { + const addToDiscordUrl = partial?.addToDiscordUrl ?? element.querySelector("#addToDiscord")?.href ?? null + const editable = partial?.editable ?? !!element.querySelector("#rollButtons") + const elements = partial?.elements ?? + Array.from(element.querySelector("#generatedScenario")!.children) + .map(li => reconstituteGeneratedElement(li as HTMLLIElement, editable ? {} : {selected: null})) + + const generatorTargetUrl = partial?.generatorTargetUrl ?? element.querySelector("#generatorWindow")!.action + return { + addToDiscordUrl, + editable, + generatorTargetUrl, + elements: elements, + } +} + +export interface GeneratorEvents { + onCopy?: (format: ExportFormat) => Promise + onReroll?: (which: GeneratorReroll) => Promise + 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() + const [rerollPopupHost, showRerollPopup] = usePopup() + 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((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
    +
    +

    Your generated scenario

    +
      + {elements.map(i => )} +
    +
    +
    + Markdown + BBCode + Text + Emoji + Text Only +
    + {editable ?
    + + Reroll {selected === GeneratorSelectionState.All ? 'All' : 'Selected'} + + Select All + Select None +
    : null} +
    + {editable ? + New Scenario + Get Scenario Link + : + Open in Generator + } +
    + { addToDiscordUrl || includesResponses ? + : null} +
    +
    +
    +} diff --git a/src/common/client/Main.less b/src/common/client/Main.less new file mode 100644 index 0000000..afe5a2e --- /dev/null +++ b/src/common/client/Main.less @@ -0,0 +1,6 @@ +body { + background-color: deepskyblue; + font-family: sans-serif; + padding: 0; + margin: 0; +} diff --git a/src/common/client/MainGeneratorOnly.less b/src/common/client/MainGeneratorOnly.less new file mode 100644 index 0000000..e9b290b --- /dev/null +++ b/src/common/client/MainGeneratorOnly.less @@ -0,0 +1,3 @@ +@import "./Main"; +@import "./GeneratorPage"; +@import "./PageFooter"; diff --git a/src/common/client/MainGeneratorOnly.tsx b/src/common/client/MainGeneratorOnly.tsx new file mode 100644 index 0000000..639e6d7 --- /dev/null +++ b/src/common/client/MainGeneratorOnly.tsx @@ -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 + initialSelected?: ReadonlySet +} + +export interface GeneratorMainEvents { + copyText?: (text: string) => Promise +} + +// 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>(initialResults) + const [selected, setSelected] = + useState|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 + + + +} diff --git a/src/common/client/Page.less b/src/common/client/Page.less new file mode 100644 index 0000000..0508366 --- /dev/null +++ b/src/common/client/Page.less @@ -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; +} + + diff --git a/src/common/template/PageFooter.less b/src/common/client/PageFooter.less similarity index 100% rename from src/common/template/PageFooter.less rename to src/common/client/PageFooter.less diff --git a/src/common/template/PageFooter.tsx b/src/common/client/PageFooter.tsx similarity index 72% rename from src/common/template/PageFooter.tsx rename to src/common/client/PageFooter.tsx index 1ca3c74..e8fb2bc 100644 --- a/src/common/template/PageFooter.tsx +++ b/src/common/client/PageFooter.tsx @@ -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 { return { creditsUrl: partial.creditsUrl ?? footer.querySelector(".creditsLink")!.href, - includesResponses: partial.includesResponses ?? footer.querySelector(".jsOffHint") !== null, - includesGenerator: partial.includesGenerator ?? footer.querySelector(".saveHint") !== null, } } -export function PageFooter({creditsUrl, includesGenerator, includesResponses}: PageFooterProps) { +export function PageFooter({creditsUrl}: PageFooterProps) { + const includesGenerator = useContext(IncludesGenerator) + const includesResponses = useContext(IncludesResponses) return