285 lines
7.5 KiB
TypeScript
285 lines
7.5 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
this.set_color(Paragraph.SUBTLE_COLOR);
|
|
return sleep(500);
|
|
}
|
|
|
|
public async show(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|