diff --git a/.cursor/plans/text_splitter_package_1eeee927.plan.md b/.cursor/plans/text_splitter_package_1eeee927.plan.md index bb27c515..d01cf04f 100644 --- a/.cursor/plans/text_splitter_package_1eeee927.plan.md +++ b/.cursor/plans/text_splitter_package_1eeee927.plan.md @@ -4,47 +4,47 @@ overview: Add a new `@wix/splittext` package to the interact monorepo that provi todos: - id: pkg-setup content: Create package directory structure, package.json, tsconfig, vite.config - status: pending + status: completed - id: types content: Define TypeScript interfaces for options and result types - status: pending + status: completed dependencies: - pkg-setup - id: line-detection content: Implement Range API-based line detection (lineDetection.ts) - status: pending + status: completed dependencies: - types - id: core-split content: Implement core splitText function with chars/words/lines splitting - status: pending + status: completed dependencies: - types - line-detection - wrapper-spans - id: accessibility content: Add ARIA attribute handling for screen reader support - status: pending + status: completed dependencies: - core-split - id: wrapper-spans content: Implement customizable span wrapper creation with class/style/attrs options - status: pending + status: completed dependencies: - types - id: autosplit content: Add responsive autoSplit with resize/font-load observers - status: pending + status: completed dependencies: - core-split - id: react-hook content: Create useSplitText React hook with proper cleanup - status: pending + status: completed dependencies: - core-split - id: tests content: Write comprehensive test suite for all features - status: pending + status: completed dependencies: - core-split - accessibility diff --git a/AGENTS.md b/AGENTS.md index b898f685..6d707f8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,11 +6,12 @@ A monorepo for Wix's web animation and interaction libraries, built on the nativ ### Project Map -| Project | Package | Directory | -| -------- | --------------------- | -------------------------- | -| Motion | `@wix/motion` | `packages/motion/` | -| Interact | `@wix/interact` | `packages/interact/` | -| Presets | `@wix/motion-presets` | `packages/motion-presets/` | +| Project | Package | Directory | +| --------- | --------------------- | -------------------------- | +| Motion | `@wix/motion` | `packages/motion/` | +| Interact | `@wix/interact` | `packages/interact/` | +| Presets | `@wix/motion-presets` | `packages/motion-presets/` | +| SplitText | `@wix/splittext` | `packages/splittext/` | ### Dependency Graph @@ -26,6 +27,10 @@ A monorepo for Wix's web animation and interaction libraries, built on the nativ @wix/motion-presets ← ready-made presets ``` +``` +@wix/splittext ← standalone text splitting utility (no @wix/motion dependency) +``` + ### Motion (`@wix/motion`) Core animation toolkit. Provides low-level APIs for running animations via the Web Animations API and CSS, including scroll-driven (ViewTimeline) and pointer-based animations. Uses `fastdom` to batch DOM reads/writes and reduce layout thrashing. @@ -38,6 +43,10 @@ Declarative, configuration-driven interaction library built on top of `@wix/moti Ready-made animation presets for `@wix/motion`, organized in five categories: entrance, ongoing, scroll, mouse, and background-scroll. Each preset is a separate module under `library/`. Consumed via `registerEffects()`. +### SplitText (`@wix/splittext`) + +Lightweight, accessible text splitting utility. Splits element text into animatable `` wrappers at the character, word, line, or sentence level. Uses `Intl.Segmenter` for locale-aware segmentation and the Range API for accurate line detection. Ships two entry points: vanilla JS (`@wix/splittext`) and React (`@wix/splittext/react`). Pairs naturally with `@wix/motion` for staggered entrance animations. + ## CLI Commands Always run `nvm use` before executing any CLI commands to ensure the correct Node.js version is active. diff --git a/README.md b/README.md index 744f795e..8230c9b6 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,17 @@ Web-native animation and interaction libraries — declarative, AI-ready, framew ## Packages -| Package | Description | Links | -| -------------------------------------------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| [`@wix/interact`](https://github.com/wix/interact/blob/master/packages/interact/) | Declarative interaction layer (main package) | [README](https://github.com/wix/interact/blob/master/packages/interact/README.md) · [npm](https://www.npmjs.com/package/@wix/interact) | -| [`@wix/motion`](https://github.com/wix/interact/blob/master/packages/motion/) | Low-level animation engine | [README](https://github.com/wix/interact/blob/master/packages/motion/README.md) · [npm](https://www.npmjs.com/package/@wix/motion) | -| [`@wix/motion-presets`](https://github.com/wix/interact/tree/master/packages/motion-presets) | Ready-made animation presets | [npm](https://www.npmjs.com/package/@wix/motion-presets) | +| Package | Description | Links | +| -------------------------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| [`@wix/interact`](https://github.com/wix/interact/blob/master/packages/interact/) | Declarative interaction layer (main package) | [README](https://github.com/wix/interact/blob/master/packages/interact/README.md) · [npm](https://www.npmjs.com/package/@wix/interact) | +| [`@wix/motion`](https://github.com/wix/interact/blob/master/packages/motion/) | Low-level animation engine | [README](https://github.com/wix/interact/blob/master/packages/motion/README.md) · [npm](https://www.npmjs.com/package/@wix/motion) | +| [`@wix/motion-presets`](https://github.com/wix/interact/tree/master/packages/motion-presets) | Ready-made animation presets | [npm](https://www.npmjs.com/package/@wix/motion-presets) | +| [`@wix/splittext`](https://github.com/wix/interact/blob/master/packages/splittext/) | Accessible text splitting for animations | [README](https://github.com/wix/interact/blob/master/packages/splittext/README.md) · [npm](https://www.npmjs.com/package/@wix/splittext) | ``` @wix/motion ← @wix/interact (declarative layer) @wix/motion ← @wix/motion-presets (ready-made effects) +@wix/splittext (standalone — pairs with @wix/motion for staggered animations) ``` ## Quick Start diff --git a/package.json b/package.json index b98ee690..02415d74 100644 --- a/package.json +++ b/package.json @@ -60,5 +60,8 @@ "eslint-plugin-react": "^7.37.5", "prettier": "3.8.3", "typescript": "^5.9.3" + }, + "resolutions": { + "semver": "7.8.0" } } diff --git a/packages/splittext/README.md b/packages/splittext/README.md new file mode 100644 index 00000000..d20de7b2 --- /dev/null +++ b/packages/splittext/README.md @@ -0,0 +1,145 @@ +# @wix/splittext + +Lightweight, accessible text splitting utility for creating staggered animations on characters, words, lines, and sentences. + +[![npm version](https://img.shields.io/npm/v/@wix/splittext)](https://www.npmjs.com/package/@wix/splittext) +[![license](https://img.shields.io/npm/l/@wix/splittext)](https://github.com/wix/interact/blob/master/LICENSE) + +## Features + +- **Split by chars, words, lines, or sentences** — each piece wrapped in an animatable `` +- **Locale-aware segmentation** — uses `Intl.Segmenter` (handles emoji, unicode, grapheme clusters) +- **Range API line detection** — accurate line breaks from browser rendering, no pre-wrapping required +- **Lazy evaluation** — DOM is only mutated when a result getter is first accessed +- **Accessible by default** — original text preserved for screen readers and SEO +- **Customizable wrappers** — add classes, inline styles, and data attributes per split type +- **CSS stagger hooks** — `--char-index`, `--word-index`, etc. set automatically on every span +- **React hook** — `useSplitText` with automatic cleanup on unmount +- **Responsive** — optional `autoSplit` re-splits on resize and font load +- **Revertible** — restore the original DOM at any time with `result.revert()` + +## Installation + +```bash +npm install @wix/splittext +``` + +**Browser requirement:** `Intl.Segmenter` (Chrome 87+, Safari 14.1+, Firefox 125+). For older environments supply a polyfill via the `segmenter` option. + +## Quick Start + +```typescript +import { splitText } from '@wix/splittext'; + +// Lazy — DOM unchanged until getter accessed +const result = splitText('.headline'); +const chars = result.chars; // DOM split into chars here, cached + +// Eager — split immediately on call +const { chars } = splitText('.headline', { type: 'chars' }); + +// Animate with any library +animate(chars, { opacity: [0, 1], transform: ['translateY(8px)', 'translateY(0)'], stagger: 0.03 }); +``` + +### Staggered CSS animation + +```typescript +splitText('.headline', { + type: 'chars', + wrapperClass: 'char', +}); +``` + +```css +.char { + opacity: 0; + animation: fadeUp 0.4s ease forwards; + animation-delay: calc(var(--char-index) * 0.04s); +} +@keyframes fadeUp { + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +### React + +```tsx +import { useRef, useEffect } from 'react'; +import { useSplitText } from '@wix/splittext/react'; + +function Headline() { + const ref = useRef(null); + const result = useSplitText(ref, { type: 'chars' }); + + useEffect(() => { + if (!result) return; + // animate result.chars … + }, [result]); + + return

