Last active
September 30, 2024 11:04
-
-
Save jerome-toole/e6741665e0fd8d386f492c93c6b5f1ef to your computer and use it in GitHub Desktop.
Lightweight Split Text Implementation - ~775B minified and gzipped
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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