import { HAttrOnData, HAttrOnDataFnc, HElement, HElements, HHandlerCtx, THSP } from "./hsml"; import { hsmls2idomPatch } from "./hsml-idom"; export type HState = () => State; export type HView = ( state: State ) => HElements; export type HView1 = ( state: State ) => HElement; export type HAppAction = | "happ-init" | "happ-mount" | "happ-umount" | "happ-attribute"; export enum HAppActions { init = "happ-init", mount = "happ-mount", umount = "happ-umount", attribute = "happ-attribute" } export interface HAction { type: HActionType; data?: any; event?: Event; } export type HDispatch = { ( type: HAction["type"], data?: HAction["data"] ): Promise; event( type: HAction["type"], data?: HAction["data"], element?: HTMLElement | Window | Document ): void; }; type SkipUpdate = true; export type HDispatcher = ( // this: HApp, action: HAction, state: State, dispatch: HDispatch, app: HApp ) => Promise; /** * Original scheduling approach using requestAnimationFrame, which is subject to the 16ms frame budget and can cause violation warnings in devtools when the callback takes too long to execute. */ // const schedule = (callback: FrameRequestCallback): number => // window.requestAnimationFrame(callback); // const unschedule = (handle: number): void => // window.cancelAnimationFrame(handle); /** * This is the same approach React adopted — MessageChannel posts a macrotask that the browser doesn't subject to the 16ms frame budget, so the violation warning goes away. The scheduling behavior is otherwise identical: * - Callbacks still batch — multiple update() calls before the message fires result in one render * - unschedule() still works by removing the callback from the map * - Timing is similar (macrotask, runs after current call stack clears) */ const scheduledCallbacks = new Map void>(); let scheduleId = 0; const scheduleChannel = new MessageChannel(); scheduleChannel.port2.onmessage = () => { const cbs = [...scheduledCallbacks.values()]; scheduledCallbacks.clear(); cbs.forEach(cb => cb()); }; const schedule = (callback: () => void): number => { const id = ++scheduleId; const wasEmpty = scheduledCallbacks.size === 0; scheduledCallbacks.set(id, callback); if (wasEmpty) { scheduleChannel.port1.postMessage(null); } return id; }; const unschedule = (handle: number): void => { scheduledCallbacks.delete(handle); }; const msgAction = "action:"; const msgDispatch = "dispatch:"; const msgRender = "render:"; const msgUpdate = "update:"; const HAPP = "happ"; export interface HAppI { state: HState; view: HView; dispatcher: HDispatcher; element?: Element | string | null; debug?: boolean; /** Pattern ^[a-z][a-z0-9-]{0,30}[a-z0-9]$ */ id?: string; attributes?: string[]; } /** * HApp definition * * @param hAppI HApp definition * @returns HApp instance */ export function happ(hAppI: { state: HState; view: HView; dispatcher: HDispatcher; element: Element | string | null; debug?: boolean; /** Pattern ^[a-z][a-z0-9-]{0,30}[a-z0-9]$ */ id?: string; // attributes?: string[]; }) { return new HApp( hAppI.state, hAppI.view, hAppI.dispatcher, hAppI.element, hAppI.debug, hAppI.id // hAppI!.attributes ); } // export type Class = new (...args: any[]) => T; // export function happi( // hAppI: Class>, // element?: Element | string | null, // debug? boolean, // /** Pattern ^[a-z][a-z0-9-]{0,30}[a-z0-9]$ */ // id?: string // // attributes?: string[] // ) { // const hapi = new hAppI(); // return new HApp( // hapi.state, // hapi.view, // hapi.dispatcher, // element, // debug // ); // } // HAppEl /** * HApp custom HTML element definition. * * @param name Custom element name, pattern ^[a-z]{0,10}-[a-z0-9-]{0,30}[a-z0-9]$ * @param hAppI HApp definition */ export function happel( /** Pattern ^[a-z]{0,10}-[a-z0-9-]{0,30}[a-z0-9]$ */ name: string, hAppI: { state: HState; view: HView; dispatcher: HDispatcher; id?: string; /** Element attribute list */ attributes?: string[]; debug?: boolean; } ): void { // condition to prevent rerunning on hot module reloads if (!customElements.get(name)) { customElements.define( name, class HAppElement extends HTMLElement { static get observedAttributes() { const state = hAppI.state(); return hAppI.attributes ?? ( typeof state === "object" ? Object.keys(state as object) : [] ); } private _happel: HApp; constructor() { super(); this._happel = new HApp( hAppI.state, hAppI.view, hAppI.dispatcher, undefined, hAppI.debug, hAppI.id ?? name // hAppI.attributes ); (this._happel as any).customElement = this; } connectedCallback() { // this._happel.mount(this); this.attachShadow({ mode: "open" }); this._happel.mount(this.shadowRoot as any); } disconnectedCallback() { this._happel.umount(); } adoptedCallback() { this._happel.update(); } attributeChangedCallback( attrName: string, oldVal: string | null, newVal: string | null ) { this._happel.dispatch( HAppActions.attribute as any, { attrName, oldVal, newVal }); } } ); } } /** * HSML App */ export class HApp implements HHandlerCtx { static log = console.log; static error = console.error; static warn = console.warn; readonly state: State; readonly view: HView; readonly dispatcher: HDispatcher; debug: boolean; /** Pattern ^[a-z][a-z0-9-]{0,30}[a-z0-9]$ */ readonly id: string; // readonly attributes?: string[]; readonly element?: HTMLElement; readonly refs: { [key: string]: HTMLElement } = {}; readonly customElement?: HTMLElement; // happ custom html element private _updateSched?: number; // private _onDispatch?: HDispatcher; private _abortController?: AbortController; /** * @param state State init function * @param view View renderer function * @param dispatcher Dispatcher function * @param element Root element * @param debug Debug mode * @param id HApp ID, pattern "^[a-z][a-z0-9-]{0,30}[a-z0-9]$" */ constructor( state: HState, view: HView, dispatcher?: HDispatcher, element?: Element | string | null, debug?: boolean , id?: string, // attributes?: string[] ) { this.id = id ?? HAPP; this.debug = debug ?? false; // this.attributes = attributes ?? []; // this.attributes = attributes ?? typeof state() === "object" // ? Object.keys(state() as object) // : []; this.state = state(); this.view = view; this.dispatcher = dispatcher ?? (async (a) => HApp.log(this.id, msgAction, a.type, a.data)); this._dispatchAction(HAppActions.init, this).then(() => element && this.mount(element)); } /** * Dispatch app action. */ dispatch: HDispatch = Object.assign( async ( type: HActionType, data?: any ): Promise => { return this._dispatchAction(type, data, undefined); }, { event: (type: HActionType, data?: any, element?: HTMLElement): void => { const emit = () => { const el = element ?? this.customElement ?? this.element; if (el) { el.dispatchEvent(new CustomEvent(type, { detail: data, bubbles: true, composed: true })); // hsml-idom event handler supports only onEventName (el as any)[`on${type}`]?.(new CustomEvent(type, { detail: data })); } }; if (this.debug) { HApp.log(this.id, msgAction, { type, data, element }); const t0 = performance.now(); emit(); const t1 = performance.now(); HApp.log(this.id, msgDispatch, `${t1 - t0} ms`); } else { emit(); } } } ); // onDispatch = (dispatcher: HDispatcher): this => { // this._onDispatch = dispatcher; // return this; // } private async _dispatchAction( type: HActionType | HAppAction | HAppActions, data?: any, event?: Event ): Promise { if (this.debug) { HApp.log(this.id, msgAction, { type, data, event }); const t0 = performance.now(); await this._dispatch(type, data, event); const t1 = performance.now(); HApp.log(this.id, msgDispatch, `${t1 - t0} ms`, this.state); } else { await this._dispatch(type, data, event); } } private async _dispatch( type: HActionType | HAppAction | HAppActions, data: any, event?: Event ): Promise { try { const skipUpdate = await this.dispatcher( { type: type as HActionType, data, event }, this.state, this.dispatch, this ); if (!skipUpdate) { this.update(); } } catch (e) { HApp.error(this.id, msgDispatch, e); } } eventListener(type: string, listener: (data: any, event: Event) => void, element: Window | Document | HTMLElement = window): void { if (!this._abortController) { this._abortController = new AbortController(); } element.addEventListener( type, e => listener("detail" in e ? e.detail : undefined, e), { signal: this._abortController?.signal } ); } /** * Render HSML based on app state. */ render = (): HElements => { if (this.debug) { const t0 = performance.now(); let hsmls; try { hsmls = this.view(this.state); } catch (e) { HApp.error(this.id, msgRender, e); } const t1 = performance.now(); HApp.log(this.id, msgRender, `${t1 - t0} ms`, hsmls); return hsmls ?? []; } else { let hsmls; try { hsmls = this.view(this.state); } catch (e) { HApp.error(this.id, msgRender, e); } return hsmls ?? []; } } /** * HSML action callback. */ actionCb = async (actionType: HActionType, data: HAttrOnData, event: Event): Promise => { data = (data?.constructor === Function) ? (data as HAttrOnDataFnc)(event) : data; if (data === undefined && event) { if (event instanceof CustomEvent) { data = event.detail; } else { data = await formData(event) as any; } // TODO middlewares for data processing // const middlewares: ((data: any, event: Event) => any)[] = []; // data = middlewares.reduce( // (data, middleware) => middleware(data, event), // data // ); } this._dispatchAction(actionType, data, event); } private _updateDom( el: Element, hsml: HElements, ctx: HHandlerCtx ): void { if (this.debug) { const t0 = performance.now(); hsmls2idomPatch(el, hsml, ctx); const t1 = performance.now(); HApp.log(this.id, msgUpdate, `${t1 - t0} ms`, el); } else { hsmls2idomPatch(el, hsml, ctx); } } /** * Mount app to DOM element. * * @param e DOM element */ mount = (e: Element | string | null): this => { const el = (typeof e === "string") ? document.getElementById(e) : e; if (el && (el as any)[HAPP]) { const a = (el as any)[HAPP] as HApp; a.umount(); } if (el && !this.element) { (this as any).element = el; (el as any)[HAPP] = this; const hsmls = (this as any).render(); this._updateDom(el, hsmls, this); this._dispatchAction(HAppActions.mount, this.element); } return this; } /** * Umount app to DOM element. */ umount = async (): Promise => { if (this.element) { await this._dispatchAction(HAppActions.umount, this.element); const aNodes = this.element.querySelectorAll(`[${HAPP}]`); // const aNodes = this.element.querySelectorAll("*"); for (let i = 0; i < aNodes.length; i++) { const a = (aNodes[i] as any)[HAPP] as HApp; a?.umount(); } while (this.element.firstChild /*.hasChildNodes()*/) { this.element.removeChild(this.element.firstChild); } delete (this.element as any)[HAPP]; (this as any).element = undefined; } if (this._abortController) { this._abortController.abort(); this._abortController = undefined; } return this; } /** * Update DOM element based on app state. */ update = (): this => { if (this.element && !this._updateSched) { this._updateSched = schedule(() => { if (this.element) { const hsmls = this.render(); this._updateDom(this.element, hsmls, this); } this._updateSched = undefined; }); } return this; } toHsml = (): HElement => { if (this.element) { if (this._updateSched) { unschedule(this._updateSched); this._updateSched = undefined; } else { return ["div", { skip: true }]; } } const hsmls = this.render(); hsmls.push( (e: Element) => { if (!this.element) { (this as any).element = e; (e as any).happ = this; this._dispatchAction(HAppActions.mount, this.element); } }); // If a view returns a cached/shared array, this mutation would corrupt it across renders. // const hsmls = [...this.render(), // (e: Element) => { // if (!this.element) { // (this as any).element = e; // (e as any)[HAPP] = this; // e.setAttribute(HAPP, this.id ?? ""); // this._dispatchAction(HAppActions.mount, this.element); // } // }]; return ["div", hsmls]; } toHtml = (): string => { return this.element ? this.element.outerHTML : ""; } } type FormDataInputValue = string | number | boolean | null | Array; // type FormInputData = { // name?: string; // value: FormDataInputValue; // valueString?: string; // valueNumber?: number | null; // valueDate?: Date | null; // valueFile?: File | FileList | null; // validation: string; // valid: boolean; // }; // type FormDataResult = { // data: { // [name: string]: FormDataInputValue; // }; // validation: { [name: string]: string }; // valid: boolean; // }; export type HFormInputData = { name?: string; value: Value; valueString?: string | null; valueNumber?: number | null; valueDate?: Date | null; valueFile?: File | FileList | null; validation: string; valid: boolean; }; export type HFormData = { data: Data; validation: { [name in keyof Data]?: string }; valid?: boolean; }; /** * Convert ArrayBuffer to base64 string using native browser APIs */ function arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ""; const chunkSize = 0x8000; // Process in 32KB chunks to avoid call stack issues for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); binary += String.fromCharCode(...chunk); } return btoa(binary); } async function formData(e: Event): Promise { const el = e.target as HTMLElement; switch (el.nodeName) { case "FORM": e.preventDefault?.(); const form: HFormData = { data: {}, validation: {}, valid: true }; const els = (el as HTMLFormElement).elements; for (let i = 0; i < els.length; i++) { const inputData = await formInputData(els[i]); if (inputData && inputData.name) { const fd = form.data; const name = inputData.name; const value = inputData.value; if (fd[name] === undefined) { fd[name] = value; } else { const existing = Array.isArray(fd[name]) ? fd[name] as Array : [fd[name]]; const incoming = Array.isArray(value) ? value : [value]; fd[name] = [...existing, ...incoming]; } if (fd[name] instanceof Array) { fd[name] = (fd[name] as Array) .filter(d => d !== null); if ((els[i] as HTMLInputElement).type === "radio") { fd[name] = (fd[name] as Array).length ? (fd[name] as Array)[0] : null; } } if (inputData.validation) { form.validation[name] = inputData.validation; } if (!inputData.valid) { form.valid = false; } } } if (e instanceof SubmitEvent) { const submitter = e.submitter; if (submitter) { const submitterData = await formInputData(submitter); if (submitterData) { if (submitterData.name) { form.data[submitterData.name] = submitterData.value; if (submitterData.validation) { form.validation[submitterData.name] = submitterData.validation; } if (!submitterData.valid) { form.valid = false; } } else { if (submitterData.value) { form.data["submitter"] = submitterData.value; } } } } } return form; default: return await formInputData(el); } } async function formInputData(el: Element): Promise { // Client-side form validation // https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation const vel = el as HTMLInputElement; if (vel.willValidate) { let valid = true; for (const key in vel.validity) { if (key !== "customError" && key !== "valid") { if ((vel.validity as any)[key]) { const msgs = (vel as any)["validation"]; if (msgs) { const msg = (msgs as any)[key]; if (msg) { vel.setCustomValidity(msg); // vel.reportValidity(); valid = false; break; } else { vel.setCustomValidity(""); // vel.reportValidity(); continue; } } } } } if (valid) { vel.setCustomValidity(""); // vel.reportValidity(); } } let data: HFormInputData | undefined; switch (el.nodeName) { case "INPUT": const iel = el as HTMLInputElement; switch (iel.type) { case "text": case "hidden": case "password": case "email": case "search": case "url": case "tel": case "color": case "submit": case "button": data = { name: iel.name, value: iel.value === "" ? null : iel.value, validation: iel.validationMessage, valid: iel.validity.valid }; break; case "number": case "range": data = { name: iel.name, value: isNaN(iel.valueAsNumber) ? null : iel.valueAsNumber, valueString: iel.value, validation: iel.validationMessage, valid: iel.validity.valid }; break; case "datetime-local": data = { name: iel.name, value: iel.value === "" ? null : iel.value, valueNumber: isNaN(iel.valueAsNumber) ? null : iel.valueAsNumber, valueDate: isNaN(iel.valueAsNumber) ? null : new Date(iel.valueAsNumber), validation: iel.validationMessage, valid: iel.validity.valid }; break; case "date": case "month": case "time": case "week": data = { name: iel.name, value: iel.value === "" ? null : iel.value, valueNumber: isNaN(iel.valueAsNumber) ? null : iel.valueAsNumber, valueDate: iel.valueAsDate, validation: iel.validationMessage, valid: iel.validity.valid }; break; case "radio": data = { name: iel.name, value: iel.checked ? iel.value : null, valueString: iel.value, validation: iel.validationMessage, valid: iel.validity.valid }; break; case "checkbox": if (iel.value === "on") { // value not set in element data = { name: iel.name, value: iel.checked, valueString: iel.value, validation: iel.validationMessage, valid: iel.validity.valid }; } else { data = { name: iel.name, value: iel.checked ? String(iel.value) : null, valueString: iel.value, validation: iel.validationMessage, valid: iel.validity.valid }; } break; case "file": const files = iel.files as FileList; // const valueFile = iel.multiple // ? files[0] // : files.length === 1 ? files[0] : null; let converted: Array = []; let convertedErrors: Array = []; switch (iel.getAttribute("convert")) { case "text": { for (const file of files) { try { const text = await file.text(); converted.push(text); } catch (error) { convertedErrors.push(error); } } break; } case "json": { for (const file of files) { try { const text = await file.text(); const json = JSON.parse(text); converted.push(json); } catch (error) { convertedErrors.push(error); } } break; } case "base64": { for (const file of files) { try { const buffer = await file.arrayBuffer(); // const b64 = Buffer.from(buffer).toString("base64") const b64 = arrayBufferToBase64(buffer); converted.push(b64); } catch (error) { convertedErrors.push(error); } } break; } case "object": { for (const file of files) { try { const buffer = await file.arrayBuffer(); // const fileData = Buffer.from(buffer).toString("base64"); const fileData = arrayBufferToBase64(buffer); converted.push({ name: file.name, type: file.type, size: file.size, data: fileData }); } catch (error) { convertedErrors.push(error); } } break; } case "dataurl": { for (const file of files) { try { const buffer = await file.arrayBuffer(); // const b64 = Buffer.from(buffer).toString("base64") const b64 = arrayBufferToBase64(buffer); const dataUrl = `data:${file.type};base64,${b64}`; converted.push(dataUrl); } catch (error) { convertedErrors.push(error); } } break; } case "arraybuffer": { for (const file of files) { try { const buffer = await file.arrayBuffer(); converted.push(buffer); } catch (error) { convertedErrors.push(error); } } break; } case "stream": { for (const file of files) { try { const stream = file.stream(); converted.push(stream); } catch (error) { convertedErrors.push(error); } } break; } default: converted = Array.from(files); break; } const value = iel.multiple ? converted : converted.length === 1 ? converted[0] : null; const valueFile = iel.multiple ? files : files.length === 1 ? files[0] : null; data = { name: iel.name, value, valueFile, valueString: iel.value, validation: iel.validationMessage, valid: iel.validity.valid }; const maxsize = Number(iel.getAttribute("maxsize")); if (maxsize) { for (const file of files) { if (file.size > maxsize) { const msg = `Max ${unit(maxsize)}, file ${unit(file.size)}`; data.validation = data.validation ? data.validation + "; " + msg : msg; data.valid = false; } } } if (convertedErrors.length) { const msg = convertedErrors.map(err => err instanceof Error ? err.message : String(err)).join("; "); data.validation = data.validation ? data.validation + "; " + msg : msg; data.valid = false; } break; } break; case "SELECT": const sel = el as HTMLSelectElement; if (sel.multiple) { const values = Array.from(sel.selectedOptions).map(o => o.value); data = { name: sel.name, value: values, valueString: sel.value, validation: sel.validationMessage, valid: sel.validity.valid }; } else { data = { name: sel.name, value: sel.value === "" ? null : sel.value, validation: sel.validationMessage, valid: sel.validity.valid }; } break; case "TEXTAREA": const tel = el as HTMLTextAreaElement; data = { name: tel.name, value: tel.value === "" ? null : tel.value, validation: tel.validationMessage, valid: tel.validity.valid }; break; case "BUTTON": const bel = el as HTMLButtonElement; data = { name: bel.name, value: bel.value === "" ? null : bel.value, validation: bel.validationMessage, valid: bel.validity.valid }; break; } return data; } function unit(num: number): string { const mb = 1e6; const kb = 1e3; if (num >= mb) { return `${(num / mb).toFixed(3)}${THSP}MB`; } else if (num >= kb) { return `${(num / kb).toFixed(0)}${THSP}kB`; } else { return `${num.toFixed(0)}${THSP}B`; } }