import styles from './calc-prompt.styles.scss'; import i18n from '../../i18n'; import type WebComponentElement from '../WebComponentElement'; import constructorFactory from '../constructorFactory'; import createElementFactory from '../createElementFactory'; import defineFactory from '../defineFactory'; import keyToPostfix from '../keyToPostfix'; import setContainerFactory from '../setContainerFactory'; import setIdFirstFactory from '../setIdFirstFactory'; /** * Elements addressed inside a single calculator prompt shadow tree. */ export interface CalcPromptElementEntry { root: HTMLElement; input: HTMLTextAreaElement; output: HTMLOutputElement; } export type CalcPromptElement = WebComponentElement; export const CalcPromptElementEntryKey: (keyof CalcPromptElementEntry)[] = ['root', 'input', 'output'] as const; type CalcInputMode = 'app' | 'native'; type CalcInputModeEvent = CustomEvent<{ mode: CalcInputMode; }>; const nativeKeyboardSuppressionMedia = '(pointer: coarse) and (max-width: 680px), (pointer: coarse) and (max-height: 520px)'; /** * Editable prompt row with input handling and MathML output rendering. */ export class CalcPrompt extends HTMLElement { public static readonly tagName = 'calc-prompt'; public readonly element = {} as CalcPromptElement; public static readonly elementFields: (keyof CalcPromptElementEntry)[] = CalcPromptElementEntryKey; public static readonly elementPostfix = keyToPostfix(CalcPromptElementEntryKey); public static readonly null = null as unknown as CalcPrompt; public static readonly undefined = undefined as unknown as CalcPrompt; private readonly nativeKeyboardSuppression = globalThis.matchMedia(nativeKeyboardSuppressionMedia); private keyboardMode: CalcInputMode = 'app'; public constructor() { super(); constructorFactory(CalcPrompt, styles).bind(this)(); } public set superId(id: string) { super.id = id; } public get superId(): string { return super.id; } public set id(id: string) { this.setId(id); } public get id(): string { return super.id; } public setId: (this: CalcPrompt, id?: string) => void = setIdFirstFactory(CalcPrompt).bind(this); public static readonly createElement = createElementFactory(CalcPrompt); public static readonly define = defineFactory(CalcPrompt); public set container(element: HTMLElement) { setContainerFactory().bind(this)(element); } public get container(): HTMLElement { return this.element.container; } public connectedCallback(): void { i18n.addEventListener('languagechange', this.setLanguage); this.element.input.addEventListener('input', this.resize); this.element.input.addEventListener('keydown', this.keydown); this.nativeKeyboardSuppression.addEventListener('change', this.layoutChange); globalThis.addEventListener('calc-input-mode-change', this.inputModeChange as EventListener); this.setInputMode(); this.setLanguage(); this.resize(); } public disconnectedCallback(): void { i18n.removeEventListener('languagechange', this.setLanguage); this.element.input.removeEventListener('input', this.resize); this.element.input.removeEventListener('keydown', this.keydown); this.nativeKeyboardSuppression.removeEventListener('change', this.layoutChange); globalThis.removeEventListener('calc-input-mode-change', this.inputModeChange as EventListener); } public get value(): string { return this.element.input.value; } /** * Replace the prompt input text and resize the textarea. */ public set value(value: string) { this.element.input.value = value; this.resize(); } public focusInput(): void { this.element.input.focus(); } /** * Insert text at the current cursor or selection. */ public insertText(text: string): void { const input = this.element.input; const start = input.selectionStart; const end = input.selectionEnd; input.setRangeText(text, start, end, 'end'); input.dispatchEvent(new Event('input', { bubbles: true })); this.focusInput(); } /** * Render trusted MathJSLab output generated by the interpreter. */ public setOutput(html: string): void { this.element.output.innerHTML = html; } /** * Clear the rendered output area. */ public clearOutput(): void { this.element.output.replaceChildren(); } private readonly resize = (): void => { this.element.input.style.height = '1px'; this.element.input.style.height = `${this.element.input.scrollHeight}px`; }; private readonly keydown = (event: KeyboardEvent): void => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); this.dispatchEvent(new CustomEvent('calc-prompt-evaluate', { bubbles: true, composed: true, detail: { prompt: this } })); } }; private readonly setLanguage = (): void => { this.element.input.setAttribute('aria-label', i18n.page.prompt.ariaLabel); }; /** * Suppress the native virtual keyboard only while touch devices use the app keypad. */ private setInputMode(): void { if (this.nativeKeyboardSuppression.matches && this.keyboardMode === 'app') { this.element.input.inputMode = 'none'; this.element.input.readOnly = true; this.element.input.setAttribute('virtualkeyboardpolicy', 'manual'); } else { this.element.input.inputMode = ''; this.element.input.readOnly = false; this.element.input.removeAttribute('virtualkeyboardpolicy'); } } private readonly layoutChange = (): void => { this.setInputMode(); }; private readonly inputModeChange = (event: CalcInputModeEvent): void => { this.keyboardMode = event.detail.mode; this.setInputMode(); }; } CalcPrompt.define();