@auroratide/typewritten-text
Version:
The text types itself out!
325 lines (322 loc) • 11.6 kB
JavaScript
import { TYPING, TYPE, TYPED, ERASING, ERASE, ERASED, PAUSED_ANY, RESUMED_ANY, NEXT_CHAR, PREV_CHAR, PHRASE_TYPED, PHRASE_REMOVED, STARTED, PAUSED, } from "./events.js";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class TypewrittenTextElement extends HTMLElement {
static defaultElementName = "typewritten-text";
static html = `
<span class="visually-hidden no-copy"><slot></slot></span>
<span aria-hidden="true"><slot name="mirror"></slot></span>
`;
static css = `
:host { display: inline; }
.visually-hidden {
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
width: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
}
.no-copy { user-select: none; }
`;
static get observedAttributes() {
return ["paused"];
}
#mirror;
#position = 0;
#direction = "typing";
#chars = [];
#cursors = [];
constructor() {
super();
this.#createRoot();
}
get paused() { return this.hasAttribute("paused"); }
set paused(value) { this.toggleAttribute("paused", value); }
get typeSpeed() {
if (!this.hasAttribute("type-speed") && this.hasAttribute("letter-interval")) {
return this.letterInterval;
}
return this.#getNumAttribute("type-speed", 80);
}
set typeSpeed(value) { this.setAttribute("type-speed", value.toString()); }
get eraseSpeed() {
if (!this.hasAttribute("erase-speed") && this.hasAttribute("letter-interval")) {
return this.letterInterval;
}
return this.#getNumAttribute("erase-speed", 50);
}
set eraseSpeed(value) { this.setAttribute("erase-speed", value.toString()); }
get repeat() { return this.hasAttribute("repeat"); }
set repeat(value) { this.toggleAttribute("repeat", value); }
get repeatInterval() {
if (!this.hasAttribute("repeat-interval") && this.hasAttribute("phrase-interval")) {
return this.phraseInterval;
}
return this.#getNumAttribute("repeat-interval", 1000);
}
set repeatInterval(value) { this.setAttribute("repeat-interval", value.toString()); }
get position() { return this.#position; }
get length() { return this.#chars.length; }
async type() { await this.#run("typing"); }
async typeOne() {
if (this.#position >= this.length)
return;
this.#chars[this.#position].hidden = false;
this.#chars[this.#position].classList.add("typewritten-text_revealed");
this.#emit(TYPE);
this.#emit(NEXT_CHAR);
this.#updateCursorPosition("typing");
this.#position += 1;
if (this.#isAtEnd("typing")) {
this.#emit(TYPED);
this.#emit(PHRASE_TYPED);
}
}
async erase() { await this.#run("erasing"); }
async eraseOne() {
if (this.#position <= 0)
return;
this.#position -= 1;
this.#chars[this.#position].hidden = true;
this.#chars[this.#position].classList.remove("typewritten-text_revealed");
this.#emit(ERASE);
this.#emit(PREV_CHAR);
this.#updateCursorPosition("erasing");
if (this.#isAtEnd("erasing")) {
this.#emit(ERASED);
this.#emit(PHRASE_REMOVED);
}
}
switchDirection() {
this.#direction = this.#direction === "typing" ? "erasing" : "typing";
}
pause() { this.paused = true; }
resume() {
if (this.#direction === "erasing")
this.erase();
else
this.type();
}
reset() {
this.#position = 0;
this.#running = false;
this.#direction = "typing";
this.#mirrorSlot.assignedNodes().forEach((node) => node.remove());
this.#createMirror();
if (!this.paused) {
this.resume();
}
}
connectedCallback() {
this.#mainSlot.addEventListener("slotchange", this.#onMainSlotChange);
this.#mirrorSlot.addEventListener("slotchange", this.#onMirrorSlotChange);
}
disconnectedCallback() {
this.#mainSlot.removeEventListener("slotchange", this.#onMainSlotChange);
this.#mainSlot.removeEventListener("slotchange", this.#onMirrorSlotChange);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "paused" && newValue == null) {
this.#emit(RESUMED_ANY);
this.#emit(STARTED);
this.resume();
}
if (name === "paused" && newValue != null) {
this.#emit(PAUSED_ANY);
this.#emit(PAUSED);
}
}
get #mainSlot() { return this.shadowRoot?.querySelector("slot"); }
get #mirrorSlot() { return this.shadowRoot?.querySelector("slot[name=\"mirror\"]"); }
#onMainSlotChange = () => this.reset();
#onMirrorSlotChange = () => {
if (this.#mirrorSlot.assignedNodes().length === 0) {
this.reset();
}
};
#createMirror() {
this.#mirror = document.createElement("span");
this.#mirror.slot = "mirror";
this.#mirror.innerHTML = "<span class=\"cursor current typewritten-text_start typewritten-text_current\"></span>" + this.#splitNode();
this.appendChild(this.#mirror);
this.#chars = this.#mirror?.querySelectorAll(".char") ?? [];
this.#cursors = this.#mirror?.querySelectorAll(".cursor") ?? [];
}
#splitNode(node = this) {
return [...node.childNodes].map((n) => {
if (n.nodeType === Node.TEXT_NODE) {
return this.#splitTextIntoWords(n.textContent);
}
else {
const nn = n.cloneNode(false);
nn.innerHTML = this.#splitNode(n);
return nn.outerHTML;
}
}).join("");
}
#splitTextIntoWords(text) {
const delimeters = /([\s-])/g;
return text
.split(delimeters)
.filter((word) => word.length > 0)
.map((word) => `<span class="${delimeters.test(word) ? "" : "word typewritten-text_word"}">${this.#splitTextIntoChars(word)}</span>`)
.join("");
}
#splitTextIntoChars(text) {
return [...text]
.map((char) => `<span class="char cursor typewritten-text_character" hidden>${char}</span>`)
.join("");
}
#emit(type) {
this.dispatchEvent(new CustomEvent(type, {
detail: {
position: this.#position,
length: this.length,
},
}));
}
#getNumAttribute = (name, def) => {
const n = parseFloat(this.getAttribute(name));
return isNaN(n) ? def : n;
};
#running = false;
async #run(direction) {
if (this.#running)
return;
this.#running = true;
this.paused = false;
this.#direction = direction;
this.#emit(this.#direction === "typing" ? TYPING : ERASING);
while (!this.paused && (!this.#isAtEnd() || this.repeat)) {
await (this.#direction === "typing" ? this.typeOne() : this.eraseOne());
if (!this.#isAtEnd()) {
await wait(this.#direction === "typing" ? this.typeSpeed : this.eraseSpeed);
}
if (this.#isAtEnd() && this.repeat && !this.paused) {
await wait(this.repeatInterval);
this.switchDirection();
}
}
if (this.#isAtEnd())
this.switchDirection();
this.paused = true;
this.#running = false;
}
#updateCursorPosition(direction) {
const forCurrent = direction === "typing" ? "add" : "remove";
const forPrevious = direction === "typing" ? "remove" : "add";
this.#cursors[this.#position + 1]?.classList[forCurrent]("current");
this.#cursors[this.#position]?.classList[forPrevious]("current");
this.#cursors[this.#position + 1]?.classList[forCurrent]("typewritten-text_current");
this.#cursors[this.#position]?.classList[forPrevious]("typewritten-text_current");
}
#isAtEnd = (direction) => this.#position === ((direction ?? this.#direction) === "typing" ? this.length : 0);
#createRoot = () => {
const root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.innerHTML = TypewrittenTextElement.css;
const template = document.createElement("template");
template.innerHTML = TypewrittenTextElement.html;
root.appendChild(style);
root.appendChild(template.content);
return root;
};
/* DEPRECATED STUFF from before v0.2.0 */
/**
* @deprecated Use `type-speed` or `erase-speed` instead
*/
get letterInterval() {
return this.#deprecated("letterInterval", ["type-speed", "erase-speed"], () => this.#getNumAttribute("letter-interval", 100));
}
/**
* @deprecated Use `type-speed` or `erase-speed` instead
*/
set letterInterval(value) {
this.#deprecated("letterInterval", ["type-speed", "erase-speed"], () => this.setAttribute("letter-interval", value.toString()));
}
/**
* @deprecated Use `repeat-interval` instead
*/
get phraseInterval() {
return this.#deprecated("phraseInterval", ["repeat-interval"], () => this.#getNumAttribute("phrase-interval", 1000));
}
/**
* @deprecated Use `repeat-interval` instead
*/
set phraseInterval(value) {
this.#deprecated("phraseInterval", ["repeat-interval"], () => this.setAttribute("phrase-interval", value.toString()));
}
/**
* @deprecated Use `position` instead
*/
get currentPosition() {
return this.#deprecated("currentPosition", ["position"], () => this.#position);
}
/**
* @deprecated Use `typeOne` instead
*/
typeNext() {
this.#deprecated("typeNext", ["typeOne"], () => this.typeOne());
}
/**
* @deprecated Use `eraseOne` instead
*/
backspace() {
this.#deprecated("backspace", ["eraseOne"], () => this.eraseOne());
}
/**
* @deprecated Use `resume` instead
*/
start() {
this.#deprecated("start", ["resume"], () => this.resume());
}
/**
* @deprecated Use `typeOne` or `eraseOne` instead
*/
tick() {
this.#deprecated("tick", ["typeOne", "eraseOne"], () => {
if (this.paused)
return;
if (this.#direction === "typing") {
this.typeOne();
}
else {
this.eraseOne();
}
if (this.#isAtEnd())
this.switchDirection();
});
}
/**
* @deprecated Use `typeOne` or `eraseOne` instead
*/
forceTick() {
this.#deprecated("tick", ["typeOne", "eraseOne"], () => {
if (this.#direction === "typing") {
this.typeOne();
}
else {
this.eraseOne();
}
if (this.#isAtEnd())
this.switchDirection();
});
}
/**
* @deprecated Use `switchDirection`
*/
reverse() {
this.#deprecated("reverse", ["switchDirection"], () => this.switchDirection());
}
#deprecated = (old, neww, fn) => {
// eslint-disable-next-line no-console
console.warn(`typewritten-text: \`${old}\` is deprecated. Use \`${neww.join("`, `")}\` instead.`);
return fn();
};
}
/**
* @deprecated Use `TypewrittenTextElement` instead
*/
export const TypewrittenText = TypewrittenTextElement;