Hello World

; +} +``` + +## API + +### `splitText(target, options?)` + +| Parameter | Type | Description | +| --------- | ----------------------- | ------------------------- | +| `target` | `string \| HTMLElement` | CSS selector or element | +| `options` | `SplitTextOptions` | Configuration (see below) | + +Returns a `SplitTextResult` with lazy getters: `.chars`, `.words`, `.lines`, `.sentences`. + +### Options + +| Option | Type | Default | Description | +| ------------------ | ---------------------------------------------------- | ------------ | ------------------------------------------------- | +| `type` | `SplitType \| SplitType[]` | — | Split eagerly on call instead of lazily | +| `wrapperClass` | `string \| WrapperClassConfig` | — | Extra CSS class(es) on wrapper spans | +| `wrapperStyle` | `Partial \| WrapperStyleConfig` | — | Inline styles on wrapper spans | +| `wrapperAttrs` | `Record \| WrapperAttrsConfig` | — | Custom attributes on wrapper spans | +| `contentAttribute` | `'none' \| 'both' \| 'attribute-only'` | `'both'` | Controls `data-content` on char/word wrappers | +| `aria` | `'auto' \| 'none'` | `'auto'` | ARIA handling mode | +| `preserveText` | `boolean` | `true` | Insert visually-hidden original text for a11y/SEO | +| `partIndexing` | `boolean` | `true` | Set `--char-index` / `--word-index` etc. on spans | +| `nested` | `'flatten' \| 'preserve' \| number` | `'preserve'` | How inner DOM structure is handled | +| `autoSplit` | `boolean` | — | Re-split on resize / font load | +| `onSplit` | `(result) => void` | — | Callback after each split | +| `segmenter` | `Intl.Segmenter \| constructor` | — | Polyfill for `Intl.Segmenter` | +| `ignore` | `string[] \| (node) => boolean` | — | Selectors / predicate to skip nodes | + +### Default CSS classes + +| Split type | Class | +| ----------- | ---------- | +| `chars` | `.split-c` | +| `words` | `.split-w` | +| `lines` | `.split-l` | +| `sentences` | `.split-s` | + +Base styles (`display: inline-block`, etc.) are injected once via `adoptedStyleSheets`. + +## Accessibility + +With defaults (`aria: 'auto'`, `preserveText: true`), the DOM looks like: + +```html +

