Skip to content

Instantly share code, notes, and snippets.

@jerome-toole
Last active September 30, 2024 11:04
Show Gist options
  • Save jerome-toole/e6741665e0fd8d386f492c93c6b5f1ef to your computer and use it in GitHub Desktop.
Save jerome-toole/e6741665e0fd8d386f492c93c6b5f1ef to your computer and use it in GitHub Desktop.
Lightweight Split Text Implementation - ~775B minified and gzipped
/**
* SplitText.js - A lightweight class to split text into words and lines
* @description A lightweight class to split text into words and lines. Other implementations were
* either too heavy or didn't support line splitting or retaining inline tags (e.g. <strong>).
* Other implementations referenced include:
* - `split-type`: too large (>4kb minzipped)
* - `@djkramnik/split-text`
* - `split-text-js`
* - `spltjs`
*
* Libraries not tested:
* - `splitting`
*
* @param {Array<HTMLElement>|HTMLElement} elements - An array of HTMLElements or a single HTMLElement
* @param {Object} options - An object containing options for the class
*
* @example
* const splitText = new SplitText(document.querySelectorAll('.split-text'), {
* letterClass: 'letter',
* wordClass: 'word',
* lineClass: 'line',
* });
*
* splitText.revert(); // Revert the elements to the initial HTML
*/
export class SplitText {
constructor(elements, options) {
// Ensure elements is an array, even if a single element is passed
if (NodeList.prototype.isPrototypeOf(elements)) {
this.els = Array.from(elements);
} else {
this.els = Array.isArray(elements) ? elements : [elements];
}
// Store original HTML for all elements
this.originalHTML = this.els.map((el) => el.innerHTML);
this.lines = [];
this.options = Object.assign(
{
letterClass: 'letter',
wordClass: 'word',
lineClass: 'line',
},
options
);
this.init();
}
init() {
this.els.forEach((el) => {
this.split(el);
});
}
// Reset the elements to their original states
revert() {
this.els.forEach((el, index) => {
el.innerHTML = this.originalHTML[index];
});
}
tokenizeEl(element) {
const fragment = document.createDocumentFragment();
const childNodes = Array.from(element.childNodes);
childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
const words = node.nodeValue.split(' ');
words.forEach((word) => {
const span = document.createElement('span');
span.classList.add(this.options.wordClass);
span.innerText = word + ' ';
fragment.appendChild(span);
});
} else if (node.nodeType === Node.ELEMENT_NODE) {
// Clone the element to retain its structure
const clonedElement = node.cloneNode(true);
const span = document.createElement('span');
span.classList.add(this.options.wordClass);
span.appendChild(clonedElement);
fragment.appendChild(span);
}
});
return fragment;
}
// Split words into individual characters if needed
tokenizeWordForLineBreaks(wordElement) {
const wordText = wordElement.innerText || wordElement.textContent;
const fragment = document.createDocumentFragment();
// Create a span for each character in the word
for (let i = 0; i < wordText.length; i++) {
const charSpan = document.createElement('span');
charSpan.classList.add(this.options.letterClass);
charSpan.innerText = wordText[i];
fragment.appendChild(charSpan);
}
return fragment;
}
getLines(tokenizedEl) {
const linesMap = {};
let currentLineOffset = null;
Array.from(tokenizedEl.children).forEach((token) => {
if (token instanceof HTMLElement) {
// Handle words that may be broken across lines
const wordFragment = this.tokenizeWordForLineBreaks(token);
token.innerHTML = ''; // Clear original word content
token.appendChild(wordFragment); // Append characters wrapped in spans
Array.from(token.children).forEach((char) => {
const offsetTop = char.offsetTop;
if (currentLineOffset === null || offsetTop !== currentLineOffset) {
currentLineOffset = offsetTop;
if (!linesMap[currentLineOffset]) {
linesMap[currentLineOffset] = [];
}
}
linesMap[currentLineOffset].push(char.outerHTML);
});
}
});
return linesMap;
}
getLineElements(linesMap) {
const fragment = document.createDocumentFragment();
Object.keys(linesMap)
.sort((a, b) => parseInt(a, 10) - parseInt(b, 10))
.forEach((key) => {
const div = document.createElement('div');
div.style.display = 'block';
div.style.textAlign = 'start';
div.style.position = 'relative';
div.classList.add(this.options.lineClass);
div.innerHTML = linesMap[key].join('');
fragment.appendChild(div);
this.lines.push(div);
});
return fragment;
}
split(el) {
if (!this.originalHTML) {
return;
}
// Tokenize words
const tokens = this.tokenizeEl(el);
el.innerHTML = ''; // Clear the current content
el.appendChild(tokens);
// Recalculate line breaks based on actual DOM layout
const lines = this.getLineElements(this.getLines(el));
el.innerHTML = ''; // Clear current content
el.appendChild(lines); // Append new lines
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment