type SplitTextOptions = {
type: "lines";
};
type SplitTextEntry = {
element: HTMLElement;
html: string;
};
type SplitTextTarget = string | Element | Element[];
/**
* Lightweight line splitter for scroll text effects.
* Groups words by offsetTop and respects explicit <br /> breaks.
*/
export class SplitText {
lines: HTMLElement[] = [];
private entries: SplitTextEntry[] = [];
constructor(target: SplitTextTarget, options: SplitTextOptions) {
if (options.type !== "lines") return;
const elements = this.resolveElements(target);
elements.forEach((element) => this.splitIntoLines(element));
}
revert(): void {
this.entries.forEach(({ element, html }) => {
element.innerHTML = html;
});
this.lines = [];
this.entries = [];
}
private resolveElements(target: SplitTextTarget): HTMLElement[] {
if (typeof target === "string") {
return Array.from(document.querySelectorAll<HTMLElement>(target));
}
if (Array.isArray(target)) {
return target.filter((el): el is HTMLElement => el instanceof HTMLElement);
}
return target instanceof HTMLElement ? [target] : [];
}
private splitIntoLines(element: HTMLElement): void {
this.entries.push({ element, html: element.innerHTML });
const rowTexts = this.extractRows(element);
element.replaceChildren();
rowTexts.forEach((rowText) => {
const trimmed = rowText.trim();
if (!trimmed) return;
const wrappedLines = this.measureWrappedLines(element, trimmed);
wrappedLines.forEach((lineText) => {
const lineEl = document.createElement("div");
lineEl.textContent = lineText;
element.appendChild(lineEl);
this.lines.push(lineEl);
});
});
}
/** Split copy at <br /> while preserving nested markup text. */
private extractRows(element: HTMLElement): string[] {
const rows: string[] = [];
let buffer = "";
const flush = (): void => {
rows.push(buffer);
buffer = "";
};
const walk = (node: Node): void => {
if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? "";
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as HTMLElement;
if (el.tagName === "BR") {
flush();
return;
}
el.childNodes.forEach(walk);
};
element.childNodes.forEach(walk);
flush();
return rows.length ? rows : [element.textContent ?? ""];
}
private measureWrappedLines(element: HTMLElement, text: string): string[] {
const width = element.offsetWidth;
if (width <= 0) {
return [text];
}
const styles = window.getComputedStyle(element);
const measureHost = document.createElement("div");
measureHost.setAttribute("aria-hidden", "true");
measureHost.style.cssText = [
"position:absolute",
"visibility:hidden",
"pointer-events:none",
"top:0",
"left:0",
`width:${width}px`,
`font:${styles.font}`,
`letter-spacing:${styles.letterSpacing}`,
`word-spacing:${styles.wordSpacing}`,
`text-transform:${styles.textTransform}`,
].join(";");
element.appendChild(measureHost);
const words = text.split(/\s+/).filter(Boolean);
const wordSpans: HTMLSpanElement[] = [];
words.forEach((word, index) => {
const span = document.createElement("span");
span.style.display = "inline-block";
span.textContent = word + (index < words.length - 1 ? " " : "");
measureHost.appendChild(span);
wordSpans.push(span);
});
const lineGroups: HTMLSpanElement[][] = [];
let currentLine: HTMLSpanElement[] = [];
let currentTop = -1;
wordSpans.forEach((span) => {
const top = span.offsetTop;
if (currentTop === -1) {
currentTop = top;
}
if (top !== currentTop) {
lineGroups.push(currentLine);
currentLine = [];
currentTop = top;
}
currentLine.push(span);
});
if (currentLine.length) {
lineGroups.push(currentLine);
}
measureHost.remove();
const lines = lineGroups.map((group) =>
group.map((span) => span.textContent).join("")
);
return lines.length ? lines : [text];
}
}
type SplitTextOptions = {
type: "lines";
};
type SplitTextEntry = {
element: HTMLElement;
html: string;
};
type SplitTextTarget = string | Element | Element[];
/**
* Lightweight line splitter for scroll text effects.
* Groups words by offsetTop and respects explicit <br /> breaks.
*/
export class SplitText {
lines: HTMLElement[] = [];
private entries: SplitTextEntry[] = [];
constructor(target: SplitTextTarget, options: SplitTextOptions) {
if (options.type !== "lines") return;
const elements = this.resolveElements(target);
elements.forEach((element) => this.splitIntoLines(element));
}
revert(): void {
this.entries.forEach(({ element, html }) => {
element.innerHTML = html;
});
this.lines = [];
this.entries = [];
}
private resolveElements(target: SplitTextTarget): HTMLElement[] {
if (typeof target === "string") {
return Array.from(document.querySelectorAll<HTMLElement>(target));
}
if (Array.isArray(target)) {
return target.filter((el): el is HTMLElement => el instanceof HTMLElement);
}
return target instanceof HTMLElement ? [target] : [];
}
private splitIntoLines(element: HTMLElement): void {
this.entries.push({ element, html: element.innerHTML });
const rowTexts = this.extractRows(element);
element.replaceChildren();
rowTexts.forEach((rowText) => {
const trimmed = rowText.trim();
if (!trimmed) return;
const wrappedLines = this.measureWrappedLines(element, trimmed);
wrappedLines.forEach((lineText) => {
const lineEl = document.createElement("div");
lineEl.textContent = lineText;
element.appendChild(lineEl);
this.lines.push(lineEl);
});
});
}
/** Split copy at <br /> while preserving nested markup text. */
private extractRows(element: HTMLElement): string[] {
const rows: string[] = [];
let buffer = "";
const flush = (): void => {
rows.push(buffer);
buffer = "";
};
const walk = (node: Node): void => {
if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? "";
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as HTMLElement;
if (el.tagName === "BR") {
flush();
return;
}
el.childNodes.forEach(walk);
};
element.childNodes.forEach(walk);
flush();
return rows.length ? rows : [element.textContent ?? ""];
}
private measureWrappedLines(element: HTMLElement, text: string): string[] {
const width = element.offsetWidth;
if (width <= 0) {
return [text];
}
const styles = window.getComputedStyle(element);
const measureHost = document.createElement("div");
measureHost.setAttribute("aria-hidden", "true");
measureHost.style.cssText = [
"position:absolute",
"visibility:hidden",
"pointer-events:none",
"top:0",
"left:0",
`width:${width}px`,
`font:${styles.font}`,
`letter-spacing:${styles.letterSpacing}`,
`word-spacing:${styles.wordSpacing}`,
`text-transform:${styles.textTransform}`,
].join(";");
element.appendChild(measureHost);
const words = text.split(/\s+/).filter(Boolean);
const wordSpans: HTMLSpanElement[] = [];
words.forEach((word, index) => {
const span = document.createElement("span");
span.style.display = "inline-block";
span.textContent = word + (index < words.length - 1 ? " " : "");
measureHost.appendChild(span);
wordSpans.push(span);
});
const lineGroups: HTMLSpanElement[][] = [];
let currentLine: HTMLSpanElement[] = [];
let currentTop = -1;
wordSpans.forEach((span) => {
const top = span.offsetTop;
if (currentTop === -1) {
currentTop = top;
}
if (top !== currentTop) {
lineGroups.push(currentLine);
currentLine = [];
currentTop = top;
}
currentLine.push(span);
});
if (currentLine.length) {
lineGroups.push(currentLine);
}
measureHost.remove();
const lines = lineGroups.map((group) =>
group.map((span) => span.textContent).join("")
);
return lines.length ? lines : [text];
}
}