+ Original text + +

+``` + +Screen readers and crawlers see the original text; the split spans are hidden from the accessibility tree. + +## License + +[MIT](https://github.com/wix/interact/blob/master/LICENSE) diff --git a/packages/splittext/package.json b/packages/splittext/package.json new file mode 100644 index 00000000..43700312 --- /dev/null +++ b/packages/splittext/package.json @@ -0,0 +1,82 @@ +{ + "name": "@wix/splittext", + "version": "0.1.0", + "description": "Lightweight, accessible text splitting utility for creating staggered animations on characters, words, and lines.", + "license": "MIT", + "main": "dist/cjs/index.js", + "module": "dist/es/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/es/index.js", + "require": "./dist/cjs/index.js" + }, + "./react": { + "types": "./dist/types/react/index.d.ts", + "import": "./dist/es/react.js", + "require": "./dist/cjs/react.js" + } + }, + "files": [ + "dist", + "docs" + ], + "sideEffects": false, + "scripts": { + "build": "rimraf dist && vite build && npm run build:types", + "build:types": "tsc -p tsconfig.build.json", + "lint": "tsc --noEmit", + "test": "vitest run", + "coverage": "vitest run --coverage" + }, + "keywords": [ + "animation", + "text-split", + "split-text", + "waapi", + "web-animations-api", + "javascript", + "stagger", + "motion", + "wix" + ], + "author": { + "name": "wow!Team", + "email": "wow-dev@wix.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wix/interact.git" + }, + "bugs": { + "url": "https://github.com/wix/interact/issues" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + }, + "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.1.0", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.0.14", + "jsdom": "^24.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rimraf": "^6.0.1", + "typescript": "^5.9.3", + "vite": "^7.2.2", + "vitest": "^4.0.14" + } +} diff --git a/packages/splittext/src/accessibility.ts b/packages/splittext/src/accessibility.ts new file mode 100644 index 00000000..ef1dffec --- /dev/null +++ b/packages/splittext/src/accessibility.ts @@ -0,0 +1,87 @@ +import type { SplitTextOptions } from './types'; + +export const WRAPPER_ATTR = 'data-splittext-wrapper'; +export const SR_ONLY_ATTR = 'data-splittext-sr'; + +/** + * Wrap all current children of `container` in an `aria-hidden` inner div and + * add a screen-reader-accessible copy of the original text. + * + * DOM structure produced when `aria: 'auto'` and `preserveText: true`: + * + * ```html + * + * Original text + * + * + * ``` + * + * When `preserveText` is `false`, `aria-label` is set on the container + * instead of injecting the visually-hidden span. + * + * @param container - The target element whose children will be wrapped. + * @param originalText - Plain-text representation of the original content. + * @param options - Parent `splitText` options. + * @returns The inner wrapper div containing the split content children. + */ +export function applyAccessibility( + container: HTMLElement, + originalText: string, + options: Pick, +): HTMLDivElement { + const aria = options.aria ?? 'auto'; + + if (aria === 'none') { + // No ARIA changes — return a transparent fragment wrapper so callers + // still have a consistent element to append split spans to. + const passthrough = document.createElement('div'); + passthrough.setAttribute(WRAPPER_ATTR, ''); + while (container.firstChild) { + passthrough.appendChild(container.firstChild); + } + container.appendChild(passthrough); + return passthrough; + } + + // aria === 'auto' + const preserveText = options.preserveText !== false; + + const inner = document.createElement('div'); + inner.setAttribute('aria-hidden', 'true'); + inner.setAttribute(WRAPPER_ATTR, ''); + + // Move existing children into the hidden wrapper + while (container.firstChild) { + inner.appendChild(container.firstChild); + } + + if (preserveText) { + const srSpan = document.createElement('span'); + srSpan.className = 'sr-only'; + srSpan.setAttribute(SR_ONLY_ATTR, ''); + srSpan.textContent = originalText; + container.appendChild(srSpan); + } else { + container.setAttribute('aria-label', originalText); + } + + container.appendChild(inner); + return inner; +} + +/** + * Remove accessibility structures added by `applyAccessibility`, restoring + * the container to its original state (only the inner wrapper is dealt with — + * the caller is responsible for restoring `innerHTML` via `originalHTML`). + */ +export function removeAccessibility(container: HTMLElement): void { + container.removeAttribute('aria-label'); + + const srOnly = container.querySelector(`[${SR_ONLY_ATTR}]`); + if (srOnly) srOnly.remove(); + + const wrapper = container.querySelector(`[${WRAPPER_ATTR}]`); + if (wrapper) wrapper.remove(); +} diff --git a/packages/splittext/src/index.ts b/packages/splittext/src/index.ts new file mode 100644 index 00000000..847708c8 --- /dev/null +++ b/packages/splittext/src/index.ts @@ -0,0 +1,9 @@ +export { splitText } from './splitText'; +export type { + SplitTextOptions, + SplitTextResult, + SplitType, + WrapperClassConfig, + WrapperStyleConfig, + WrapperAttrsConfig, +} from './types'; diff --git a/packages/splittext/src/lineDetection.ts b/packages/splittext/src/lineDetection.ts new file mode 100644 index 00000000..b9ed4f8d --- /dev/null +++ b/packages/splittext/src/lineDetection.ts @@ -0,0 +1,95 @@ +import type { SplitTextOptions } from './types'; +import { walkTextNodes } from './utils'; + +/** + * A line as detected from a single text node: an ordered list of character + * strings that share the same rendered line. + */ +interface DetectedLine { + /** Characters belonging to this line. */ + chars: string[]; + /** The text node these characters come from. */ + node: Text; +} + +/** + * Detect rendered lines within a single text node using `Range.getClientRects()`. + * + * The algorithm iterates character-by-character, growing a range from the node + * start to each character position and counting the number of rects returned. + * Each additional rect indicates a new rendered line. + * + * **Safari compatibility:** Safari's `getClientRects()` is sensitive to markup + * whitespace (raw newlines and multiple spaces each produce an extra rect). + * The function unconditionally normalises whitespace before measurement and + * restores the original text afterwards. + */ +export function detectLinesFromTextNode(textNode: Text): DetectedLine[] { + const originalText = textNode.textContent ?? ''; + const normalised = originalText.trim().replace(/\s+/g, ' '); + + if (!normalised) return []; + + // Apply normalised content for accurate rect measurement + textNode.textContent = normalised; + + const range = document.createRange(); + const lines: DetectedLine[] = []; + let currentLine: DetectedLine | null = null; + + for (let i = 0; i < normalised.length; i++) { + range.setStart(textNode, 0); + range.setEnd(textNode, i + 1); + + const rects = range.getClientRects(); + const lineIndex = rects.length - 1; + + if (!lines[lineIndex]) { + currentLine = { chars: [], node: textNode }; + lines.push(currentLine); + } else { + currentLine = lines[lineIndex]; + } + + currentLine.chars.push(normalised.charAt(i)); + } + + // Restore original text to leave the node in its pre-measurement state + textNode.textContent = originalText; + + return lines; +} + +/** + * Detect all rendered lines within `element` by walking its text nodes and + * applying Range-based line detection to each. + * + * Returns an array of trimmed line strings in document order. + * + * Notes: + * - Line detection reads layout (via `getClientRects`) and therefore must run + * **before** any DOM mutation that would reflow the element. + * - When the element contains multiple text nodes (e.g. nested inline + * elements), lines that span text-node boundaries are treated as separate + * segments; the caller is responsible for merging if needed. + */ +export function detectLines( + element: HTMLElement, + options: Pick = {}, +): string[] { + const allLines: string[] = []; + + walkTextNodes( + element, + (node) => { + const detected = detectLinesFromTextNode(node); + for (const line of detected) { + const text = line.chars.join('').trim(); + if (text) allLines.push(text); + } + }, + options.ignore, + ); + + return allLines; +} diff --git a/packages/splittext/src/react/index.ts b/packages/splittext/src/react/index.ts new file mode 100644 index 00000000..ebe7c7d0 --- /dev/null +++ b/packages/splittext/src/react/index.ts @@ -0,0 +1 @@ +export { useSplitText } from './useSplitText'; diff --git a/packages/splittext/src/react/useSplitText.ts b/packages/splittext/src/react/useSplitText.ts new file mode 100644 index 00000000..cff99f09 --- /dev/null +++ b/packages/splittext/src/react/useSplitText.ts @@ -0,0 +1,50 @@ +import { useEffect, useState, type RefObject } from 'react'; +import { splitText } from '../splitText'; +import type { SplitTextOptions, SplitTextResult } from '../types'; + +/** + * React hook that splits the text content of the element referenced by `ref`. + * + * Splitting happens after mount (and after every `options` change). The result + * is `null` on the first render and during SSR. On unmount the element is + * automatically reverted to its original HTML. + * + * ```tsx + * function Headline() { + * const ref = useRef(null); + * const result = useSplitText(ref, { type: 'chars' }); + * + * useEffect(() => { + * if (!result) return; + * // animate result.chars … + * }, [result]); + * + * return

Hello World

; + * } + * ``` + */ +export function useSplitText( + ref: RefObject, + options: SplitTextOptions = {}, +): SplitTextResult | null { + const [result, setResult] = useState(null); + // Serialise options to a stable string so the effect re-runs only when + // options genuinely change (avoids infinite loops from inline object literals). + const optionsKey = JSON.stringify(options); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const splitResult = splitText(element, options); + setResult(splitResult); + + return () => { + splitResult.revert(); + setResult(null); + }; + // optionsKey is the serialised version of options — safe to use as dep + }, [ref, optionsKey]); + + return result; +} diff --git a/packages/splittext/src/splitText.ts b/packages/splittext/src/splitText.ts new file mode 100644 index 00000000..6752817b --- /dev/null +++ b/packages/splittext/src/splitText.ts @@ -0,0 +1,320 @@ +import type { SplitTextOptions, SplitTextResult, SplitType } from './types'; +import { applyAccessibility } from './accessibility'; +import { detectLines } from './lineDetection'; +import { segmentChars, segmentSentences, segmentWords, getTextContent } from './utils'; +import { createWrapper, injectBaseStyles } from './wrappers'; + +type SplitCache = { + chars?: HTMLSpanElement[]; + words?: HTMLSpanElement[]; + lines?: HTMLSpanElement[]; + sentences?: HTMLSpanElement[]; +}; + +/** + * Perform character-level splitting on a plain text string. + * Returns an array of `` elements (not yet inserted into the DOM). + */ +function splitChars(text: string, options: SplitTextOptions): HTMLSpanElement[] { + const chars = segmentChars(text, options); + return chars.map((char, i) => createWrapper(char, 'chars', i, options)); +} + +/** + * Perform word-level splitting on a plain text string. + * Returns an array of `` elements (not yet inserted into the DOM). + */ +function splitWords(text: string, options: SplitTextOptions): HTMLSpanElement[] { + const words = segmentWords(text, options); + return words.map((word, i) => createWrapper(word, 'words', i, options)); +} + +/** + * Perform sentence-level splitting on a plain text string. + * Returns an array of `` elements (not yet inserted into the DOM). + */ +function splitSentences(text: string, options: SplitTextOptions): HTMLSpanElement[] { + const sentences = segmentSentences(text, options); + return sentences.map((sentence, i) => createWrapper(sentence, 'sentences', i, options)); +} + +/** + * Perform line-level splitting on `element`. + * + * Line detection **must** happen before any DOM mutation (the Range API + * queries the pre-split layout). After detection, the element's text is + * replaced with wrapper spans, one per detected line. + */ +function splitLinesInElement(element: HTMLElement, options: SplitTextOptions): HTMLSpanElement[] { + const lines = detectLines(element, options); + return lines.map((line, i) => createWrapper(line, 'lines', i, options)); +} + +/** + * Resolve a CSS selector or DOM element to an `HTMLElement`. + * Throws a descriptive error when the target cannot be found. + */ +function resolveElement(target: string | HTMLElement): HTMLElement { + if (typeof target === 'string') { + const el = document.querySelector(target); + if (!el) throw new Error(`[@wix/splittext] No element found for selector: "${target}"`); + return el; + } + return target; +} + +// --------------------------------------------------------------------------- +// SplitTextResultImpl +// --------------------------------------------------------------------------- + +class SplitTextResultImpl implements SplitTextResult { + readonly element: HTMLElement; + readonly originalHTML: string; + + private _originalText: string; + private _options: SplitTextOptions; + private _cache: SplitCache = {}; + private _isSplit = false; + /** + * Which split type currently occupies the element's DOM. Only one type is + * rendered in the DOM at a time; cached spans for other types remain valid + * JS objects and are re-inserted when their getter is accessed again. + */ + private _activeType: SplitType | null = null; + + /** ResizeObserver for autoSplit support. */ + private _resizeObserver: ResizeObserver | null = null; + + constructor(element: HTMLElement, options: SplitTextOptions = {}) { + this.element = element; + this.originalHTML = element.innerHTML; + // Capture plain text before any DOM mutation so getters always use the + // correct source string regardless of the element's current DOM state. + this._originalText = getTextContent(element); + this._options = options; + + // Inject base stylesheet once per document + injectBaseStyles(element.ownerDocument); + + // Eager split when `type` is specified + if (options.type) { + const types = Array.isArray(options.type) ? options.type : [options.type]; + for (const type of types) { + this._compute(type); + } + // Activate the last requested type in the DOM + this._activate(types[types.length - 1]); + } + + // AutoSplit observers + if (options.autoSplit) { + this._attachObservers(); + } + } + + // ------------------------------------------------------------------------- + // Public getters (lazy evaluation) + // ------------------------------------------------------------------------- + + get chars(): HTMLSpanElement[] { + if (!this._cache.chars) this._compute('chars'); + if (this._activeType !== 'chars') this._activate('chars'); + return this._cache.chars!; + } + + get words(): HTMLSpanElement[] { + if (!this._cache.words) this._compute('words'); + if (this._activeType !== 'words') this._activate('words'); + return this._cache.words!; + } + + get lines(): HTMLSpanElement[] { + if (!this._cache.lines) this._compute('lines'); + if (this._activeType !== 'lines') this._activate('lines'); + return this._cache.lines!; + } + + get sentences(): HTMLSpanElement[] { + if (!this._cache.sentences) this._compute('sentences'); + if (this._activeType !== 'sentences') this._activate('sentences'); + return this._cache.sentences!; + } + + get isSplit(): boolean { + return this._isSplit; + } + + // ------------------------------------------------------------------------- + // Public methods + // ------------------------------------------------------------------------- + + revert(): void { + this._detachObservers(); + this.element.innerHTML = this.originalHTML; + this._cache = {}; + this._isSplit = false; + this._activeType = null; + } + + split(options?: SplitTextOptions): SplitTextResult { + this.revert(); + return new SplitTextResultImpl(this.element, { ...this._options, ...options }); + } + + // ------------------------------------------------------------------------- + // Internal: compute (no DOM write) vs. activate (DOM write) + // ------------------------------------------------------------------------- + + /** + * Compute and cache the spans for `type` **without mutating the DOM**, + * except for `'lines'` which requires the pre-split layout. + * + * Separating computation from activation lets multiple types be computed + * and cached independently without overwriting each other's cached spans + * in the DOM between calls. + */ + private _compute(type: SplitType): void { + if (this._cache[type]) return; + + if (type === 'lines') { + // Range-based line detection must run before any DOM mutation. + // If another type is currently rendered, restore the original HTML first. + if (this._isSplit) { + this.element.innerHTML = this.originalHTML; + this._isSplit = false; + this._activeType = null; + // Cached spans for other types remain valid detached DOM nodes and + // can be re-activated later via _activate(). + } + this._cache.lines = splitLinesInElement(this.element, this._options); + } else if (type === 'chars') { + this._cache.chars = splitChars(this._originalText, this._options); + } else if (type === 'words') { + this._cache.words = splitWords(this._originalText, this._options); + } else { + this._cache.sentences = splitSentences(this._originalText, this._options); + } + } + + /** + * Insert the cached spans for `type` into the element's DOM, replacing any + * currently active split content. Only one type is active in the DOM at a + * time. + */ + private _activate(type: SplitType): void { + const spans = this._cache[type]; + if (!spans) return; + + const text = this._originalText; + const finalSpans = this._applyBidi(spans, text); + + this.element.innerHTML = ''; + const innerWrapper = applyAccessibility(this.element, text, this._options); + for (const span of finalSpans) { + innerWrapper.appendChild(span); + } + + this._activeType = type; + this._isSplit = true; + this._options.onSplit?.(this); + } + + /** + * Wrap split spans in `` runs when a `bidiResolver` is + * provided. Returns the original array unchanged when no resolver is set. + */ + private _applyBidi(spans: HTMLSpanElement[], text: string): Array { + const resolver = this._options.bidiResolver; + if (!resolver) return spans; + + const runs = resolver(text); + const runEls: HTMLSpanElement[] = []; + let spanIndex = 0; + for (const run of runs) { + const runSpan = document.createElement('span') as HTMLSpanElement; + runSpan.setAttribute('dir', run.direction); + runSpan.classList.add(run.direction === 'rtl' ? 'split-rtl' : 'split-ltr'); + + const runChars = Array.from( + new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(run.text), + ); + for (let i = 0; i < runChars.length && spanIndex < spans.length; i++, spanIndex++) { + runSpan.appendChild(spans[spanIndex]); + } + runEls.push(runSpan); + } + return runEls; + } + + // ------------------------------------------------------------------------- + // AutoSplit observers + // ------------------------------------------------------------------------- + + private _attachObservers(): void { + if (typeof ResizeObserver !== 'undefined') { + this._resizeObserver = new ResizeObserver(() => this._onResize()); + this._resizeObserver.observe(this.element); + } + + if (typeof document !== 'undefined' && document.fonts) { + document.fonts.ready.then(() => this._onResize()); + } + } + + private _detachObservers(): void { + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + } + + private _onResize(): void { + const cachedTypes = Object.keys(this._cache) as SplitType[]; + if (cachedTypes.length === 0) return; + + const prevActive = this._activeType; + + // Reset state and restore original DOM + this._cache = {}; + this._isSplit = false; + this._activeType = null; + this.element.innerHTML = this.originalHTML; + // Re-read plain text in case the element's content changed + this._originalText = getTextContent(this.element); + + // Re-compute all previously cached types + for (const type of cachedTypes) { + this._compute(type); + } + + // Re-activate the previously rendered type + if (prevActive && this._cache[prevActive]) { + this._activate(prevActive); + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Split the text content of `target` into animatable `` wrappers. + * + * ```typescript + * // Lazy — no DOM mutation until a getter is accessed + * const result = splitText('.headline'); + * const chars = result.chars; // DOM mutated here + * + * // Eager — chars split immediately on call + * const { chars } = splitText('.headline', { type: 'chars' }); + * ``` + * + * @param target - CSS selector string or `HTMLElement`. + * @param options - Configuration options. + */ +export function splitText( + target: string | HTMLElement, + options: SplitTextOptions = {}, +): SplitTextResult { + const element = resolveElement(target); + return new SplitTextResultImpl(element, options); +} diff --git a/packages/splittext/src/types.ts b/packages/splittext/src/types.ts new file mode 100644 index 00000000..d70c673e --- /dev/null +++ b/packages/splittext/src/types.ts @@ -0,0 +1,162 @@ +export type SplitType = 'chars' | 'words' | 'lines' | 'sentences'; + +export interface WrapperClassConfig { + chars?: string; + words?: string; + lines?: string; + sentences?: string; +} + +export interface WrapperStyleConfig { + chars?: Partial; + words?: Partial; + lines?: Partial; + sentences?: Partial; +} + +export interface WrapperAttrsConfig { + chars?: Record; + words?: Record; + lines?: Record; + sentences?: Record; +} + +export interface SplitTextOptions { + /** + * Split types to compute. When specified, those types are split eagerly on + * invocation; omitting the option defers splitting until each getter is + * accessed. + */ + type?: SplitType | SplitType[]; + + /** + * CSS class(es) added to every wrapper ``. Accepts either a single + * string (applied to all types) or a per-type config object. + */ + wrapperClass?: string | WrapperClassConfig; + + /** + * Inline styles applied to every wrapper ``. Accepts either a global + * `CSSStyleDeclaration` partial (applied to all types) or a per-type config. + */ + wrapperStyle?: Partial | WrapperStyleConfig; + + /** + * Custom HTML attributes applied to every wrapper ``. Accepts either a + * global record (applied to all types) or a per-type config. + */ + wrapperAttrs?: Record | WrapperAttrsConfig; + + /** + * Controls whether char/word wrappers receive a `data-content` attribute + * mirroring their text content (useful for CSS `content: attr(data-content)` + * generated-content effects). + * + * - `'both'` (default): text content present and `data-content` set. + * - `'none'`: no `data-content` attribute. + * - `'attribute-only'`: `data-content` set, text content left empty. + */ + contentAttribute?: 'none' | 'both' | 'attribute-only'; + + /** + * ARIA handling mode. + * + * - `'auto'` (default): wraps split content in an `aria-hidden` div and + * preserves the original text for screen readers. + * - `'none'`: no ARIA changes. + */ + aria?: 'auto' | 'none'; + + /** + * When `true` (default), inserts a visually-hidden `` containing the + * original text as a sibling of the split content for SEO and assistive + * technology. When `false`, sets `aria-label` on the container instead. + */ + preserveText?: boolean; + + /** + * How inner DOM structure is handled. + * + * - `'preserve'` (default): traverse text nodes via `TreeWalker`, keeping + * inline elements (links, bold, italic) intact. + * - `'flatten'`: use `element.textContent`, discarding all inner DOM. + * - `number`: preserve N element levels; deeper content is flattened. + */ + nested?: 'flatten' | 'preserve' | number; + + /** + * Provide a custom `Intl.Segmenter` constructor when native support is + * missing. Accepts either an already-constructed instance or the constructor + * itself (the library will instantiate it per granularity). + */ + segmenter?: + | Intl.Segmenter + | { new (locale: string, options: { granularity: string }): Intl.Segmenter }; + + /** + * Optional plugin for BiDi (bidirectional text) handling. Receives the flat + * text content and must return ordered runs with explicit direction. See docs + * for plugin contract details. + */ + bidiResolver?: (text: string) => Array<{ text: string; direction: 'ltr' | 'rtl' }>; + + /** + * When `true`, attaches a `ResizeObserver` and `fonts.ready` listener that + * automatically re-split on viewport or font changes. + */ + autoSplit?: boolean; + + /** + * Called after every split (including re-splits triggered by `autoSplit`). + * Receives the updated `SplitTextResult`. + */ + onSplit?: (result: SplitTextResult) => Animation | void; + + /** + * When `true` (default), sets CSS custom properties (`--char-index`, + * `--word-index`, `--line-index`, `--sentence-index`) on each wrapper span + * for use in staggered CSS animations. + */ + partIndexing?: boolean; + + /** + * Selectors or a predicate to skip nodes during traversal (only applies in + * `'preserve'` / `number` nested modes). Example: `['sup', 'sub']`. + */ + ignore?: string[] | ((node: Node) => boolean); +} + +export interface SplitTextResult { + /** Split into individual grapheme clusters. DOM is mutated on first access. */ + readonly chars: HTMLSpanElement[]; + + /** Split into word tokens. DOM is mutated on first access. */ + readonly words: HTMLSpanElement[]; + + /** + * Split into rendered lines using the Range API. DOM is mutated on first + * access. Triggers layout queries. + */ + readonly lines: HTMLSpanElement[]; + + /** Split into sentences. DOM is mutated on first access. */ + readonly sentences: HTMLSpanElement[]; + + /** Restore the element to its original HTML and clear the cache. */ + revert(): void; + + /** + * Re-split the element with (optionally new) options, clearing the current + * cache first. + */ + split(options?: SplitTextOptions): SplitTextResult; + + /** Original `innerHTML` captured at construction time. */ + readonly originalHTML: string; + + /** The target element. */ + readonly element: HTMLElement; + + /** `true` if the DOM has been mutated by at least one split operation. */ + readonly isSplit: boolean; +} diff --git a/packages/splittext/src/utils.ts b/packages/splittext/src/utils.ts new file mode 100644 index 00000000..3ec9e300 --- /dev/null +++ b/packages/splittext/src/utils.ts @@ -0,0 +1,134 @@ +import type { SplitTextOptions } from './types'; + +/** + * Resolve `Intl.Segmenter` constructor from the `segmenter` option or the + * global. Throws a descriptive error when neither is available. + */ +function resolveSegmenterCtor(option: SplitTextOptions['segmenter']): typeof Intl.Segmenter { + // Option is already a constructor (has `prototype.segment`) + if (typeof option === 'function') { + return option as unknown as typeof Intl.Segmenter; + } + + // Option is a pre-constructed instance — extract its constructor + if (option != null && typeof (option as Intl.Segmenter).segment === 'function') { + return option.constructor as unknown as typeof Intl.Segmenter; + } + + // Fall back to native global + if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') { + return Intl.Segmenter; + } + + throw new Error( + '[@wix/splittext] Intl.Segmenter is not available in this environment. ' + + 'Provide a polyfill via the `segmenter` option or install one that ' + + 'patches the global (e.g. `@formatjs/intl-segmenter`).', + ); +} + +/** + * Resolve the best available locale for Intl.Segmenter. + * An empty string `''` is not a valid BCP 47 tag in all runtimes; fall back + * to `'en'` when locale detection is unavailable. + */ +function resolveLocale(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().locale || 'en'; + } catch { + return 'en'; + } +} + +/** + * Segment `text` into grapheme clusters (characters), respecting emoji and + * multi-codepoint sequences. + */ +export function segmentChars( + text: string, + options: Pick = {}, +): string[] { + const Ctor = resolveSegmenterCtor(options.segmenter); + const segmenter = new Ctor(resolveLocale(), { granularity: 'grapheme' }); + return Array.from(segmenter.segment(text), (s) => s.segment); +} + +/** + * Segment `text` into word tokens, filtering punctuation/whitespace-only + * segments via `isWordLike`. + */ +export function segmentWords( + text: string, + options: Pick = {}, +): string[] { + const Ctor = resolveSegmenterCtor(options.segmenter); + const segmenter = new Ctor(resolveLocale(), { granularity: 'word' }); + return Array.from(segmenter.segment(text)) + .filter((s) => s.isWordLike) + .map((s) => s.segment); +} + +/** + * Segment `text` into sentences using `Intl.Segmenter` with + * `granularity: 'sentence'`. + */ +export function segmentSentences( + text: string, + options: Pick = {}, +): string[] { + const Ctor = resolveSegmenterCtor(options.segmenter); + const segmenter = new Ctor(resolveLocale(), { granularity: 'sentence' }); + return Array.from(segmenter.segment(text), (s) => s.segment.trim()).filter(Boolean); +} + +/** + * Walk all text node descendants of `root`, invoking `callback` for each. + * Skips `