import { el, sleep, wait_for_dom_refresh } from "./tools"; class Word { private dom_root: HTMLParagraphElement; private inner: string; private is_visible: boolean; private static ANIMATION_DURATION: number = 20; public constructor(inner: string, initially_visible: boolean = false) { this.inner = inner; this.is_visible = initially_visible; this.dom_root = el.p(this.inner, ["word"]); if (!this.is_visible) { this.dom_root.style.opacity = "0"; this.dom_root.style.transform = "scale(0)"; } } public set text(new_value: string) { this.inner = new_value; this.dom_root.innerText = this.inner; } public get text(): string { return this.inner; } public async show(): Promise { if (this.is_visible) { return; } this.dom_root.style.opacity = "1"; this.dom_root.style.transform = "scale(100%)"; this.is_visible = true; await sleep(Word.ANIMATION_DURATION); } public get visible(): boolean { return this.is_visible; } public get dom(): HTMLDivElement { return this.dom_root; } } class Paragraph { private dom_root: HTMLDivElement; private words: Word[]; private height: number; private height_current: boolean; private visible: boolean; private vertical_position: number; private static SUBTLE_COLOR: string = "gray"; private static ACTIVE_COLOR: string = "white"; private static WORD_ANIM_POST_WAIT: number = 180; public constructor(initial_text: string | null) { this.dom_root = el.div([], ["paragraph"]); this.set_color(Paragraph.ACTIVE_COLOR); this.words = []; this.visible = true; this.height = 0; this.height_current = true; this.vertical_position = 0; this.set_initial_text(initial_text); } private set_initial_text(text: string | null): void { if (text === null) { return; } this.visible = false; this.height_current = false; this.dom_root.style.opacity = "0"; const initial_words = text.split(" "); for (const word of initial_words) { const cur_word = new Word(word, true); this.words.push(cur_word); this.dom_root.appendChild(cur_word.dom); } } private set_color(new_color: string): void { this.dom_root.style.color = new_color; } private trim_words(target_length: number): void { if (this.words.length == 0 || target_length < 0) { return; } if (this.words.length != target_length) { this.invalidate_height(); } for (let i = this.words.length - 1; i >= target_length; --i) { const current_pop = this.words.pop(); if (current_pop === undefined) { continue; } this.dom_root.removeChild(current_pop.dom); } } private invalidate_height(): void { this.height_current = false; } public set_text(new_text: string): void { const new_words = new_text.split(" "); if (new_words.length < this.words.length) { this.trim_words(new_words.length); } for (let i = 0; i < new_words.length; ++i) { const new_word = new_words[i]; if (this.words.length <= i) { const cur_word = new Word(new_word); this.words.push(cur_word); this.dom_root.appendChild(cur_word.dom); this.invalidate_height(); continue; } if (this.words[i].text != new_word) { this.words[i].text = new_word; this.invalidate_height(); } } } public async animate(): Promise { for (const word of this.words) { await word.show(); } await sleep(Paragraph.WORD_ANIM_POST_WAIT); } private calculate_height(): void { this.height = this.dom_root.getBoundingClientRect().height; } public get real_height(): number { if (!this.height_current) { this.calculate_height(); this.height_current = true; } return this.height; } public subtleize(): Promise { this.set_color(Paragraph.SUBTLE_COLOR); return sleep(500); } public async show(): Promise { if (this.visible) { return; } this.dom_root.style.opacity = "1"; return sleep(500); } public set translate_y(value: number) { this.vertical_position = value; this.dom_root.style.transform = `translateY(${value}px)`; } public get translate_y(): number { return this.vertical_position; } public get dom(): HTMLDivElement { return this.dom_root; } } export class ScrollingTextBox { private dom_root: HTMLDivElement; private paragraphs: Paragraph[]; private static MAX_HEIGHT: number = 650; private static CENTER_HEIGHT: number = 350; private static VERTICAL_MARGIN: number = 10; public constructor() { this.dom_root = el.div([], ["scrolling-textbox"]); this.paragraphs = []; } private add_paragraph(initial_text: string | null): void { const new_paragraph = new Paragraph(initial_text); new_paragraph.translate_y = ScrollingTextBox.CENTER_HEIGHT; this.paragraphs.push(new_paragraph); this.dom_root.appendChild(new_paragraph.dom); } private add_paragraph_bottom(initial_text: string): void { if (this.paragraphs.length == 0) { this.add_paragraph(initial_text); return; } const most_bottom = this.paragraphs[this.paragraphs.length - 1]; const offset = most_bottom.translate_y + most_bottom.real_height; const new_paragraph = new Paragraph(initial_text); new_paragraph.translate_y = offset; this.paragraphs.push(new_paragraph); this.dom_root.appendChild(new_paragraph.dom); } /** * Remove all paragraphs up to (not including) a certain index. */ private trim_to(exclusive_end: number): void { const spliced = this.paragraphs.splice(0, exclusive_end); for (const item of spliced) { this.dom_root.removeChild(item.dom); } } /** * Run scroll animation on the first element and then pop it off of the DOM. */ private async scroll_trim(offset: number): Promise { const to_trim = this.paragraphs.shift(); if (to_trim === undefined) { return; } to_trim.translate_y = offset; await sleep(500); this.dom_root.removeChild(to_trim.dom); } /** * Update elements DOM scrolls and wait for animations to complete. * * Note that this will remove any paragraphs that aren't visible anymore. */ private async update_scroll(): Promise { if (this.paragraphs.length == 0) { return; } const last = this.paragraphs[this.paragraphs.length - 1]; let offset = Math.min( ScrollingTextBox.MAX_HEIGHT - last.real_height, ScrollingTextBox.CENTER_HEIGHT ); last.translate_y = offset; if (this.paragraphs.length == 1) { return; } for (let i = this.paragraphs.length - 2; i >= 0; --i) { offset -= ScrollingTextBox.VERTICAL_MARGIN; const cur = this.paragraphs[i]; if (offset <= 0) { this.trim_to(i); this.scroll_trim(offset - cur.real_height); break; } offset -= cur.real_height; cur.translate_y = offset; } await sleep(500); } public async update_current(text: string): Promise { if (this.paragraphs.length == 0) { this.add_paragraph(null); this.update_scroll(); } const current = this.paragraphs[this.paragraphs.length - 1]; current.set_text(text); await wait_for_dom_refresh(); await Promise.all([this.update_scroll(), current.animate()]); } public async finish_current(text: string): Promise { if (this.paragraphs.length == 0) { this.add_paragraph(text); } const current = this.paragraphs[this.paragraphs.length - 1]; current.set_text(text); this.add_paragraph(null); await Promise.all([this.update_scroll(), current.subtleize(), current.animate()]); } public async add_line(text: string): Promise { if (this.paragraphs.length != 0) { this.paragraphs[this.paragraphs.length - 1].subtleize(); } this.add_paragraph_bottom(text); await wait_for_dom_refresh(); await Promise.all([ this.update_scroll(), this.paragraphs[this.paragraphs.length - 1].show(), ]); } public get dom(): HTMLDivElement { return this.dom_root; } }