animejs
Version:
JavaScript animation engine
499 lines (473 loc) • 20 kB
JavaScript
/**
* Anime.js - text - ESM
* @version v4.3.6
* @license MIT
* @copyright 2026 - Julian Garnier
*/
import { isBrowser, doc } from '../core/consts.js';
import { scope } from '../core/globals.js';
import { isArr, isObj, isFnc, isUnd, isStr, isNum } from '../core/helpers.js';
import { getNodeList } from '../core/targets.js';
import { setValue } from '../core/values.js';
import { keepTime } from '../utils/time.js';
/**
* @import {
* Tickable,
* DOMTarget,
* SplitTemplateParams,
* SplitFunctionValue,
* TextSplitterParams,
* } from '../types/index.js'
*/
const segmenter = (typeof Intl !== 'undefined') && Intl.Segmenter;
const valueRgx = /\{value\}/g;
const indexRgx = /\{i\}/g;
const whiteSpaceGroupRgx = /(\s+)/;
const whiteSpaceRgx = /^\s+$/;
const lineType = 'line';
const wordType = 'word';
const charType = 'char';
const dataLine = `data-line`;
/**
* @typedef {Object} Segment
* @property {String} segment
* @property {Boolean} [isWordLike]
*/
/**
* @typedef {Object} Segmenter
* @property {function(String): Iterable<Segment>} segment
*/
/** @type {Segmenter} */
let wordSegmenter = null;
/** @type {Segmenter} */
let graphemeSegmenter = null;
let $splitTemplate = null;
/**
* @param {Segment} seg
* @return {Boolean}
*/
const isSegmentWordLike = seg => {
return seg.isWordLike ||
seg.segment === ' ' || // Consider spaces as words first, then handle them diffrently later
isNum(+seg.segment); // Safari doesn't considers numbers as words
};
/**
* @param {HTMLElement} $el
*/
const setAriaHidden = $el => $el.setAttribute('aria-hidden', 'true');
/**
* @param {DOMTarget} $el
* @param {String} type
* @return {Array<HTMLElement>}
*/
const getAllTopLevelElements = ($el, type) => [.../** @type {*} */($el.querySelectorAll(`[data-${type}]:not([data-${type}] [data-${type}])`))];
const debugColors = { line: '#00D672', word: '#FF4B4B', char: '#5A87FF' };
/**
* @param {HTMLElement} $el
*/
const filterEmptyElements = $el => {
if (!$el.childElementCount && !$el.textContent.trim()) {
const $parent = $el.parentElement;
$el.remove();
if ($parent) filterEmptyElements($parent);
}
};
/**
* @param {HTMLElement} $el
* @param {Number} lineIndex
* @param {Set<HTMLElement|Node>} bin
* @returns {Set<HTMLElement|Node>}
*/
const filterLineElements = ($el, lineIndex, bin) => {
const dataLineAttr = $el.getAttribute(dataLine);
if (dataLineAttr !== null && +dataLineAttr !== lineIndex || $el.tagName === 'BR') {
bin.add($el);
// Also remove adjacent whitespace-only text nodes
const prev = $el.previousSibling;
const next = $el.nextSibling;
if (prev && prev.nodeType === 3 && whiteSpaceRgx.test(prev.textContent)) {
bin.add(prev);
}
if (next && next.nodeType === 3 && whiteSpaceRgx.test(next.textContent)) {
bin.add(next);
}
}
let i = $el.childElementCount;
while (i--) filterLineElements(/** @type {HTMLElement} */($el.children[i]), lineIndex, bin);
return bin;
};
/**
* @param {'line'|'word'|'char'} type
* @param {SplitTemplateParams} params
* @return {String}
*/
const generateTemplate = (type, params = {}) => {
let template = ``;
const classString = isStr(params.class) ? ` class="${params.class}"` : '';
const cloneType = setValue(params.clone, false);
const wrapType = setValue(params.wrap, false);
const overflow = wrapType ? wrapType === true ? 'clip' : wrapType : cloneType ? 'clip' : false;
if (wrapType) template += `<span${overflow ? ` style="overflow:${overflow};"` : ''}>`;
template += `<span${classString}${cloneType ? ` style="position:relative;"` : ''} data-${type}="{i}">`;
if (cloneType) {
const left = cloneType === 'left' ? '-100%' : cloneType === 'right' ? '100%' : '0';
const top = cloneType === 'top' ? '-100%' : cloneType === 'bottom' ? '100%' : '0';
template += `<span>{value}</span>`;
template += `<span inert style="position:absolute;top:${top};left:${left};white-space:nowrap;">{value}</span>`;
} else {
template += `{value}`;
}
template += `</span>`;
if (wrapType) template += `</span>`;
return template;
};
/**
* @param {String|SplitFunctionValue} htmlTemplate
* @param {Array<HTMLElement>} store
* @param {Node|HTMLElement} node
* @param {DocumentFragment} $parentFragment
* @param {'line'|'word'|'char'} type
* @param {Boolean} debug
* @param {Number} lineIndex
* @param {Number} [wordIndex]
* @param {Number} [charIndex]
* @return {HTMLElement}
*/
const processHTMLTemplate = (htmlTemplate, store, node, $parentFragment, type, debug, lineIndex, wordIndex, charIndex) => {
const isLine = type === lineType;
const isChar = type === charType;
const className = `_${type}_`;
const template = isFnc(htmlTemplate) ? htmlTemplate(node) : htmlTemplate;
const displayStyle = isLine ? 'block' : 'inline-block';
$splitTemplate.innerHTML = template
.replace(valueRgx, `<i class="${className}"></i>`)
.replace(indexRgx, `${isChar ? charIndex : isLine ? lineIndex : wordIndex}`);
const $content = $splitTemplate.content;
const $highestParent = /** @type {HTMLElement} */($content.firstElementChild);
const $split = /** @type {HTMLElement} */($content.querySelector(`[data-${type}]`)) || $highestParent;
const $replacables = /** @type {NodeListOf<HTMLElement>} */($content.querySelectorAll(`i.${className}`));
const replacablesLength = $replacables.length;
if (replacablesLength) {
$highestParent.style.display = displayStyle;
$split.style.display = displayStyle;
$split.setAttribute(dataLine, `${lineIndex}`);
if (!isLine) {
$split.setAttribute('data-word', `${wordIndex}`);
if (isChar) $split.setAttribute('data-char', `${charIndex}`);
}
let i = replacablesLength;
while (i--) {
const $replace = $replacables[i];
const $closestParent = $replace.parentElement;
$closestParent.style.display = displayStyle;
if (isLine) {
$closestParent.innerHTML = /** @type {HTMLElement} */(node).innerHTML;
} else {
$closestParent.replaceChild(node.cloneNode(true), $replace);
}
}
store.push($split);
$parentFragment.appendChild($content);
} else {
console.warn(`The expression "{value}" is missing from the provided template.`);
}
if (debug) $highestParent.style.outline = `1px dotted ${debugColors[type]}`;
return $highestParent;
};
/**
* A class that splits text into words and wraps them in span elements while preserving the original HTML structure.
* @class
*/
class TextSplitter {
/**
* @param {HTMLElement|NodeList|String|Array<HTMLElement>} target
* @param {TextSplitterParams} [parameters]
*/
constructor(target, parameters = {}) {
// Only init segmenters when needed
if (!wordSegmenter) wordSegmenter = segmenter ? new segmenter([], { granularity: wordType }) : {
segment: (text) => {
const segments = [];
const words = text.split(whiteSpaceGroupRgx);
for (let i = 0, l = words.length; i < l; i++) {
const segment = words[i];
segments.push({
segment,
isWordLike: !whiteSpaceRgx.test(segment), // Consider non-whitespace as word-like
});
}
return segments;
}
};
if (!graphemeSegmenter) graphemeSegmenter = segmenter ? new segmenter([], { granularity: 'grapheme' }) : {
segment: text => [...text].map(char => ({ segment: char }))
};
if (!$splitTemplate && isBrowser) $splitTemplate = doc.createElement('template');
if (scope.current) scope.current.register(this);
const { words, chars, lines, accessible, includeSpaces, debug } = parameters;
const $target = /** @type {HTMLElement} */((target = isArr(target) ? target[0] : target) && /** @type {Node} */(target).nodeType ? target : (getNodeList(target) || [])[0]);
const lineParams = lines === true ? {} : lines;
const wordParams = words === true || isUnd(words) ? {} : words;
const charParams = chars === true ? {} : chars;
this.debug = setValue(debug, false);
this.includeSpaces = setValue(includeSpaces, false);
this.accessible = setValue(accessible, true);
this.linesOnly = lineParams && (!wordParams && !charParams);
/** @type {String|false|SplitFunctionValue} */
this.lineTemplate = isObj(lineParams) ? generateTemplate(lineType, /** @type {SplitTemplateParams} */(lineParams)) : lineParams;
/** @type {String|false|SplitFunctionValue} */
this.wordTemplate = isObj(wordParams) || this.linesOnly ? generateTemplate(wordType, /** @type {SplitTemplateParams} */(wordParams)) : wordParams;
/** @type {String|false|SplitFunctionValue} */
this.charTemplate = isObj(charParams) ? generateTemplate(charType, /** @type {SplitTemplateParams} */(charParams)) : charParams;
this.$target = $target;
this.html = $target && $target.innerHTML;
this.lines = [];
this.words = [];
this.chars = [];
this.effects = [];
this.effectsCleanups = [];
this.cache = null;
this.ready = false;
this.width = 0;
this.resizeTimeout = null;
const handleSplit = () => this.html && (lineParams || wordParams || charParams) && this.split();
// Make sure this is declared before calling handleSplit() in case revert() is called inside an effect callback
this.resizeObserver = new ResizeObserver(() => {
// Use a setTimeout instead of a Timer for better tree shaking
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
const currentWidth = /** @type {HTMLElement} */($target).offsetWidth;
if (currentWidth === this.width) return;
this.width = currentWidth;
handleSplit();
}, 150);
});
// Only declare the font ready promise when splitting by lines and not alreay split
if (this.lineTemplate && !this.ready) {
doc.fonts.ready.then(handleSplit);
} else {
handleSplit();
}
$target ? this.resizeObserver.observe($target) : console.warn('No Text Splitter target found.');
}
/**
* @param {(...args: any[]) => Tickable | (() => void)} effect
* @return this
*/
addEffect(effect) {
if (!isFnc(effect)) return console.warn('Effect must return a function.');
const refreshableEffect = keepTime(effect);
this.effects.push(refreshableEffect);
if (this.ready) this.effectsCleanups[this.effects.length - 1] = refreshableEffect(this);
return this;
}
revert() {
clearTimeout(this.resizeTimeout);
this.lines.length = this.words.length = this.chars.length = 0;
this.resizeObserver.disconnect();
// Make sure to revert the effects after disconnecting the resizeObserver to avoid triggering it in the process
this.effectsCleanups.forEach(cleanup => isFnc(cleanup) ? cleanup(this) : cleanup.revert && cleanup.revert());
this.$target.innerHTML = this.html;
return this;
}
/**
* Recursively processes a node and its children
* @param {Node} node
*/
splitNode(node) {
const wordTemplate = this.wordTemplate;
const charTemplate = this.charTemplate;
const includeSpaces = this.includeSpaces;
const debug = this.debug;
const nodeType = node.nodeType;
if (nodeType === 3) {
const nodeText = node.nodeValue;
// If the nodeText is only whitespace, leave it as is
if (nodeText.trim()) {
const tempWords = [];
const words = this.words;
const chars = this.chars;
const wordSegments = wordSegmenter.segment(nodeText);
const $wordsFragment = doc.createDocumentFragment();
let prevSeg = null;
for (const wordSegment of wordSegments) {
const segment = wordSegment.segment;
const isWordLike = isSegmentWordLike(wordSegment);
// Determine if this segment should be a new word, first segment always becomes a new word
if (!prevSeg || (isWordLike && (prevSeg && (isSegmentWordLike(prevSeg))))) {
tempWords.push(segment);
} else {
// Only concatenate if both current and previous are non-word-like and don't contain spaces
const lastWordIndex = tempWords.length - 1;
const lastWord = tempWords[lastWordIndex];
if (!whiteSpaceGroupRgx.test(lastWord) && !whiteSpaceGroupRgx.test(segment)) {
tempWords[lastWordIndex] += segment;
} else {
tempWords.push(segment);
}
}
prevSeg = wordSegment;
}
for (let i = 0, l = tempWords.length; i < l; i++) {
const word = tempWords[i];
if (!word.trim()) {
// Preserve whitespace only if includeSpaces is false and if the current space is not the first node
if (i && includeSpaces) continue;
$wordsFragment.appendChild(doc.createTextNode(word));
} else {
const nextWord = tempWords[i + 1];
const hasWordFollowingSpace = includeSpaces && nextWord && !nextWord.trim();
const wordToProcess = word;
const charSegments = charTemplate ? graphemeSegmenter.segment(wordToProcess) : null;
const $charsFragment = charTemplate ? doc.createDocumentFragment() : doc.createTextNode(hasWordFollowingSpace ? word + '\xa0' : word);
if (charTemplate) {
const charSegmentsArray = [...charSegments];
for (let j = 0, jl = charSegmentsArray.length; j < jl; j++) {
const charSegment = charSegmentsArray[j];
const isLastChar = j === jl - 1;
// If this is the last character and includeSpaces is true with a following space, append the space
const charText = isLastChar && hasWordFollowingSpace ? charSegment.segment + '\xa0' : charSegment.segment;
const $charNode = doc.createTextNode(charText);
processHTMLTemplate(charTemplate, chars, $charNode, /** @type {DocumentFragment} */($charsFragment), charType, debug, -1, words.length, chars.length);
}
}
if (wordTemplate) {
processHTMLTemplate(wordTemplate, words, $charsFragment, $wordsFragment, wordType, debug, -1, words.length, chars.length);
// Chars elements must be re-parsed in the split() method if both words and chars are parsed
} else if (charTemplate) {
$wordsFragment.appendChild($charsFragment);
} else {
$wordsFragment.appendChild(doc.createTextNode(word));
}
// Skip the next iteration if we included a space
if (hasWordFollowingSpace) i++;
}
}
node.parentNode.replaceChild($wordsFragment, node);
}
} else if (nodeType === 1) {
// Converting to an array is necessary to work around childNodes pottential mutation
const childNodes = /** @type {Array<Node>} */([.../** @type {*} */(node.childNodes)]);
for (let i = 0, l = childNodes.length; i < l; i++) this.splitNode(childNodes[i]);
}
}
/**
* @param {Boolean} clearCache
* @return {this}
*/
split(clearCache = false) {
const $el = this.$target;
const isCached = !!this.cache && !clearCache;
const lineTemplate = this.lineTemplate;
const wordTemplate = this.wordTemplate;
const charTemplate = this.charTemplate;
const fontsReady = doc.fonts.status !== 'loading';
const canSplitLines = lineTemplate && fontsReady;
this.ready = !lineTemplate || fontsReady;
if (canSplitLines || clearCache) {
// No need to revert effects animations here since it's already taken care by the refreshable
this.effectsCleanups.forEach(cleanup => isFnc(cleanup) && cleanup(this));
}
if (!isCached) {
if (clearCache) {
$el.innerHTML = this.html;
this.words.length = this.chars.length = 0;
}
this.splitNode($el);
this.cache = $el.innerHTML;
}
if (canSplitLines) {
if (isCached) $el.innerHTML = this.cache;
this.lines.length = 0;
if (wordTemplate) this.words = getAllTopLevelElements($el, wordType);
}
// Always reparse characters after a line reset or if both words and chars are activated
if (charTemplate && (canSplitLines || wordTemplate)) {
this.chars = getAllTopLevelElements($el, charType);
}
// Words are used when lines only and prioritized over chars
const elementsArray = this.words.length ? this.words : this.chars;
let y, linesCount = 0;
for (let i = 0, l = elementsArray.length; i < l; i++) {
const $el = elementsArray[i];
const { top, height } = $el.getBoundingClientRect();
if (!isUnd(y) && top - y > height * .5) linesCount++;
$el.setAttribute(dataLine, `${linesCount}`);
const nested = $el.querySelectorAll(`[${dataLine}]`);
let c = nested.length;
while (c--) nested[c].setAttribute(dataLine, `${linesCount}`);
y = top;
}
if (canSplitLines) {
const linesFragment = doc.createDocumentFragment();
const parents = new Set();
const clones = [];
for (let lineIndex = 0; lineIndex < linesCount + 1; lineIndex++) {
const $clone = /** @type {HTMLElement} */($el.cloneNode(true));
filterLineElements($clone, lineIndex, new Set()).forEach($el => {
const $parent = $el.parentNode;
if ($parent) {
if ($el.nodeType === 1) parents.add(/** @type {HTMLElement} */($parent));
$parent.removeChild($el);
}
});
clones.push($clone);
}
parents.forEach(filterEmptyElements);
for (let cloneIndex = 0, clonesLength = clones.length; cloneIndex < clonesLength; cloneIndex++) {
processHTMLTemplate(lineTemplate, this.lines, clones[cloneIndex], linesFragment, lineType, this.debug, cloneIndex);
}
$el.innerHTML = '';
$el.appendChild(linesFragment);
if (wordTemplate) this.words = getAllTopLevelElements($el, wordType);
if (charTemplate) this.chars = getAllTopLevelElements($el, charType);
}
// Remove the word wrappers and clear the words array if lines split only
if (this.linesOnly) {
const words = this.words;
let w = words.length;
while (w--) {
const $word = words[w];
$word.replaceWith($word.textContent);
}
words.length = 0;
}
if (this.accessible && (canSplitLines || !isCached)) {
const $accessible = doc.createElement('span');
// Make the accessible element visually-hidden (https://www.scottohara.me/blog/2017/04/14/inclusively-hidden.html)
$accessible.style.cssText = `position:absolute;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);width:1px;height:1px;white-space:nowrap;`;
// $accessible.setAttribute('tabindex', '-1');
$accessible.innerHTML = this.html;
$el.insertBefore($accessible, $el.firstChild);
this.lines.forEach(setAriaHidden);
this.words.forEach(setAriaHidden);
this.chars.forEach(setAriaHidden);
}
this.width = /** @type {HTMLElement} */($el).offsetWidth;
if (canSplitLines || clearCache) {
this.effects.forEach((effect, i) => this.effectsCleanups[i] = effect(this));
}
return this;
}
refresh() {
this.split(true);
}
}
/**
* @param {HTMLElement|NodeList|String|Array<HTMLElement>} target
* @param {TextSplitterParams} [parameters]
* @return {TextSplitter}
*/
const splitText = (target, parameters) => new TextSplitter(target, parameters);
/**
* @deprecated text.split() is deprecated, import splitText() directly, or text.splitText()
*
* @param {HTMLElement|NodeList|String|Array<HTMLElement>} target
* @param {TextSplitterParams} [parameters]
* @return {TextSplitter}
*/
const split = (target, parameters) => {
console.warn('text.split() is deprecated, import splitText() directly, or text.splitText()');
return new TextSplitter(target, parameters);
};
export { TextSplitter, split, splitText };