text-slicer 1.5.0-dev.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,174 +1,133 @@
1
- <p align="center"><strong>text-slicer</strong></p>
1
+ # text-slicer
2
2
 
3
- <div align="center">
3
+ Split text inside an HTML element into words and/or characters, wrapping each fragment in its own span.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/text-slicer.svg?colorB=brightgreen)](https://www.npmjs.com/package/text-slicer)
6
- [![GitHub package version](https://img.shields.io/github/package-json/v/ux-ui-pro/text-slicer.svg)](https://github.com/ux-ui-pro/text-slicer)
7
- [![NPM Downloads](https://img.shields.io/npm/dm/text-slicer.svg?style=flat)](https://www.npmjs.org/package/text-slicer)
6
+ [![NPM Downloads](https://img.shields.io/npm/dm/text-slicer.svg?style=flat)](https://www.npmjs.com/package/text-slicer)
8
7
 
9
- </div>
8
+ [Demo](https://codepen.io/ux-ui/full/vYMoGoG)
10
9
 
11
- <p align="center">Split text inside an HTML element into words and/or characters, wrapping each in a dedicated <code>&lt;span&gt;</code>. Built for robust animation pipelines and i18n-safe rendering.</p>
12
- <p align="center"><a href="https://codepen.io/ux-ui/full/vYMoGoG">Demo</a></p>
13
- <br>
10
+ ---
14
11
 
15
- ## Install
12
+ - Split by words, characters, or both (`splitMode`).
13
+ - Optional CSS variables (`--word-index`, `--char-index`, `--word-total`, `--char-total`, `--container-height`).
14
+ - Optional `data-word` / `data-char` attributes for styling or scripting.
15
+ - Lifecycle helpers: `init`, `reinit`, `clear`, `split`, `destroy`, `updateOptions`, `lockHeight`, `unlockHeight`.
16
+ - `onAfterRender` callback with word/char totals and timestamp.
17
+ - ~1.5kB gzipped.
18
+
19
+ ---
20
+
21
+ ## Installation
16
22
 
17
23
  ```bash
18
- yarn add text-slicer
19
- # or
20
- npm i text-slicer
24
+ npm install text-slicer
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ HTML:
30
+
31
+ ```html
32
+ <p class="text-slicer">Hello world</p>
21
33
  ```
22
- <br>
23
34
 
24
- ## Quick start
35
+ JavaScript:
25
36
 
26
37
  ```ts
27
38
  import { TextSlicer } from 'text-slicer';
28
39
 
29
- const slicer = new TextSlicer({ container: '.text-slicer' });
40
+ const textSlicer = new TextSlicer({ container: '.text-slicer' });
30
41
 
31
- slicer.init();
42
+ textSlicer.init();
32
43
  ```
33
44
 
34
- Initialize per element:
45
+ With options and a callback:
35
46
 
36
47
  ```ts
37
- document.querySelectorAll('.text-slicer').forEach((el) => {
38
- const slicer = new TextSlicer({ container: el });
48
+ document.addEventListener('DOMContentLoaded', () => {
49
+ const textSlicer = new TextSlicer(
50
+ {
51
+ container: '.text-slicer',
52
+ splitMode: 'both',
53
+ cssVariables: true,
54
+ dataAttributes: true,
55
+ },
56
+ {
57
+ onAfterRender: (metrics) => console.log(metrics),
58
+ },
59
+ );
39
60
 
40
- slicer.init();
61
+ textSlicer.init();
41
62
  });
42
63
  ```
43
- <br>
44
-
45
- ## API
46
64
 
47
- ### Types
65
+ Apply to every matching element on the page:
48
66
 
49
67
  ```ts
50
- export type SplitMode = 'words' | 'chars' | 'both';
51
-
52
- export interface TextSlicerOptions {
53
- container?: HTMLElement | string;
54
- splitMode?: SplitMode;
55
- cssVariables?: boolean;
56
- dataAttributes?: boolean;
57
- /** Keep dedicated whitespace nodes between words (for precise animations). Default: true */
58
- keepWhitespaceNodes?: boolean;
59
- /** Freeze measured word widths to avoid reflow jitter on responsive layouts. Default: false */
60
- freezeWordWidths?: boolean;
61
- }
62
-
63
- export interface TextSlicerMetrics {
64
- wordTotal: number;
65
- charTotal: number;
66
- renderedAt: number;
67
- }
68
-
69
- export interface TextSlicerCallbacks {
70
- onAfterRender?: (metrics: TextSlicerMetrics) => void;
71
- }
72
- ```
73
-
74
- ### Classnames & CSS vars
68
+ document.addEventListener('DOMContentLoaded', () => {
69
+ document.querySelectorAll('.text-slicer').forEach((element) => {
70
+ const textSlicer = new TextSlicer({ container: element });
75
71
 
76
- ```ts
77
- import { CLASSNAMES } from 'text-slicer';
78
-
79
- // Classes applied to generated spans
80
- CLASSNAMES.word // 'ts-word'
81
- CLASSNAMES.char // 'ts-char'
82
- CLASSNAMES.whitespace // 'ts-whitespace'
83
-
84
- // CSS variables placed on container and items (when cssVariables: true)
85
- --word-total
86
- --char-total
87
- --word-index
88
- --char-index
72
+ textSlicer.init();
73
+ });
74
+ });
89
75
  ```
90
76
 
91
- ### Constructor
77
+ ## API
78
+
79
+ Named exports:
92
80
 
93
81
  ```ts
94
- new TextSlicer(options?: TextSlicerOptions, callbacks?: TextSlicerCallbacks)
82
+ import { TextSlicer, CLASSNAMES } from 'text-slicer';
95
83
  ```
96
84
 
97
- ### Methods
85
+ - `TextSlicer` — main class.
86
+ - `CLASSNAMES` — BEM-style class map (`word`, `char`, `whitespace`).
87
+ - Types: `SplitMode`, `TextSlicerOptions`, `TextSlicerMetrics`, `TextSlicerCallbacks`.
98
88
 
99
- - `init(): void` Perform initial split.
100
- - `reinit(newText?: string, nextOpts?: Partial<TextSlicerOptions>): void` – Update text and/or options and re-split.
101
- - `updateOptions(next: Partial<TextSlicerOptions>): void` – Merge options and re-split (if mounted).
102
- - `clear(): void` – Remove generated nodes and unfreeze widths.
103
- - `split(): void` – (Re)build DOM (called internally by `init`/`reinit`/`updateOptions`).
104
- - `destroy(): void` – Detach observers, clear DOM, and mark unmounted.
105
- - `get metrics(): TextSlicerMetrics` – Read-only metrics collected on the last render.
89
+ Constructor: `new TextSlicer(options?, callbacks?)`.
106
90
 
107
- ### Options in detail
91
+ ## Options
108
92
 
109
- - `splitMode` `'words' | 'chars' | 'both'`. When `'both'`, each word is wrapped and further split into graphemes.
110
- - `cssVariables` – When `true`, indexes and totals are exposed as CSS custom properties for stagger animations.
111
- - `dataAttributes` When `true`, adds `data-word` / `data-char` attributes.
112
- - `keepWhitespaceNodes` When `true`, explicit whitespace nodes are inserted between words (class `ts-whitespace`).
113
- - `freezeWordWidths` When `true`, measured widths of `.ts-word` nodes are frozen (after fonts load + next frame) and
114
- kept in sync on container/window resize to prevent layout jitter during animations.
93
+ | Option | Type | Default | Description |
94
+ |:----------------------|:------------------------|:-----------:|:----------------------------------------------------------------------------|
95
+ | `container` | `HTMLElement \| string` | `undefined` | Target element or selector for text splitting. |
96
+ | `splitMode` | `'words' \| 'chars' \| 'both'` | `both` | Split by words, characters, or both. |
97
+ | `cssVariables` | `boolean` | `false` | Sets `--word-index`, `--char-index`, `--word-total`, `--char-total` on spans. |
98
+ | `dataAttributes` | `boolean` | `false` | Adds `data-word` and `data-char` attributes. |
99
+ | `keepWhitespaceNodes` | `boolean` | `true` | When `false`, whitespace nodes are ignored in character mode. |
100
+ | `containerHeightVar` | `boolean` | `false` | Sets dynamic `--container-height` via `ResizeObserver`. |
115
101
 
116
- ### i18n-friendly grapheme splitting
102
+ ## Callbacks
117
103
 
118
- Characters are split using `Intl.Segmenter` (when available) with `{ granularity: 'grapheme' }`, so compound emoji and
119
- grapheme clusters render as expected. Environments without `Intl.Segmenter` gracefully fall back to `Array.from(text)`.
104
+ | Callback | Arguments | Description |
105
+ |-----------------|---------------------|--------------------------------------------------------------------------|
106
+ | `onAfterRender` | `TextSlicerMetrics` | Called after render; provides `wordTotal`, `charTotal`, `renderedAt`. |
120
107
 
121
- ### Callbacks
108
+ ## Methods
122
109
 
123
- ```ts
124
- const slicer = new TextSlicer(
125
- { container: '.title', cssVariables: true },
126
- {
127
- onAfterRender(metrics) {
128
- // e.g. attach animation based on metrics.charTotal
129
- console.log(metrics);
130
- },
131
- }
132
- );
133
- slicer.init();
134
- ```
110
+ | Method | Description |
111
+ |--------------------------------|----------------------------------------------------------|
112
+ | `init()` | Initializes and renders text splitting. |
113
+ | `reinit(newText?, options?)` | Re-initializes with optional new text and updated options. |
114
+ | `clear()` | Clears all content inside the container. |
115
+ | `split()` | Manually triggers splitting and rendering. |
116
+ | `destroy()` | Cleans up instance, observers, and styles. |
117
+ | `updateOptions(options)` | Updates options at runtime; re-renders if mounted. |
118
+ | `lockHeight()` | Locks container height to its measured value. |
119
+ | `unlockHeight()` | Unlocks container height. |
120
+ | `metrics` (getter) | Returns `wordTotal`, `charTotal`, and `renderedAt`. |
135
121
 
136
- ### Responsive width freezing
122
+ ## Styling
137
123
 
138
- ```ts
139
- const slicer = new TextSlicer({
140
- container: '.headline',
141
- splitMode: 'both',
142
- freezeWordWidths: true,
143
- });
144
- slicer.init();
145
- ```
124
+ Exported `CLASSNAMES`:
146
125
 
147
- When enabled, widths are measured after fonts are ready and then frozen (`flex: 0 0 auto; width: <px>`). A `ResizeObserver`
148
- watches the container and a `resize` handler remeasures on viewport changes.
149
- <br>
150
-
151
- ## CSS usage example
152
-
153
- ```css
154
- .ts-char {
155
- display: inline-block;
156
- transform: translateY(0.75em);
157
- opacity: 0;
158
- transition: transform 400ms ease, opacity 400ms ease;
159
- }
160
-
161
- .ts-char.appear {
162
- transform: translateY(0);
163
- opacity: 1;
164
- }
165
-
166
- /* stagger via CSS variables */
167
- .ts-char {
168
- transition-delay: calc(var(--char-index, 0) * 10ms);
169
- }
170
- ```
171
- <br>
126
+ - `ts-word` word wrapper (when splitting includes words).
127
+ - `ts-char` character span.
128
+ - `ts-whitespace` — whitespace span.
129
+
130
+ When `cssVariables: true`, spans receive `--word-index`, `--char-index`; the container may receive `--word-total`, `--char-total`, and (with `containerHeightVar`) `--container-height`.
172
131
 
173
132
  ## License
174
133
 
package/dist/index.cjs ADDED
@@ -0,0 +1,212 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ //#region src/index.ts
4
+ const DEFAULT_OPTIONS = {
5
+ splitMode: "both",
6
+ cssVariables: false,
7
+ dataAttributes: false,
8
+ keepWhitespaceNodes: true,
9
+ containerHeightVar: false
10
+ };
11
+ const CLASSNAMES = Object.freeze({
12
+ word: "ts-word",
13
+ char: "ts-char",
14
+ whitespace: "ts-whitespace"
15
+ });
16
+ const SPACE = " ";
17
+ const CSS_VAR_WORD_TOTAL = "--word-total";
18
+ const CSS_VAR_CHAR_TOTAL = "--char-total";
19
+ const CSS_VAR_WORD_INDEX = "--word-index";
20
+ const CSS_VAR_CHAR_INDEX = "--char-index";
21
+ const CSS_VAR_CONTAINER_HEIGHT = "--container-height";
22
+ const MEASURING_CLASS = "ts-measuring";
23
+ const canUseDOM = () => typeof window !== "undefined" && typeof document !== "undefined";
24
+ const resolveContainer = (container) => {
25
+ if (!canUseDOM()) return null;
26
+ if (!container) return null;
27
+ return typeof container === "string" ? document.querySelector(container) : container;
28
+ };
29
+ const splitIntoGraphemes = (text) => {
30
+ const Seg = Intl.Segmenter;
31
+ if (typeof Seg === "function") {
32
+ const segmenter = new Seg("en", { granularity: "grapheme" });
33
+ return Array.from(segmenter.segment(text), (s) => s.segment);
34
+ }
35
+ return Array.from(text);
36
+ };
37
+ const splitIntoWords = (text) => text.split(SPACE);
38
+ const emptyElement = (el) => {
39
+ el.replaceChildren();
40
+ };
41
+ const isHTMLElement = (el) => !!el && typeof HTMLElement !== "undefined" && el instanceof HTMLElement;
42
+ const omitUndefined = (obj) => {
43
+ const out = {};
44
+ Object.keys(obj).forEach((key) => {
45
+ const val = obj[key];
46
+ if (val !== void 0) out[key] = val;
47
+ });
48
+ return out;
49
+ };
50
+ var TextSlicer = class {
51
+ el;
52
+ original;
53
+ opts;
54
+ callbacks;
55
+ charIndex;
56
+ mounted;
57
+ resizeObserver;
58
+ constructor(options = {}, callbacks) {
59
+ const el = resolveContainer(options.container);
60
+ this.el = el;
61
+ this.original = isHTMLElement(el) ? el.textContent?.toString() ?? "" : "";
62
+ this.opts = {
63
+ ...DEFAULT_OPTIONS,
64
+ ...omitUndefined(options)
65
+ };
66
+ this.callbacks = callbacks;
67
+ this.charIndex = 0;
68
+ this.mounted = false;
69
+ }
70
+ get metrics() {
71
+ const text = this.original;
72
+ return {
73
+ wordTotal: text.length ? splitIntoWords(text).length : 0,
74
+ charTotal: text.length,
75
+ renderedAt: Date.now()
76
+ };
77
+ }
78
+ init() {
79
+ if (!this.el) return;
80
+ this.mounted = true;
81
+ this.split();
82
+ if (this.opts.containerHeightVar) this.initHeightObserver();
83
+ }
84
+ reinit(newText, next) {
85
+ if (!this.el) return;
86
+ if (typeof newText === "string") this.original = newText;
87
+ if (next) this.opts = {
88
+ ...this.opts,
89
+ ...omitUndefined(next)
90
+ };
91
+ this.split();
92
+ }
93
+ clear() {
94
+ if (!this.el) return;
95
+ emptyElement(this.el);
96
+ }
97
+ split() {
98
+ if (!this.el) return;
99
+ this.clear();
100
+ this.charIndex = 0;
101
+ const text = this.original;
102
+ const fragment = document.createDocumentFragment();
103
+ const words = splitIntoWords(text);
104
+ if (this.opts.splitMode === "chars") this.appendChars(fragment, text);
105
+ else this.appendWords(fragment, words);
106
+ this.el.appendChild(fragment);
107
+ if (this.opts.cssVariables) {
108
+ this.el.style.setProperty(CSS_VAR_WORD_TOTAL, String(words.length));
109
+ this.el.style.setProperty(CSS_VAR_CHAR_TOTAL, String(text.length));
110
+ }
111
+ this.callbacks?.onAfterRender?.(this.metrics);
112
+ }
113
+ destroy() {
114
+ if (!this.el) return;
115
+ this.clear();
116
+ this.unlockHeight();
117
+ if (this.resizeObserver) {
118
+ this.resizeObserver.disconnect();
119
+ this.resizeObserver = void 0;
120
+ }
121
+ if (this.opts.containerHeightVar) this.el.style.removeProperty(CSS_VAR_CONTAINER_HEIGHT);
122
+ this.mounted = false;
123
+ }
124
+ updateOptions(next) {
125
+ this.opts = {
126
+ ...this.opts,
127
+ ...omitUndefined(next)
128
+ };
129
+ if (this.mounted) this.split();
130
+ }
131
+ lockHeight() {
132
+ if (!this.el) return;
133
+ const h = this.measureHeight();
134
+ if (h > 0) this.el.style.height = `${h}px`;
135
+ }
136
+ unlockHeight() {
137
+ if (!this.el) return;
138
+ this.el.style.removeProperty("height");
139
+ }
140
+ appendWords(fragment, words) {
141
+ words.forEach((word, wordIndex) => {
142
+ if (this.opts.splitMode === "both") {
143
+ const wordSpan = this.createWordSpan(wordIndex, word);
144
+ for (const ch of splitIntoGraphemes(word)) {
145
+ const charSpan = this.createCharSpan(ch);
146
+ wordSpan.append(charSpan);
147
+ }
148
+ fragment.append(wordSpan);
149
+ } else {
150
+ const wordSpan = this.createWordSpan(wordIndex);
151
+ wordSpan.append(document.createTextNode(word));
152
+ fragment.append(wordSpan);
153
+ }
154
+ if (wordIndex < words.length - 1) fragment.append(this.createSpaceSpan());
155
+ });
156
+ }
157
+ appendChars(fragment, text) {
158
+ for (const ch of splitIntoGraphemes(text)) {
159
+ const span = this.createCharSpan(ch);
160
+ fragment.append(span);
161
+ }
162
+ }
163
+ createWordSpan(index, word = "") {
164
+ const span = document.createElement("span");
165
+ span.classList.add(CLASSNAMES.word);
166
+ if (this.opts.dataAttributes && word) span.setAttribute("data-word", word);
167
+ if (this.opts.cssVariables) span.style.setProperty(CSS_VAR_WORD_INDEX, String(index));
168
+ return span;
169
+ }
170
+ createCharSpan(ch) {
171
+ const span = document.createElement("span");
172
+ span.textContent = ch;
173
+ if (this.opts.dataAttributes) span.setAttribute("data-char", ch);
174
+ if (ch === SPACE) {
175
+ span.classList.add(CLASSNAMES.whitespace);
176
+ if (!this.opts.keepWhitespaceNodes) span.textContent = SPACE;
177
+ } else {
178
+ span.classList.add(CLASSNAMES.char);
179
+ if (this.opts.cssVariables) span.style.setProperty(CSS_VAR_CHAR_INDEX, String(this.charIndex));
180
+ this.charIndex += 1;
181
+ }
182
+ return span;
183
+ }
184
+ createSpaceSpan() {
185
+ const span = document.createElement("span");
186
+ span.classList.add(CLASSNAMES.whitespace);
187
+ span.textContent = SPACE;
188
+ return span;
189
+ }
190
+ measureHeight() {
191
+ if (!this.el) return 0;
192
+ this.el.classList.add(MEASURING_CLASS);
193
+ this.el.offsetHeight;
194
+ let h = this.el.offsetHeight || this.el.clientHeight || 0;
195
+ if (!h) h = Math.round(this.el.getBoundingClientRect().height);
196
+ this.el.classList.remove(MEASURING_CLASS);
197
+ return Math.max(0, Math.ceil(h));
198
+ }
199
+ initHeightObserver() {
200
+ if (!this.el) return;
201
+ this.resizeObserver = new ResizeObserver(() => {
202
+ const h = this.measureHeight();
203
+ if (h > 0) this.el?.style.setProperty(CSS_VAR_CONTAINER_HEIGHT, `${h}px`);
204
+ });
205
+ this.resizeObserver.observe(this.el);
206
+ }
207
+ };
208
+
209
+ //#endregion
210
+ exports.CLASSNAMES = CLASSNAMES;
211
+ exports.TextSlicer = TextSlicer;
212
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["export type SplitMode = 'words' | 'chars' | 'both';\n\nexport interface TextSlicerOptions {\n container?: HTMLElement | string;\n splitMode?: SplitMode;\n cssVariables?: boolean;\n dataAttributes?: boolean;\n keepWhitespaceNodes?: boolean;\n containerHeightVar?: boolean;\n}\n\nexport interface TextSlicerMetrics {\n wordTotal: number;\n charTotal: number;\n renderedAt: number;\n}\n\nexport interface TextSlicerCallbacks {\n onAfterRender?: (metrics: TextSlicerMetrics) => void;\n}\n\ntype RuntimeOptions = Required<Omit<TextSlicerOptions, 'container'>>;\n\nconst DEFAULT_OPTIONS: RuntimeOptions = {\n splitMode: 'both',\n cssVariables: false,\n dataAttributes: false,\n keepWhitespaceNodes: true,\n containerHeightVar: false,\n};\n\nexport const CLASSNAMES = Object.freeze({\n word: 'ts-word',\n char: 'ts-char',\n whitespace: 'ts-whitespace',\n});\n\nconst SPACE = ' ';\nconst CSS_VAR_WORD_TOTAL = '--word-total';\nconst CSS_VAR_CHAR_TOTAL = '--char-total';\nconst CSS_VAR_WORD_INDEX = '--word-index';\nconst CSS_VAR_CHAR_INDEX = '--char-index';\nconst CSS_VAR_CONTAINER_HEIGHT = '--container-height';\nconst MEASURING_CLASS = 'ts-measuring';\n\nconst canUseDOM = (): boolean => typeof window !== 'undefined' && typeof document !== 'undefined';\n\nconst resolveContainer = (container?: HTMLElement | string): HTMLElement | null => {\n if (!canUseDOM()) return null;\n if (!container) return null;\n\n return typeof container === 'string' ? document.querySelector(container) : container;\n};\n\ntype IntlWithSegmenter = typeof Intl & {\n Segmenter?: new (locales?: string | string[], options?: Intl.SegmenterOptions) => Intl.Segmenter;\n};\n\nconst splitIntoGraphemes = (text: string): string[] => {\n const Seg = (Intl as IntlWithSegmenter).Segmenter;\n\n if (typeof Seg === 'function') {\n const segmenter = new Seg('en', { granularity: 'grapheme' });\n return Array.from(segmenter.segment(text), (s: Intl.SegmentData) => s.segment);\n }\n\n return Array.from(text);\n};\n\nconst splitIntoWords = (text: string): string[] => text.split(SPACE);\n\nconst emptyElement = (el: HTMLElement): void => {\n el.replaceChildren();\n};\n\nconst isHTMLElement = (el: unknown): el is HTMLElement =>\n !!el && typeof HTMLElement !== 'undefined' && el instanceof HTMLElement;\n\ntype NonUndefined<T> = T extends undefined ? never : T;\ntype OmitUndefined<T extends Record<string, unknown>> = {\n [K in keyof T as T[K] extends undefined ? never : K]: NonUndefined<T[K]>;\n};\n\nconst omitUndefined = <T extends Record<string, unknown>>(obj: T): OmitUndefined<T> => {\n const out = {} as OmitUndefined<T>;\n\n (Object.keys(obj) as Array<keyof T>).forEach((key) => {\n const val = obj[key];\n\n if (val !== undefined) {\n (out as Record<string, unknown>)[key as string] = val as unknown;\n }\n });\n\n return out;\n};\n\nexport class TextSlicer {\n private readonly el: HTMLElement | null;\n private original: string;\n private opts: RuntimeOptions;\n private callbacks: TextSlicerCallbacks | undefined;\n private charIndex: number;\n private mounted: boolean;\n private resizeObserver?: ResizeObserver;\n\n constructor(options: TextSlicerOptions = {}, callbacks?: TextSlicerCallbacks) {\n const el = resolveContainer(options.container);\n this.el = el;\n this.original = isHTMLElement(el) ? (el.textContent?.toString() ?? '') : '';\n this.opts = {\n ...DEFAULT_OPTIONS,\n ...omitUndefined<Omit<TextSlicerOptions, 'container'>>(options),\n } as RuntimeOptions;\n\n this.callbacks = callbacks;\n this.charIndex = 0;\n this.mounted = false;\n }\n\n get metrics(): TextSlicerMetrics {\n const text = this.original;\n\n return {\n wordTotal: text.length ? splitIntoWords(text).length : 0,\n charTotal: text.length,\n renderedAt: Date.now(),\n };\n }\n\n init(): void {\n if (!this.el) return;\n\n this.mounted = true;\n this.split();\n\n if (this.opts.containerHeightVar) {\n this.initHeightObserver();\n }\n }\n\n reinit(newText?: string, next?: Partial<TextSlicerOptions>): void {\n if (!this.el) return;\n if (typeof newText === 'string') this.original = newText;\n if (next) this.opts = { ...this.opts, ...omitUndefined(next) } as RuntimeOptions;\n\n this.split();\n }\n\n clear(): void {\n if (!this.el) return;\n\n emptyElement(this.el);\n }\n\n split(): void {\n if (!this.el) return;\n\n this.clear();\n this.charIndex = 0;\n\n const text = this.original;\n const fragment = document.createDocumentFragment();\n const words = splitIntoWords(text);\n\n if (this.opts.splitMode === 'chars') {\n this.appendChars(fragment, text);\n } else {\n this.appendWords(fragment, words);\n }\n\n this.el.appendChild(fragment);\n\n if (this.opts.cssVariables) {\n this.el.style.setProperty(CSS_VAR_WORD_TOTAL, String(words.length));\n this.el.style.setProperty(CSS_VAR_CHAR_TOTAL, String(text.length));\n }\n\n this.callbacks?.onAfterRender?.(this.metrics);\n }\n\n destroy(): void {\n if (!this.el) return;\n\n this.clear();\n this.unlockHeight();\n\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n this.resizeObserver = undefined;\n }\n\n if (this.opts.containerHeightVar) {\n this.el.style.removeProperty(CSS_VAR_CONTAINER_HEIGHT);\n }\n\n this.mounted = false;\n }\n\n updateOptions(next: Partial<TextSlicerOptions>): void {\n this.opts = { ...this.opts, ...omitUndefined(next) } as RuntimeOptions;\n\n if (this.mounted) this.split();\n }\n\n lockHeight(): void {\n if (!this.el) return;\n\n const h = this.measureHeight();\n\n if (h > 0) {\n this.el.style.height = `${h}px`;\n }\n }\n\n unlockHeight(): void {\n if (!this.el) return;\n\n this.el.style.removeProperty('height');\n }\n\n private appendWords(fragment: DocumentFragment, words: string[]): void {\n words.forEach((word, wordIndex) => {\n if (this.opts.splitMode === 'both') {\n const wordSpan = this.createWordSpan(wordIndex, word);\n\n for (const ch of splitIntoGraphemes(word)) {\n const charSpan = this.createCharSpan(ch);\n\n wordSpan.append(charSpan);\n }\n\n fragment.append(wordSpan);\n } else {\n const wordSpan = this.createWordSpan(wordIndex);\n\n wordSpan.append(document.createTextNode(word));\n fragment.append(wordSpan);\n }\n\n if (wordIndex < words.length - 1) {\n fragment.append(this.createSpaceSpan());\n }\n });\n }\n\n private appendChars(fragment: DocumentFragment, text: string): void {\n for (const ch of splitIntoGraphemes(text)) {\n const span = this.createCharSpan(ch);\n\n fragment.append(span);\n }\n }\n\n private createWordSpan(index: number, word: string = ''): HTMLSpanElement {\n const span = document.createElement('span');\n\n span.classList.add(CLASSNAMES.word);\n\n if (this.opts.dataAttributes && word) {\n span.setAttribute('data-word', word);\n }\n\n if (this.opts.cssVariables) {\n span.style.setProperty(CSS_VAR_WORD_INDEX, String(index));\n }\n\n return span;\n }\n\n private createCharSpan(ch: string): HTMLSpanElement {\n const span = document.createElement('span');\n\n span.textContent = ch;\n\n if (this.opts.dataAttributes) span.setAttribute('data-char', ch);\n\n if (ch === SPACE) {\n span.classList.add(CLASSNAMES.whitespace);\n\n if (!this.opts.keepWhitespaceNodes) span.textContent = SPACE;\n } else {\n span.classList.add(CLASSNAMES.char);\n\n if (this.opts.cssVariables) {\n span.style.setProperty(CSS_VAR_CHAR_INDEX, String(this.charIndex));\n }\n\n this.charIndex += 1;\n }\n\n return span;\n }\n\n private createSpaceSpan(): HTMLSpanElement {\n const span = document.createElement('span');\n\n span.classList.add(CLASSNAMES.whitespace);\n span.textContent = SPACE;\n\n return span;\n }\n\n private measureHeight(): number {\n if (!this.el) return 0;\n\n this.el.classList.add(MEASURING_CLASS);\n void this.el.offsetHeight;\n\n let h = this.el.offsetHeight || this.el.clientHeight || 0;\n\n if (!h) {\n h = Math.round(this.el.getBoundingClientRect().height);\n }\n\n this.el.classList.remove(MEASURING_CLASS);\n\n return Math.max(0, Math.ceil(h));\n }\n\n private initHeightObserver(): void {\n if (!this.el) return;\n\n this.resizeObserver = new ResizeObserver(() => {\n const h = this.measureHeight();\n\n if (h > 0) {\n this.el?.style.setProperty(CSS_VAR_CONTAINER_HEIGHT, `${h}px`);\n }\n });\n\n this.resizeObserver.observe(this.el);\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ //#region src/index.d.ts
2
+ type SplitMode = 'words' | 'chars' | 'both';
3
+ interface TextSlicerOptions {
4
+ container?: HTMLElement | string;
5
+ splitMode?: SplitMode;
6
+ cssVariables?: boolean;
7
+ dataAttributes?: boolean;
8
+ keepWhitespaceNodes?: boolean;
9
+ containerHeightVar?: boolean;
10
+ }
11
+ interface TextSlicerMetrics {
12
+ wordTotal: number;
13
+ charTotal: number;
14
+ renderedAt: number;
15
+ }
16
+ interface TextSlicerCallbacks {
17
+ onAfterRender?: (metrics: TextSlicerMetrics) => void;
18
+ }
19
+ declare const CLASSNAMES: Readonly<{
20
+ word: "ts-word";
21
+ char: "ts-char";
22
+ whitespace: "ts-whitespace";
23
+ }>;
24
+ declare class TextSlicer {
25
+ private readonly el;
26
+ private original;
27
+ private opts;
28
+ private callbacks;
29
+ private charIndex;
30
+ private mounted;
31
+ private resizeObserver?;
32
+ constructor(options?: TextSlicerOptions, callbacks?: TextSlicerCallbacks);
33
+ get metrics(): TextSlicerMetrics;
34
+ init(): void;
35
+ reinit(newText?: string, next?: Partial<TextSlicerOptions>): void;
36
+ clear(): void;
37
+ split(): void;
38
+ destroy(): void;
39
+ updateOptions(next: Partial<TextSlicerOptions>): void;
40
+ lockHeight(): void;
41
+ unlockHeight(): void;
42
+ private appendWords;
43
+ private appendChars;
44
+ private createWordSpan;
45
+ private createCharSpan;
46
+ private createSpaceSpan;
47
+ private measureHeight;
48
+ private initHeightObserver;
49
+ }
50
+ //#endregion
51
+ export { CLASSNAMES, SplitMode, TextSlicer, TextSlicerCallbacks, TextSlicerMetrics, TextSlicerOptions };
52
+ //# sourceMappingURL=index.d.cts.map
package/dist/index.d.ts CHANGED
@@ -1,49 +1,52 @@
1
- export type SplitMode = 'words' | 'chars' | 'both';
2
- export interface TextSlicerOptions {
3
- container?: HTMLElement | string;
4
- splitMode?: SplitMode;
5
- cssVariables?: boolean;
6
- dataAttributes?: boolean;
7
- keepWhitespaceNodes?: boolean;
8
- containerHeightVar?: boolean;
1
+ //#region src/index.d.ts
2
+ type SplitMode = 'words' | 'chars' | 'both';
3
+ interface TextSlicerOptions {
4
+ container?: HTMLElement | string;
5
+ splitMode?: SplitMode;
6
+ cssVariables?: boolean;
7
+ dataAttributes?: boolean;
8
+ keepWhitespaceNodes?: boolean;
9
+ containerHeightVar?: boolean;
9
10
  }
10
- export interface TextSlicerMetrics {
11
- wordTotal: number;
12
- charTotal: number;
13
- renderedAt: number;
11
+ interface TextSlicerMetrics {
12
+ wordTotal: number;
13
+ charTotal: number;
14
+ renderedAt: number;
14
15
  }
15
- export interface TextSlicerCallbacks {
16
- onAfterRender?: (metrics: TextSlicerMetrics) => void;
16
+ interface TextSlicerCallbacks {
17
+ onAfterRender?: (metrics: TextSlicerMetrics) => void;
17
18
  }
18
- export declare const CLASSNAMES: Readonly<{
19
- word: "ts-word";
20
- char: "ts-char";
21
- whitespace: "ts-whitespace";
19
+ declare const CLASSNAMES: Readonly<{
20
+ word: "ts-word";
21
+ char: "ts-char";
22
+ whitespace: "ts-whitespace";
22
23
  }>;
23
- export declare class TextSlicer {
24
- private readonly el;
25
- private original;
26
- private opts;
27
- private callbacks;
28
- private charIndex;
29
- private mounted;
30
- private heightLocked;
31
- private resizeObserver?;
32
- constructor(options?: TextSlicerOptions, callbacks?: TextSlicerCallbacks);
33
- get metrics(): TextSlicerMetrics;
34
- init(): void;
35
- reinit(newText?: string, next?: Partial<TextSlicerOptions>): void;
36
- clear(): void;
37
- split(): void;
38
- destroy(): void;
39
- updateOptions(next: Partial<TextSlicerOptions>): void;
40
- lockHeight(): void;
41
- unlockHeight(): void;
42
- private appendWords;
43
- private appendChars;
44
- private createWordSpan;
45
- private createCharSpan;
46
- private createSpaceSpan;
47
- private measureHeight;
48
- private initHeightObserver;
24
+ declare class TextSlicer {
25
+ private readonly el;
26
+ private original;
27
+ private opts;
28
+ private callbacks;
29
+ private charIndex;
30
+ private mounted;
31
+ private resizeObserver?;
32
+ constructor(options?: TextSlicerOptions, callbacks?: TextSlicerCallbacks);
33
+ get metrics(): TextSlicerMetrics;
34
+ init(): void;
35
+ reinit(newText?: string, next?: Partial<TextSlicerOptions>): void;
36
+ clear(): void;
37
+ split(): void;
38
+ destroy(): void;
39
+ updateOptions(next: Partial<TextSlicerOptions>): void;
40
+ lockHeight(): void;
41
+ unlockHeight(): void;
42
+ private appendWords;
43
+ private appendChars;
44
+ private createWordSpan;
45
+ private createCharSpan;
46
+ private createSpaceSpan;
47
+ private measureHeight;
48
+ private initHeightObserver;
49
49
  }
50
+ //#endregion
51
+ export { CLASSNAMES, SplitMode, TextSlicer, TextSlicerCallbacks, TextSlicerMetrics, TextSlicerOptions };
52
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,209 @@
1
+ //#region src/index.ts
2
+ const DEFAULT_OPTIONS = {
3
+ splitMode: "both",
4
+ cssVariables: false,
5
+ dataAttributes: false,
6
+ keepWhitespaceNodes: true,
7
+ containerHeightVar: false
8
+ };
9
+ const CLASSNAMES = Object.freeze({
10
+ word: "ts-word",
11
+ char: "ts-char",
12
+ whitespace: "ts-whitespace"
13
+ });
14
+ const SPACE = " ";
15
+ const CSS_VAR_WORD_TOTAL = "--word-total";
16
+ const CSS_VAR_CHAR_TOTAL = "--char-total";
17
+ const CSS_VAR_WORD_INDEX = "--word-index";
18
+ const CSS_VAR_CHAR_INDEX = "--char-index";
19
+ const CSS_VAR_CONTAINER_HEIGHT = "--container-height";
20
+ const MEASURING_CLASS = "ts-measuring";
21
+ const canUseDOM = () => typeof window !== "undefined" && typeof document !== "undefined";
22
+ const resolveContainer = (container) => {
23
+ if (!canUseDOM()) return null;
24
+ if (!container) return null;
25
+ return typeof container === "string" ? document.querySelector(container) : container;
26
+ };
27
+ const splitIntoGraphemes = (text) => {
28
+ const Seg = Intl.Segmenter;
29
+ if (typeof Seg === "function") {
30
+ const segmenter = new Seg("en", { granularity: "grapheme" });
31
+ return Array.from(segmenter.segment(text), (s) => s.segment);
32
+ }
33
+ return Array.from(text);
34
+ };
35
+ const splitIntoWords = (text) => text.split(SPACE);
36
+ const emptyElement = (el) => {
37
+ el.replaceChildren();
38
+ };
39
+ const isHTMLElement = (el) => !!el && typeof HTMLElement !== "undefined" && el instanceof HTMLElement;
40
+ const omitUndefined = (obj) => {
41
+ const out = {};
42
+ Object.keys(obj).forEach((key) => {
43
+ const val = obj[key];
44
+ if (val !== void 0) out[key] = val;
45
+ });
46
+ return out;
47
+ };
48
+ var TextSlicer = class {
49
+ el;
50
+ original;
51
+ opts;
52
+ callbacks;
53
+ charIndex;
54
+ mounted;
55
+ resizeObserver;
56
+ constructor(options = {}, callbacks) {
57
+ const el = resolveContainer(options.container);
58
+ this.el = el;
59
+ this.original = isHTMLElement(el) ? el.textContent?.toString() ?? "" : "";
60
+ this.opts = {
61
+ ...DEFAULT_OPTIONS,
62
+ ...omitUndefined(options)
63
+ };
64
+ this.callbacks = callbacks;
65
+ this.charIndex = 0;
66
+ this.mounted = false;
67
+ }
68
+ get metrics() {
69
+ const text = this.original;
70
+ return {
71
+ wordTotal: text.length ? splitIntoWords(text).length : 0,
72
+ charTotal: text.length,
73
+ renderedAt: Date.now()
74
+ };
75
+ }
76
+ init() {
77
+ if (!this.el) return;
78
+ this.mounted = true;
79
+ this.split();
80
+ if (this.opts.containerHeightVar) this.initHeightObserver();
81
+ }
82
+ reinit(newText, next) {
83
+ if (!this.el) return;
84
+ if (typeof newText === "string") this.original = newText;
85
+ if (next) this.opts = {
86
+ ...this.opts,
87
+ ...omitUndefined(next)
88
+ };
89
+ this.split();
90
+ }
91
+ clear() {
92
+ if (!this.el) return;
93
+ emptyElement(this.el);
94
+ }
95
+ split() {
96
+ if (!this.el) return;
97
+ this.clear();
98
+ this.charIndex = 0;
99
+ const text = this.original;
100
+ const fragment = document.createDocumentFragment();
101
+ const words = splitIntoWords(text);
102
+ if (this.opts.splitMode === "chars") this.appendChars(fragment, text);
103
+ else this.appendWords(fragment, words);
104
+ this.el.appendChild(fragment);
105
+ if (this.opts.cssVariables) {
106
+ this.el.style.setProperty(CSS_VAR_WORD_TOTAL, String(words.length));
107
+ this.el.style.setProperty(CSS_VAR_CHAR_TOTAL, String(text.length));
108
+ }
109
+ this.callbacks?.onAfterRender?.(this.metrics);
110
+ }
111
+ destroy() {
112
+ if (!this.el) return;
113
+ this.clear();
114
+ this.unlockHeight();
115
+ if (this.resizeObserver) {
116
+ this.resizeObserver.disconnect();
117
+ this.resizeObserver = void 0;
118
+ }
119
+ if (this.opts.containerHeightVar) this.el.style.removeProperty(CSS_VAR_CONTAINER_HEIGHT);
120
+ this.mounted = false;
121
+ }
122
+ updateOptions(next) {
123
+ this.opts = {
124
+ ...this.opts,
125
+ ...omitUndefined(next)
126
+ };
127
+ if (this.mounted) this.split();
128
+ }
129
+ lockHeight() {
130
+ if (!this.el) return;
131
+ const h = this.measureHeight();
132
+ if (h > 0) this.el.style.height = `${h}px`;
133
+ }
134
+ unlockHeight() {
135
+ if (!this.el) return;
136
+ this.el.style.removeProperty("height");
137
+ }
138
+ appendWords(fragment, words) {
139
+ words.forEach((word, wordIndex) => {
140
+ if (this.opts.splitMode === "both") {
141
+ const wordSpan = this.createWordSpan(wordIndex, word);
142
+ for (const ch of splitIntoGraphemes(word)) {
143
+ const charSpan = this.createCharSpan(ch);
144
+ wordSpan.append(charSpan);
145
+ }
146
+ fragment.append(wordSpan);
147
+ } else {
148
+ const wordSpan = this.createWordSpan(wordIndex);
149
+ wordSpan.append(document.createTextNode(word));
150
+ fragment.append(wordSpan);
151
+ }
152
+ if (wordIndex < words.length - 1) fragment.append(this.createSpaceSpan());
153
+ });
154
+ }
155
+ appendChars(fragment, text) {
156
+ for (const ch of splitIntoGraphemes(text)) {
157
+ const span = this.createCharSpan(ch);
158
+ fragment.append(span);
159
+ }
160
+ }
161
+ createWordSpan(index, word = "") {
162
+ const span = document.createElement("span");
163
+ span.classList.add(CLASSNAMES.word);
164
+ if (this.opts.dataAttributes && word) span.setAttribute("data-word", word);
165
+ if (this.opts.cssVariables) span.style.setProperty(CSS_VAR_WORD_INDEX, String(index));
166
+ return span;
167
+ }
168
+ createCharSpan(ch) {
169
+ const span = document.createElement("span");
170
+ span.textContent = ch;
171
+ if (this.opts.dataAttributes) span.setAttribute("data-char", ch);
172
+ if (ch === SPACE) {
173
+ span.classList.add(CLASSNAMES.whitespace);
174
+ if (!this.opts.keepWhitespaceNodes) span.textContent = SPACE;
175
+ } else {
176
+ span.classList.add(CLASSNAMES.char);
177
+ if (this.opts.cssVariables) span.style.setProperty(CSS_VAR_CHAR_INDEX, String(this.charIndex));
178
+ this.charIndex += 1;
179
+ }
180
+ return span;
181
+ }
182
+ createSpaceSpan() {
183
+ const span = document.createElement("span");
184
+ span.classList.add(CLASSNAMES.whitespace);
185
+ span.textContent = SPACE;
186
+ return span;
187
+ }
188
+ measureHeight() {
189
+ if (!this.el) return 0;
190
+ this.el.classList.add(MEASURING_CLASS);
191
+ this.el.offsetHeight;
192
+ let h = this.el.offsetHeight || this.el.clientHeight || 0;
193
+ if (!h) h = Math.round(this.el.getBoundingClientRect().height);
194
+ this.el.classList.remove(MEASURING_CLASS);
195
+ return Math.max(0, Math.ceil(h));
196
+ }
197
+ initHeightObserver() {
198
+ if (!this.el) return;
199
+ this.resizeObserver = new ResizeObserver(() => {
200
+ const h = this.measureHeight();
201
+ if (h > 0) this.el?.style.setProperty(CSS_VAR_CONTAINER_HEIGHT, `${h}px`);
202
+ });
203
+ this.resizeObserver.observe(this.el);
204
+ }
205
+ };
206
+
207
+ //#endregion
208
+ export { CLASSNAMES, TextSlicer };
209
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["export type SplitMode = 'words' | 'chars' | 'both';\n\nexport interface TextSlicerOptions {\n container?: HTMLElement | string;\n splitMode?: SplitMode;\n cssVariables?: boolean;\n dataAttributes?: boolean;\n keepWhitespaceNodes?: boolean;\n containerHeightVar?: boolean;\n}\n\nexport interface TextSlicerMetrics {\n wordTotal: number;\n charTotal: number;\n renderedAt: number;\n}\n\nexport interface TextSlicerCallbacks {\n onAfterRender?: (metrics: TextSlicerMetrics) => void;\n}\n\ntype RuntimeOptions = Required<Omit<TextSlicerOptions, 'container'>>;\n\nconst DEFAULT_OPTIONS: RuntimeOptions = {\n splitMode: 'both',\n cssVariables: false,\n dataAttributes: false,\n keepWhitespaceNodes: true,\n containerHeightVar: false,\n};\n\nexport const CLASSNAMES = Object.freeze({\n word: 'ts-word',\n char: 'ts-char',\n whitespace: 'ts-whitespace',\n});\n\nconst SPACE = ' ';\nconst CSS_VAR_WORD_TOTAL = '--word-total';\nconst CSS_VAR_CHAR_TOTAL = '--char-total';\nconst CSS_VAR_WORD_INDEX = '--word-index';\nconst CSS_VAR_CHAR_INDEX = '--char-index';\nconst CSS_VAR_CONTAINER_HEIGHT = '--container-height';\nconst MEASURING_CLASS = 'ts-measuring';\n\nconst canUseDOM = (): boolean => typeof window !== 'undefined' && typeof document !== 'undefined';\n\nconst resolveContainer = (container?: HTMLElement | string): HTMLElement | null => {\n if (!canUseDOM()) return null;\n if (!container) return null;\n\n return typeof container === 'string' ? document.querySelector(container) : container;\n};\n\ntype IntlWithSegmenter = typeof Intl & {\n Segmenter?: new (locales?: string | string[], options?: Intl.SegmenterOptions) => Intl.Segmenter;\n};\n\nconst splitIntoGraphemes = (text: string): string[] => {\n const Seg = (Intl as IntlWithSegmenter).Segmenter;\n\n if (typeof Seg === 'function') {\n const segmenter = new Seg('en', { granularity: 'grapheme' });\n return Array.from(segmenter.segment(text), (s: Intl.SegmentData) => s.segment);\n }\n\n return Array.from(text);\n};\n\nconst splitIntoWords = (text: string): string[] => text.split(SPACE);\n\nconst emptyElement = (el: HTMLElement): void => {\n el.replaceChildren();\n};\n\nconst isHTMLElement = (el: unknown): el is HTMLElement =>\n !!el && typeof HTMLElement !== 'undefined' && el instanceof HTMLElement;\n\ntype NonUndefined<T> = T extends undefined ? never : T;\ntype OmitUndefined<T extends Record<string, unknown>> = {\n [K in keyof T as T[K] extends undefined ? never : K]: NonUndefined<T[K]>;\n};\n\nconst omitUndefined = <T extends Record<string, unknown>>(obj: T): OmitUndefined<T> => {\n const out = {} as OmitUndefined<T>;\n\n (Object.keys(obj) as Array<keyof T>).forEach((key) => {\n const val = obj[key];\n\n if (val !== undefined) {\n (out as Record<string, unknown>)[key as string] = val as unknown;\n }\n });\n\n return out;\n};\n\nexport class TextSlicer {\n private readonly el: HTMLElement | null;\n private original: string;\n private opts: RuntimeOptions;\n private callbacks: TextSlicerCallbacks | undefined;\n private charIndex: number;\n private mounted: boolean;\n private resizeObserver?: ResizeObserver;\n\n constructor(options: TextSlicerOptions = {}, callbacks?: TextSlicerCallbacks) {\n const el = resolveContainer(options.container);\n this.el = el;\n this.original = isHTMLElement(el) ? (el.textContent?.toString() ?? '') : '';\n this.opts = {\n ...DEFAULT_OPTIONS,\n ...omitUndefined<Omit<TextSlicerOptions, 'container'>>(options),\n } as RuntimeOptions;\n\n this.callbacks = callbacks;\n this.charIndex = 0;\n this.mounted = false;\n }\n\n get metrics(): TextSlicerMetrics {\n const text = this.original;\n\n return {\n wordTotal: text.length ? splitIntoWords(text).length : 0,\n charTotal: text.length,\n renderedAt: Date.now(),\n };\n }\n\n init(): void {\n if (!this.el) return;\n\n this.mounted = true;\n this.split();\n\n if (this.opts.containerHeightVar) {\n this.initHeightObserver();\n }\n }\n\n reinit(newText?: string, next?: Partial<TextSlicerOptions>): void {\n if (!this.el) return;\n if (typeof newText === 'string') this.original = newText;\n if (next) this.opts = { ...this.opts, ...omitUndefined(next) } as RuntimeOptions;\n\n this.split();\n }\n\n clear(): void {\n if (!this.el) return;\n\n emptyElement(this.el);\n }\n\n split(): void {\n if (!this.el) return;\n\n this.clear();\n this.charIndex = 0;\n\n const text = this.original;\n const fragment = document.createDocumentFragment();\n const words = splitIntoWords(text);\n\n if (this.opts.splitMode === 'chars') {\n this.appendChars(fragment, text);\n } else {\n this.appendWords(fragment, words);\n }\n\n this.el.appendChild(fragment);\n\n if (this.opts.cssVariables) {\n this.el.style.setProperty(CSS_VAR_WORD_TOTAL, String(words.length));\n this.el.style.setProperty(CSS_VAR_CHAR_TOTAL, String(text.length));\n }\n\n this.callbacks?.onAfterRender?.(this.metrics);\n }\n\n destroy(): void {\n if (!this.el) return;\n\n this.clear();\n this.unlockHeight();\n\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n this.resizeObserver = undefined;\n }\n\n if (this.opts.containerHeightVar) {\n this.el.style.removeProperty(CSS_VAR_CONTAINER_HEIGHT);\n }\n\n this.mounted = false;\n }\n\n updateOptions(next: Partial<TextSlicerOptions>): void {\n this.opts = { ...this.opts, ...omitUndefined(next) } as RuntimeOptions;\n\n if (this.mounted) this.split();\n }\n\n lockHeight(): void {\n if (!this.el) return;\n\n const h = this.measureHeight();\n\n if (h > 0) {\n this.el.style.height = `${h}px`;\n }\n }\n\n unlockHeight(): void {\n if (!this.el) return;\n\n this.el.style.removeProperty('height');\n }\n\n private appendWords(fragment: DocumentFragment, words: string[]): void {\n words.forEach((word, wordIndex) => {\n if (this.opts.splitMode === 'both') {\n const wordSpan = this.createWordSpan(wordIndex, word);\n\n for (const ch of splitIntoGraphemes(word)) {\n const charSpan = this.createCharSpan(ch);\n\n wordSpan.append(charSpan);\n }\n\n fragment.append(wordSpan);\n } else {\n const wordSpan = this.createWordSpan(wordIndex);\n\n wordSpan.append(document.createTextNode(word));\n fragment.append(wordSpan);\n }\n\n if (wordIndex < words.length - 1) {\n fragment.append(this.createSpaceSpan());\n }\n });\n }\n\n private appendChars(fragment: DocumentFragment, text: string): void {\n for (const ch of splitIntoGraphemes(text)) {\n const span = this.createCharSpan(ch);\n\n fragment.append(span);\n }\n }\n\n private createWordSpan(index: number, word: string = ''): HTMLSpanElement {\n const span = document.createElement('span');\n\n span.classList.add(CLASSNAMES.word);\n\n if (this.opts.dataAttributes && word) {\n span.setAttribute('data-word', word);\n }\n\n if (this.opts.cssVariables) {\n span.style.setProperty(CSS_VAR_WORD_INDEX, String(index));\n }\n\n return span;\n }\n\n private createCharSpan(ch: string): HTMLSpanElement {\n const span = document.createElement('span');\n\n span.textContent = ch;\n\n if (this.opts.dataAttributes) span.setAttribute('data-char', ch);\n\n if (ch === SPACE) {\n span.classList.add(CLASSNAMES.whitespace);\n\n if (!this.opts.keepWhitespaceNodes) span.textContent = SPACE;\n } else {\n span.classList.add(CLASSNAMES.char);\n\n if (this.opts.cssVariables) {\n span.style.setProperty(CSS_VAR_CHAR_INDEX, String(this.charIndex));\n }\n\n this.charIndex += 1;\n }\n\n return span;\n }\n\n private createSpaceSpan(): HTMLSpanElement {\n const span = document.createElement('span');\n\n span.classList.add(CLASSNAMES.whitespace);\n span.textContent = SPACE;\n\n return span;\n }\n\n private measureHeight(): number {\n if (!this.el) return 0;\n\n this.el.classList.add(MEASURING_CLASS);\n void this.el.offsetHeight;\n\n let h = this.el.offsetHeight || this.el.clientHeight || 0;\n\n if (!h) {\n h = Math.round(this.el.getBoundingClientRect().height);\n }\n\n this.el.classList.remove(MEASURING_CLASS);\n\n return Math.max(0, Math.ceil(h));\n }\n\n private initHeightObserver(): void {\n if (!this.el) return;\n\n this.resizeObserver = new ResizeObserver(() => {\n const h = this.measureHeight();\n\n if (h > 0) {\n this.el?.style.setProperty(CSS_VAR_CONTAINER_HEIGHT, `${h}px`);\n }\n });\n\n this.resizeObserver.observe(this.el);\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "text-slicer",
3
- "version": "1.5.0-dev.5",
3
+ "version": "2.0.0",
4
4
  "description": "TextSlicer is designed to split text within an HTML element into separate words and/or characters, wrapping each word and/or character in separate span elements.",
5
5
  "author": "ux-ui.pro",
6
6
  "license": "MIT",
@@ -13,46 +13,58 @@
13
13
  },
14
14
  "homepage": "https://github.com/ux-ui-pro/text-slicer",
15
15
  "sideEffects": false,
16
+ "type": "module",
17
+ "packageManager": "npm@10.9.3",
18
+ "engines": {
19
+ "node": ">=20.19.0"
20
+ },
16
21
  "scripts": {
17
22
  "clean": "rimraf dist",
18
- "build": "vite build",
19
- "lint:ts": "eslint src/**/*.ts",
20
- "lint:fix:ts": "eslint src/**/*.ts --fix",
21
- "format:ts": "prettier --write src/**/*.ts",
22
- "lint:fix": "yarn lint:fix:ts && yarn format:ts"
23
+ "build": "tsdown",
24
+ "verify": "npm run lint && npm run typecheck && npm run build && npm run test:smoke && npm run pack:check",
25
+ "lint": "biome check src tests tsdown.config.ts",
26
+ "lint:fix": "biome check --write src tests tsdown.config.ts",
27
+ "format": "biome format --write src tests tsdown.config.ts",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
29
+ "test:smoke": "node --test \"tests/**/*.js\"",
30
+ "pack:check": "npm pack --dry-run && publint && attw --pack . --profile node16",
31
+ "prepublishOnly": "npm run clean && npm run verify"
23
32
  },
24
33
  "source": "src/index.ts",
25
- "main": "dist/index.cjs.js",
26
- "module": "dist/index.es.js",
27
- "browser": "./dist/index.umd.js",
28
- "types": "dist/index.d.ts",
34
+ "main": "./dist/index.cjs",
35
+ "module": "./dist/index.js",
36
+ "types": "./dist/index.d.ts",
29
37
  "exports": {
30
38
  ".": {
31
- "require": "./dist/index.cjs.js",
32
- "import": "./dist/index.es.js",
33
- "default": "./dist/index.umd.js"
34
- },
35
- "./dist/*": "./dist/*"
39
+ "import": {
40
+ "types": "./dist/index.d.ts",
41
+ "default": "./dist/index.js"
42
+ },
43
+ "require": {
44
+ "types": "./dist/index.d.cts",
45
+ "default": "./dist/index.cjs"
46
+ }
47
+ }
36
48
  },
37
49
  "files": [
38
- "dist/"
50
+ "dist",
51
+ "README.md",
52
+ "LICENSE"
39
53
  ],
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
40
57
  "devDependencies": {
41
- "@eslint/js": "9.34.0",
42
- "@rollup/plugin-terser": "0.4.4",
43
- "@types/node": "24.3.0",
44
- "@typescript-eslint/eslint-plugin": "8.40.0",
45
- "@typescript-eslint/parser": "8.40.0",
46
- "eslint": "9.34.0",
47
- "eslint-config-prettier": "10.1.8",
48
- "eslint-import-resolver-typescript": "4.4.4",
49
- "eslint-plugin-import": "2.32.0",
50
- "globals": "16.3.0",
51
- "prettier": "3.6.2",
52
- "rimraf": "6.0.1",
53
- "typescript": "5.9.2",
54
- "vite": "7.1.3",
55
- "vite-plugin-dts": "4.5.4"
58
+ "@arethetypeswrong/cli": "^0.18.3",
59
+ "@biomejs/biome": "2.4.16",
60
+ "@types/node": "25.9.2",
61
+ "@ux-ui/biome-config": "^0.1.0",
62
+ "@ux-ui/tsconfig-base": "^0.1.0",
63
+ "@ux-ui/tsdown-config": "^0.1.0",
64
+ "publint": "^0.3.21",
65
+ "rimraf": "6.1.3",
66
+ "tsdown": "0.22.3",
67
+ "typescript": "6.0.3"
56
68
  },
57
69
  "keywords": [
58
70
  "text",
package/dist/index.cjs.js DELETED
@@ -1 +0,0 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u={splitMode:"both",cssVariables:!1,dataAttributes:!1,keepWhitespaceNodes:!0,containerHeightVar:!1},n=Object.freeze({word:"ts-word",char:"ts-char",whitespace:"ts-whitespace"}),h=" ",a="--container-height",c="ts-measuring",m=t=>typeof window>"u"||typeof document>"u"?null:t?typeof t=="string"?document.querySelector(t):t:null,l=t=>{const e=Intl.Segmenter;if(typeof e=="function"){const s=new e("en",{granularity:"grapheme"});return Array.from(s.segment(t),i=>i.segment)}return Array.from(t)},d=t=>t.split(h),o=t=>{const e={};return Object.keys(t).forEach(s=>{const i=t[s];i!==void 0&&(e[s]=i)}),e};exports.CLASSNAMES=n,exports.TextSlicer=class{el;original;opts;callbacks;charIndex;mounted;heightLocked;resizeObserver;constructor(t={},e){const s=m(t.container);this.el=s,this.original=(i=>!!i&&typeof HTMLElement<"u"&&i instanceof HTMLElement)(s)?s.textContent?.toString()??"":"",this.opts={...u,...o(t)},this.callbacks=e,this.charIndex=0,this.mounted=!1,this.heightLocked=!1}get metrics(){const t=this.original;return{wordTotal:t.length?d(t).length:0,charTotal:t.length,renderedAt:Date.now()}}init(){this.el&&(this.mounted=!0,this.split(),this.opts.containerHeightVar&&this.initHeightObserver())}reinit(t,e){this.el&&(typeof t=="string"&&(this.original=t),e&&(this.opts={...this.opts,...o(e)}),this.split())}clear(){this.el&&this.el.replaceChildren()}split(){if(!this.el)return;this.clear(),this.charIndex=0;const t=this.original,e=document.createDocumentFragment(),s=d(t);this.opts.splitMode==="chars"?this.appendChars(e,t):this.appendWords(e,s),this.el.appendChild(e),this.opts.cssVariables&&(this.el.style.setProperty("--word-total",String(s.length)),this.el.style.setProperty("--char-total",String(t.length))),this.callbacks?.onAfterRender?.(this.metrics)}destroy(){this.el&&(this.clear(),this.unlockHeight(),this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=void 0),this.opts.containerHeightVar&&this.el.style.removeProperty(a),this.mounted=!1)}updateOptions(t){this.opts={...this.opts,...o(t)},this.mounted&&this.split()}lockHeight(){if(!this.el)return;const t=this.measureHeight();t>0&&(this.el.style.height=`${t}px`,this.heightLocked=!0)}unlockHeight(){this.el&&(this.el.style.removeProperty("height"),this.heightLocked=!1)}appendWords(t,e){e.forEach((s,i)=>{if(this.opts.splitMode==="both"){const r=this.createWordSpan(i,s);for(const p of l(s)){const g=this.createCharSpan(p);r.append(g)}t.append(r)}else{const r=this.createWordSpan(i);r.append(document.createTextNode(s)),t.append(r)}i<e.length-1&&t.append(this.createSpaceSpan())})}appendChars(t,e){for(const s of l(e)){const i=this.createCharSpan(s);t.append(i)}}createWordSpan(t,e=""){const s=document.createElement("span");return s.classList.add(n.word),this.opts.dataAttributes&&e&&s.setAttribute("data-word",e),this.opts.cssVariables&&s.style.setProperty("--word-index",String(t)),s}createCharSpan(t){const e=document.createElement("span");return e.textContent=t,this.opts.dataAttributes&&e.setAttribute("data-char",t),t===h?(e.classList.add(n.whitespace),this.opts.keepWhitespaceNodes||(e.textContent=h)):(e.classList.add(n.char),this.opts.cssVariables&&e.style.setProperty("--char-index",String(this.charIndex)),this.charIndex+=1),e}createSpaceSpan(){const t=document.createElement("span");return t.classList.add(n.whitespace),t.textContent=h,t}measureHeight(){if(!this.el)return 0;this.el.classList.add(c),this.el.offsetHeight;let t=this.el.offsetHeight||this.el.clientHeight||0;return t||(t=Math.round(this.el.getBoundingClientRect().height)),this.el.classList.remove(c),Math.max(0,Math.ceil(t))}initHeightObserver(){this.el&&(this.resizeObserver=new ResizeObserver(()=>{const t=this.measureHeight();t>0&&this.el.style.setProperty(a,`${t}px`)}),this.resizeObserver.observe(this.el))}};
package/dist/index.es.js DELETED
@@ -1,111 +0,0 @@
1
- const m = { splitMode: "both", cssVariables: !1, dataAttributes: !1, keepWhitespaceNodes: !0, containerHeightVar: !1 }, h = Object.freeze({ word: "ts-word", char: "ts-char", whitespace: "ts-whitespace" }), a = " ", c = "--container-height", l = "ts-measuring", f = (i) => typeof window > "u" || typeof document > "u" ? null : i ? typeof i == "string" ? document.querySelector(i) : i : null, p = (i) => {
2
- const t = Intl.Segmenter;
3
- if (typeof t == "function") {
4
- const e = new t("en", { granularity: "grapheme" });
5
- return Array.from(e.segment(i), (s) => s.segment);
6
- }
7
- return Array.from(i);
8
- }, d = (i) => i.split(a), o = (i) => {
9
- const t = {};
10
- return Object.keys(i).forEach((e) => {
11
- const s = i[e];
12
- s !== void 0 && (t[e] = s);
13
- }), t;
14
- };
15
- class y {
16
- el;
17
- original;
18
- opts;
19
- callbacks;
20
- charIndex;
21
- mounted;
22
- heightLocked;
23
- resizeObserver;
24
- constructor(t = {}, e) {
25
- const s = f(t.container);
26
- this.el = s, this.original = ((r) => !!r && typeof HTMLElement < "u" && r instanceof HTMLElement)(s) ? s.textContent?.toString() ?? "" : "", this.opts = { ...m, ...o(t) }, this.callbacks = e, this.charIndex = 0, this.mounted = !1, this.heightLocked = !1;
27
- }
28
- get metrics() {
29
- const t = this.original;
30
- return { wordTotal: t.length ? d(t).length : 0, charTotal: t.length, renderedAt: Date.now() };
31
- }
32
- init() {
33
- this.el && (this.mounted = !0, this.split(), this.opts.containerHeightVar && this.initHeightObserver());
34
- }
35
- reinit(t, e) {
36
- this.el && (typeof t == "string" && (this.original = t), e && (this.opts = { ...this.opts, ...o(e) }), this.split());
37
- }
38
- clear() {
39
- this.el && this.el.replaceChildren();
40
- }
41
- split() {
42
- if (!this.el) return;
43
- this.clear(), this.charIndex = 0;
44
- const t = this.original, e = document.createDocumentFragment(), s = d(t);
45
- this.opts.splitMode === "chars" ? this.appendChars(e, t) : this.appendWords(e, s), this.el.appendChild(e), this.opts.cssVariables && (this.el.style.setProperty("--word-total", String(s.length)), this.el.style.setProperty("--char-total", String(t.length))), this.callbacks?.onAfterRender?.(this.metrics);
46
- }
47
- destroy() {
48
- this.el && (this.clear(), this.unlockHeight(), this.resizeObserver && (this.resizeObserver.disconnect(), this.resizeObserver = void 0), this.opts.containerHeightVar && this.el.style.removeProperty(c), this.mounted = !1);
49
- }
50
- updateOptions(t) {
51
- this.opts = { ...this.opts, ...o(t) }, this.mounted && this.split();
52
- }
53
- lockHeight() {
54
- if (!this.el) return;
55
- const t = this.measureHeight();
56
- t > 0 && (this.el.style.height = `${t}px`, this.heightLocked = !0);
57
- }
58
- unlockHeight() {
59
- this.el && (this.el.style.removeProperty("height"), this.heightLocked = !1);
60
- }
61
- appendWords(t, e) {
62
- e.forEach((s, r) => {
63
- if (this.opts.splitMode === "both") {
64
- const n = this.createWordSpan(r, s);
65
- for (const g of p(s)) {
66
- const u = this.createCharSpan(g);
67
- n.append(u);
68
- }
69
- t.append(n);
70
- } else {
71
- const n = this.createWordSpan(r);
72
- n.append(document.createTextNode(s)), t.append(n);
73
- }
74
- r < e.length - 1 && t.append(this.createSpaceSpan());
75
- });
76
- }
77
- appendChars(t, e) {
78
- for (const s of p(e)) {
79
- const r = this.createCharSpan(s);
80
- t.append(r);
81
- }
82
- }
83
- createWordSpan(t, e = "") {
84
- const s = document.createElement("span");
85
- return s.classList.add(h.word), this.opts.dataAttributes && e && s.setAttribute("data-word", e), this.opts.cssVariables && s.style.setProperty("--word-index", String(t)), s;
86
- }
87
- createCharSpan(t) {
88
- const e = document.createElement("span");
89
- return e.textContent = t, this.opts.dataAttributes && e.setAttribute("data-char", t), t === a ? (e.classList.add(h.whitespace), this.opts.keepWhitespaceNodes || (e.textContent = a)) : (e.classList.add(h.char), this.opts.cssVariables && e.style.setProperty("--char-index", String(this.charIndex)), this.charIndex += 1), e;
90
- }
91
- createSpaceSpan() {
92
- const t = document.createElement("span");
93
- return t.classList.add(h.whitespace), t.textContent = a, t;
94
- }
95
- measureHeight() {
96
- if (!this.el) return 0;
97
- this.el.classList.add(l), this.el.offsetHeight;
98
- let t = this.el.offsetHeight || this.el.clientHeight || 0;
99
- return t || (t = Math.round(this.el.getBoundingClientRect().height)), this.el.classList.remove(l), Math.max(0, Math.ceil(t));
100
- }
101
- initHeightObserver() {
102
- this.el && (this.resizeObserver = new ResizeObserver(() => {
103
- const t = this.measureHeight();
104
- t > 0 && this.el.style.setProperty(c, `${t}px`);
105
- }), this.resizeObserver.observe(this.el));
106
- }
107
- }
108
- export {
109
- h as CLASSNAMES,
110
- y as TextSlicer
111
- };
package/dist/index.umd.js DELETED
@@ -1 +0,0 @@
1
- (function(r,n){typeof exports=="object"&&typeof module<"u"?n(exports):typeof define=="function"&&define.amd?define(["exports"],n):n((r=typeof globalThis<"u"?globalThis:r||self).TextSlicer={})})(this,function(r){"use strict";const n={splitMode:"both",cssVariables:!1,dataAttributes:!1,keepWhitespaceNodes:!0,containerHeightVar:!1},o=Object.freeze({word:"ts-word",char:"ts-char",whitespace:"ts-whitespace"}),a=" ",l="--container-height",d="ts-measuring",g=e=>typeof window>"u"||typeof document>"u"?null:e?typeof e=="string"?document.querySelector(e):e:null,p=e=>{const t=Intl.Segmenter;if(typeof t=="function"){const s=new t("en",{granularity:"grapheme"});return Array.from(s.segment(e),i=>i.segment)}return Array.from(e)},u=e=>e.split(a),c=e=>{const t={};return Object.keys(e).forEach(s=>{const i=e[s];i!==void 0&&(t[s]=i)}),t};r.CLASSNAMES=o,r.TextSlicer=class{el;original;opts;callbacks;charIndex;mounted;heightLocked;resizeObserver;constructor(e={},t){const s=g(e.container);this.el=s,this.original=(i=>!!i&&typeof HTMLElement<"u"&&i instanceof HTMLElement)(s)?s.textContent?.toString()??"":"",this.opts={...n,...c(e)},this.callbacks=t,this.charIndex=0,this.mounted=!1,this.heightLocked=!1}get metrics(){const e=this.original;return{wordTotal:e.length?u(e).length:0,charTotal:e.length,renderedAt:Date.now()}}init(){this.el&&(this.mounted=!0,this.split(),this.opts.containerHeightVar&&this.initHeightObserver())}reinit(e,t){this.el&&(typeof e=="string"&&(this.original=e),t&&(this.opts={...this.opts,...c(t)}),this.split())}clear(){this.el&&this.el.replaceChildren()}split(){if(!this.el)return;this.clear(),this.charIndex=0;const e=this.original,t=document.createDocumentFragment(),s=u(e);this.opts.splitMode==="chars"?this.appendChars(t,e):this.appendWords(t,s),this.el.appendChild(t),this.opts.cssVariables&&(this.el.style.setProperty("--word-total",String(s.length)),this.el.style.setProperty("--char-total",String(e.length))),this.callbacks?.onAfterRender?.(this.metrics)}destroy(){this.el&&(this.clear(),this.unlockHeight(),this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=void 0),this.opts.containerHeightVar&&this.el.style.removeProperty(l),this.mounted=!1)}updateOptions(e){this.opts={...this.opts,...c(e)},this.mounted&&this.split()}lockHeight(){if(!this.el)return;const e=this.measureHeight();e>0&&(this.el.style.height=`${e}px`,this.heightLocked=!0)}unlockHeight(){this.el&&(this.el.style.removeProperty("height"),this.heightLocked=!1)}appendWords(e,t){t.forEach((s,i)=>{if(this.opts.splitMode==="both"){const h=this.createWordSpan(i,s);for(const f of p(s)){const m=this.createCharSpan(f);h.append(m)}e.append(h)}else{const h=this.createWordSpan(i);h.append(document.createTextNode(s)),e.append(h)}i<t.length-1&&e.append(this.createSpaceSpan())})}appendChars(e,t){for(const s of p(t)){const i=this.createCharSpan(s);e.append(i)}}createWordSpan(e,t=""){const s=document.createElement("span");return s.classList.add(o.word),this.opts.dataAttributes&&t&&s.setAttribute("data-word",t),this.opts.cssVariables&&s.style.setProperty("--word-index",String(e)),s}createCharSpan(e){const t=document.createElement("span");return t.textContent=e,this.opts.dataAttributes&&t.setAttribute("data-char",e),e===a?(t.classList.add(o.whitespace),this.opts.keepWhitespaceNodes||(t.textContent=a)):(t.classList.add(o.char),this.opts.cssVariables&&t.style.setProperty("--char-index",String(this.charIndex)),this.charIndex+=1),t}createSpaceSpan(){const e=document.createElement("span");return e.classList.add(o.whitespace),e.textContent=a,e}measureHeight(){if(!this.el)return 0;this.el.classList.add(d),this.el.offsetHeight;let e=this.el.offsetHeight||this.el.clientHeight||0;return e||(e=Math.round(this.el.getBoundingClientRect().height)),this.el.classList.remove(d),Math.max(0,Math.ceil(e))}initHeightObserver(){this.el&&(this.resizeObserver=new ResizeObserver(()=>{const e=this.measureHeight();e>0&&this.el.style.setProperty(l,`${e}px`)}),this.resizeObserver.observe(this.el))}},Object.defineProperty(r,Symbol.toStringTag,{value:"Module"})});