text-slicer 2.0.0 → 2.1.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 +174 -40
- package/dist/index.cjs +200 -82
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -4
- package/dist/index.d.ts +17 -4
- package/dist/index.js +200 -82
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# text-slicer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Create word and grapheme-level DOM hooks from plain text for animation and styling.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/text-slicer)
|
|
6
6
|
[](https://www.npmjs.com/package/text-slicer)
|
|
@@ -9,12 +9,14 @@ Split text inside an HTML element into words and/or characters, wrapping each fr
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- Split short plain text into words, graphemes, or both.
|
|
15
|
+
- Use locale-aware word and grapheme segmentation with `Intl.Segmenter`.
|
|
16
|
+
- Add CSS hooks through `--word-index`, `--char-index`, `--word-total`, and `--char-total`.
|
|
17
|
+
- Add optional `data-word` and `data-char` attributes when scripting needs the raw text.
|
|
18
|
+
- Clean up with lifecycle methods such as `destroy()`, `reinit()`, `lockHeight()`, and `unlockHeight()`.
|
|
19
|
+
- Read render metrics with `onAfterRender`.
|
|
18
20
|
|
|
19
21
|
---
|
|
20
22
|
|
|
@@ -24,6 +26,8 @@ Split text inside an HTML element into words and/or characters, wrapping each fr
|
|
|
24
26
|
npm install text-slicer
|
|
25
27
|
```
|
|
26
28
|
|
|
29
|
+
---
|
|
30
|
+
|
|
27
31
|
## Quick Start
|
|
28
32
|
|
|
29
33
|
HTML:
|
|
@@ -66,68 +70,198 @@ Apply to every matching element on the page:
|
|
|
66
70
|
|
|
67
71
|
```ts
|
|
68
72
|
document.addEventListener('DOMContentLoaded', () => {
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
const textSlicers = Array.from(
|
|
74
|
+
document.querySelectorAll<HTMLElement>('.text-slicer'),
|
|
75
|
+
(container) => {
|
|
76
|
+
const textSlicer = new TextSlicer({ container });
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
textSlicer.init();
|
|
79
|
+
|
|
80
|
+
return textSlicer;
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Later, before removing the elements, replacing this section,
|
|
85
|
+
// or cleaning up during SPA route changes:
|
|
86
|
+
const destroyTextSlicers = () => {
|
|
87
|
+
textSlicers.forEach((textSlicer) => textSlicer.destroy());
|
|
88
|
+
};
|
|
74
89
|
});
|
|
75
90
|
```
|
|
76
91
|
|
|
77
|
-
|
|
92
|
+
---
|
|
78
93
|
|
|
79
|
-
|
|
94
|
+
## API
|
|
80
95
|
|
|
81
96
|
```ts
|
|
82
97
|
import { TextSlicer, CLASSNAMES } from 'text-slicer';
|
|
83
98
|
```
|
|
84
99
|
|
|
85
|
-
- `TextSlicer` — main class.
|
|
86
|
-
- `CLASSNAMES` —
|
|
100
|
+
- `TextSlicer` — the main class.
|
|
101
|
+
- `CLASSNAMES` — class map for `word`, `char`, and `whitespace`.
|
|
87
102
|
- Types: `SplitMode`, `TextSlicerOptions`, `TextSlicerMetrics`, `TextSlicerCallbacks`.
|
|
88
103
|
|
|
89
|
-
Constructor:
|
|
104
|
+
Constructor:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
new TextSlicer(options?, callbacks?);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
90
111
|
|
|
91
112
|
## Options
|
|
92
113
|
|
|
93
|
-
| Option
|
|
94
|
-
|
|
95
|
-
| `container`
|
|
96
|
-
| `splitMode`
|
|
97
|
-
| `cssVariables`
|
|
98
|
-
| `dataAttributes`
|
|
99
|
-
| `keepWhitespaceNodes` | `boolean`
|
|
100
|
-
| `containerHeightVar`
|
|
114
|
+
| Option | Type | Default | Description |
|
|
115
|
+
|:--|:--|:--:|:--|
|
|
116
|
+
| `container` | `HTMLElement \| string` | `undefined` | Target element or selector. One `TextSlicer` instance manages one element. |
|
|
117
|
+
| `splitMode` | `'words' \| 'chars' \| 'both'` | `both` | Split into words, graphemes, or both. |
|
|
118
|
+
| `cssVariables` | `boolean` | `false` | Sets `--word-index` / `--char-index` on generated spans and `--word-total` / `--char-total` on the container. |
|
|
119
|
+
| `dataAttributes` | `boolean` | `false` | Adds `data-word` and `data-char` attributes. |
|
|
120
|
+
| `keepWhitespaceNodes` | `boolean` | `true` | When `false`, whitespace stays visible as text nodes instead of `.ts-whitespace` spans in character-based modes. |
|
|
121
|
+
| `containerHeightVar` | `boolean` | `false` | Sets `--container-height` from `ResizeObserver` measurements when available. |
|
|
122
|
+
| `locale` | `string \| string[]` | `undefined` | Locale passed to `Intl.Segmenter`; omitted means the runtime default locale. |
|
|
123
|
+
|
|
124
|
+
---
|
|
101
125
|
|
|
102
126
|
## Callbacks
|
|
103
127
|
|
|
104
|
-
| Callback
|
|
105
|
-
|
|
106
|
-
| `onAfterRender` | `TextSlicerMetrics` |
|
|
128
|
+
| Callback | Arguments | Description |
|
|
129
|
+
|:--|:--|:--|
|
|
130
|
+
| `onAfterRender` | `TextSlicerMetrics` | Runs after render with `wordTotal`, `charTotal`, and `renderedAt`. |
|
|
131
|
+
|
|
132
|
+
`charTotal` counts non-whitespace grapheme clusters, not UTF-16 code units. In `chars` and `both` modes it matches rendered `.ts-char` spans. In `words` mode, metrics still describe the original plain text even though character spans are not rendered.
|
|
133
|
+
|
|
134
|
+
---
|
|
107
135
|
|
|
108
136
|
## Methods
|
|
109
137
|
|
|
110
|
-
| Method
|
|
111
|
-
|
|
112
|
-
| `init()`
|
|
113
|
-
| `reinit(newText?, options?)`
|
|
114
|
-
| `clear()`
|
|
115
|
-
| `split()`
|
|
116
|
-
| `destroy()`
|
|
117
|
-
| `updateOptions(options)`
|
|
118
|
-
| `lockHeight()`
|
|
119
|
-
| `unlockHeight()`
|
|
120
|
-
| `metrics`
|
|
138
|
+
| Method | Description |
|
|
139
|
+
|:--|:--|
|
|
140
|
+
| `init()` | Render the current text and mark the instance mounted. |
|
|
141
|
+
| `reinit(newText?, options?)` | Render again, optionally with new plain text and updated options. |
|
|
142
|
+
| `clear()` | Remove rendered children and restore library-owned accessibility attributes. Use `destroy()` for full cleanup. |
|
|
143
|
+
| `split()` | Render the current text again. |
|
|
144
|
+
| `destroy()` | Disconnect observers, restore library-owned styles and attributes, remove generated spans, and put the original plain text back. |
|
|
145
|
+
| `updateOptions(options)` | Update options and re-render if the instance is mounted. |
|
|
146
|
+
| `lockHeight()` | Lock the container height to its measured value. |
|
|
147
|
+
| `unlockHeight()` | Restore the previous inline `height`. |
|
|
148
|
+
| `metrics` | Return metrics computed from the original plain text. |
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Splitting Semantics
|
|
153
|
+
|
|
154
|
+
- `words`: word-like segments become `.ts-word`; punctuation stays visible but is not counted as a word.
|
|
155
|
+
- `chars`: non-whitespace grapheme clusters become `.ts-char`.
|
|
156
|
+
- `both`: word-like segments become `.ts-word`, and every non-whitespace grapheme, including punctuation, becomes `.ts-char`.
|
|
157
|
+
|
|
158
|
+
Word-like segments come from `Intl.Segmenter` with `granularity: 'word'`. Whitespace stays visible as `.ts-whitespace` spans, or as plain text nodes when `keepWhitespaceNodes: false` is used in character-based modes.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Limitations
|
|
163
|
+
|
|
164
|
+
- Plain text only: nested markup is not preserved. `destroy()` restores `textContent`, not the original child nodes.
|
|
165
|
+
- Best for short headings, labels, and animation copy. Long text can create a lot of DOM nodes.
|
|
166
|
+
- `split()` and `updateOptions()` rebuild the generated content.
|
|
167
|
+
- `Intl.Segmenter` is used when available. The fallback is simpler and may miss locale-specific word boundaries or complex emoji clusters.
|
|
168
|
+
- Client-only: call `init()` after mount in SSR frameworks.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Accessibility
|
|
173
|
+
|
|
174
|
+
Generated visual spans are marked `aria-hidden="true"`.
|
|
175
|
+
|
|
176
|
+
If the container has no `aria-label` or `aria-labelledby`, TextSlicer adds a managed `aria-label` with the original plain text and restores it on cleanup.
|
|
177
|
+
|
|
178
|
+
If you provide your own `aria-label` or `aria-labelledby`, TextSlicer leaves it alone. Keep your custom accessible label in sync when calling `reinit()` with new text.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Framework Integration
|
|
183
|
+
|
|
184
|
+
Call `init()` only after the element exists in the browser. Call `destroy()` before unmounting or replacing the element.
|
|
185
|
+
|
|
186
|
+
React / Next.js:
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
const slicer = new TextSlicer({
|
|
191
|
+
container: ref.current,
|
|
192
|
+
splitMode: 'chars',
|
|
193
|
+
cssVariables: true,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
slicer.init();
|
|
197
|
+
|
|
198
|
+
return () => slicer.destroy();
|
|
199
|
+
}, []);
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Vue:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
onMounted(() => {
|
|
206
|
+
slicer = new TextSlicer({ container: el.value });
|
|
207
|
+
slicer.init();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
onBeforeUnmount(() => slicer?.destroy());
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Svelte:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
onMount(() => {
|
|
217
|
+
const slicer = new TextSlicer({ container: node });
|
|
218
|
+
slicer.init();
|
|
219
|
+
|
|
220
|
+
return () => slicer.destroy();
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
121
225
|
|
|
122
226
|
## Styling
|
|
123
227
|
|
|
124
228
|
Exported `CLASSNAMES`:
|
|
125
229
|
|
|
126
|
-
- `ts-word` — word wrapper
|
|
230
|
+
- `ts-word` — word wrapper.
|
|
127
231
|
- `ts-char` — character span.
|
|
128
232
|
- `ts-whitespace` — whitespace span.
|
|
129
233
|
|
|
130
|
-
When `cssVariables: true`,
|
|
234
|
+
When `cssVariables: true`, `--word-total` and `--char-total` are set on the container. `--word-index` is set on `.ts-word` spans, and `--char-index` is set on non-whitespace `.ts-char` spans.
|
|
235
|
+
|
|
236
|
+
`--container-height` is based on `ResizeObserver` measurements when `containerHeightVar: true` and the platform API is available.
|
|
237
|
+
|
|
238
|
+
Staggered character reveal:
|
|
239
|
+
|
|
240
|
+
```css
|
|
241
|
+
.ts-char {
|
|
242
|
+
opacity: 0;
|
|
243
|
+
transform: translateY(0.5em);
|
|
244
|
+
animation: char-in 0.4s ease forwards;
|
|
245
|
+
animation-delay: calc(var(--char-index) * 40ms);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@keyframes char-in {
|
|
249
|
+
to {
|
|
250
|
+
opacity: 1;
|
|
251
|
+
transform: translateY(0);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Browser Support
|
|
259
|
+
|
|
260
|
+
`Intl.Segmenter` is required for correct word and grapheme splitting. Without it, TextSlicer uses a simplified fallback; emoji and complex grapheme clusters may be incorrect.
|
|
261
|
+
|
|
262
|
+
`containerHeightVar` is a no-op when `ResizeObserver` is unavailable.
|
|
263
|
+
|
|
264
|
+
---
|
|
131
265
|
|
|
132
266
|
## License
|
|
133
267
|
|
package/dist/index.cjs
CHANGED
|
@@ -6,14 +6,14 @@ const DEFAULT_OPTIONS = {
|
|
|
6
6
|
cssVariables: false,
|
|
7
7
|
dataAttributes: false,
|
|
8
8
|
keepWhitespaceNodes: true,
|
|
9
|
-
containerHeightVar: false
|
|
9
|
+
containerHeightVar: false,
|
|
10
|
+
locale: void 0
|
|
10
11
|
};
|
|
11
12
|
const CLASSNAMES = Object.freeze({
|
|
12
13
|
word: "ts-word",
|
|
13
14
|
char: "ts-char",
|
|
14
15
|
whitespace: "ts-whitespace"
|
|
15
16
|
});
|
|
16
|
-
const SPACE = " ";
|
|
17
17
|
const CSS_VAR_WORD_TOTAL = "--word-total";
|
|
18
18
|
const CSS_VAR_CHAR_TOTAL = "--char-total";
|
|
19
19
|
const CSS_VAR_WORD_INDEX = "--word-index";
|
|
@@ -26,15 +26,58 @@ const resolveContainer = (container) => {
|
|
|
26
26
|
if (!container) return null;
|
|
27
27
|
return typeof container === "string" ? document.querySelector(container) : container;
|
|
28
28
|
};
|
|
29
|
-
const
|
|
29
|
+
const localeKey = (locale) => locale === void 0 ? "" : Array.isArray(locale) ? locale.join("\0") : locale;
|
|
30
|
+
const segmenterCache = /* @__PURE__ */ new Map();
|
|
31
|
+
const getSegmenter = (locale, granularity) => {
|
|
32
|
+
if (typeof Intl === "undefined") return null;
|
|
30
33
|
const Seg = Intl.Segmenter;
|
|
31
|
-
if (typeof Seg
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
if (typeof Seg !== "function") return null;
|
|
35
|
+
const key = `${granularity}:${localeKey(locale)}`;
|
|
36
|
+
let segmenter = segmenterCache.get(key);
|
|
37
|
+
if (!segmenter) {
|
|
38
|
+
segmenter = new Seg(locale, { granularity });
|
|
39
|
+
segmenterCache.set(key, segmenter);
|
|
34
40
|
}
|
|
41
|
+
return segmenter;
|
|
42
|
+
};
|
|
43
|
+
const splitIntoGraphemes = (text, locale) => {
|
|
44
|
+
const segmenter = getSegmenter(locale, "grapheme");
|
|
45
|
+
if (segmenter) return Array.from(segmenter.segment(text), (s) => s.segment);
|
|
35
46
|
return Array.from(text);
|
|
36
47
|
};
|
|
37
|
-
const
|
|
48
|
+
const isWhitespaceGrapheme = (ch) => /^\s+$/u.test(ch);
|
|
49
|
+
const countCharGraphemes = (text, locale) => splitIntoGraphemes(text, locale).filter((ch) => !isWhitespaceGrapheme(ch)).length;
|
|
50
|
+
const segmentWords = (text, locale) => {
|
|
51
|
+
const segmenter = getSegmenter(locale, "word");
|
|
52
|
+
if (segmenter) return Array.from(segmenter.segment(text));
|
|
53
|
+
const words = text.split(/\s+/u).filter(Boolean);
|
|
54
|
+
const segments = [];
|
|
55
|
+
let cursor = 0;
|
|
56
|
+
for (const word of words) {
|
|
57
|
+
const start = text.indexOf(word, cursor);
|
|
58
|
+
if (start > cursor) segments.push({
|
|
59
|
+
segment: text.slice(cursor, start),
|
|
60
|
+
index: cursor,
|
|
61
|
+
input: text,
|
|
62
|
+
isWordLike: false
|
|
63
|
+
});
|
|
64
|
+
segments.push({
|
|
65
|
+
segment: word,
|
|
66
|
+
index: start,
|
|
67
|
+
input: text,
|
|
68
|
+
isWordLike: true
|
|
69
|
+
});
|
|
70
|
+
cursor = start + word.length;
|
|
71
|
+
}
|
|
72
|
+
if (cursor < text.length) segments.push({
|
|
73
|
+
segment: text.slice(cursor),
|
|
74
|
+
index: cursor,
|
|
75
|
+
input: text,
|
|
76
|
+
isWordLike: false
|
|
77
|
+
});
|
|
78
|
+
return segments;
|
|
79
|
+
};
|
|
80
|
+
const countWords = (text, locale) => segmentWords(text, locale).filter((s) => s.isWordLike).length;
|
|
38
81
|
const emptyElement = (el) => {
|
|
39
82
|
el.replaceChildren();
|
|
40
83
|
};
|
|
@@ -47,6 +90,13 @@ const omitUndefined = (obj) => {
|
|
|
47
90
|
});
|
|
48
91
|
return out;
|
|
49
92
|
};
|
|
93
|
+
const mergeRuntimeOptions = (base, patch) => {
|
|
94
|
+
const { container: _container, ...runtimeOptions } = patch;
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
...omitUndefined(runtimeOptions)
|
|
98
|
+
};
|
|
99
|
+
};
|
|
50
100
|
var TextSlicer = class {
|
|
51
101
|
el;
|
|
52
102
|
original;
|
|
@@ -55,23 +105,24 @@ var TextSlicer = class {
|
|
|
55
105
|
charIndex;
|
|
56
106
|
mounted;
|
|
57
107
|
resizeObserver;
|
|
108
|
+
managedStyles = /* @__PURE__ */ new Map();
|
|
109
|
+
managedAttributes = /* @__PURE__ */ new Map();
|
|
58
110
|
constructor(options = {}, callbacks) {
|
|
59
|
-
const
|
|
111
|
+
const { container, ...runtimeOptions } = options;
|
|
112
|
+
const el = resolveContainer(container);
|
|
60
113
|
this.el = el;
|
|
61
114
|
this.original = isHTMLElement(el) ? el.textContent?.toString() ?? "" : "";
|
|
62
|
-
this.opts =
|
|
63
|
-
...DEFAULT_OPTIONS,
|
|
64
|
-
...omitUndefined(options)
|
|
65
|
-
};
|
|
115
|
+
this.opts = mergeRuntimeOptions(DEFAULT_OPTIONS, runtimeOptions);
|
|
66
116
|
this.callbacks = callbacks;
|
|
67
117
|
this.charIndex = 0;
|
|
68
118
|
this.mounted = false;
|
|
69
119
|
}
|
|
70
120
|
get metrics() {
|
|
71
121
|
const text = this.original;
|
|
122
|
+
const { locale } = this.opts;
|
|
72
123
|
return {
|
|
73
|
-
wordTotal: text.length ?
|
|
74
|
-
charTotal: text.length,
|
|
124
|
+
wordTotal: text.length ? countWords(text, locale) : 0,
|
|
125
|
+
charTotal: text.length ? countCharGraphemes(text, locale) : 0,
|
|
75
126
|
renderedAt: Date.now()
|
|
76
127
|
};
|
|
77
128
|
}
|
|
@@ -79,130 +130,197 @@ var TextSlicer = class {
|
|
|
79
130
|
if (!this.el) return;
|
|
80
131
|
this.mounted = true;
|
|
81
132
|
this.split();
|
|
82
|
-
|
|
133
|
+
this.syncHeightObserver();
|
|
83
134
|
}
|
|
84
135
|
reinit(newText, next) {
|
|
85
136
|
if (!this.el) return;
|
|
86
137
|
if (typeof newText === "string") this.original = newText;
|
|
87
|
-
if (next) this.opts =
|
|
88
|
-
|
|
89
|
-
...omitUndefined(next)
|
|
90
|
-
};
|
|
138
|
+
if (next) this.opts = mergeRuntimeOptions(this.opts, next);
|
|
139
|
+
this.mounted = true;
|
|
91
140
|
this.split();
|
|
141
|
+
this.syncHeightObserver();
|
|
92
142
|
}
|
|
93
143
|
clear() {
|
|
94
144
|
if (!this.el) return;
|
|
95
145
|
emptyElement(this.el);
|
|
146
|
+
this.restoreManagedAttribute("aria-label");
|
|
96
147
|
}
|
|
97
148
|
split() {
|
|
98
149
|
if (!this.el) return;
|
|
99
|
-
this.
|
|
150
|
+
emptyElement(this.el);
|
|
100
151
|
this.charIndex = 0;
|
|
101
152
|
const text = this.original;
|
|
102
153
|
const fragment = document.createDocumentFragment();
|
|
103
|
-
const words = splitIntoWords(text);
|
|
104
154
|
if (this.opts.splitMode === "chars") this.appendChars(fragment, text);
|
|
105
|
-
else this.appendWords(fragment,
|
|
155
|
+
else this.appendWords(fragment, text);
|
|
106
156
|
this.el.appendChild(fragment);
|
|
157
|
+
this.syncAccessibleLabel(text);
|
|
158
|
+
const metrics = this.metrics;
|
|
107
159
|
if (this.opts.cssVariables) {
|
|
108
|
-
this.
|
|
109
|
-
this.
|
|
160
|
+
this.setManagedStyle(CSS_VAR_WORD_TOTAL, String(metrics.wordTotal));
|
|
161
|
+
this.setManagedStyle(CSS_VAR_CHAR_TOTAL, String(metrics.charTotal));
|
|
162
|
+
} else {
|
|
163
|
+
this.restoreManagedStyle(CSS_VAR_WORD_TOTAL);
|
|
164
|
+
this.restoreManagedStyle(CSS_VAR_CHAR_TOTAL);
|
|
110
165
|
}
|
|
111
|
-
this.callbacks?.onAfterRender?.(
|
|
166
|
+
this.callbacks?.onAfterRender?.(metrics);
|
|
112
167
|
}
|
|
113
168
|
destroy() {
|
|
114
169
|
if (!this.el) return;
|
|
115
|
-
this.
|
|
116
|
-
this.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
this.resizeObserver = void 0;
|
|
120
|
-
}
|
|
121
|
-
if (this.opts.containerHeightVar) this.el.style.removeProperty(CSS_VAR_CONTAINER_HEIGHT);
|
|
170
|
+
this.syncHeightObserver(false);
|
|
171
|
+
this.restoreManagedStyles();
|
|
172
|
+
this.restoreManagedAttribute("aria-label");
|
|
173
|
+
this.el.textContent = this.original;
|
|
122
174
|
this.mounted = false;
|
|
123
175
|
}
|
|
124
176
|
updateOptions(next) {
|
|
125
|
-
this.opts =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
177
|
+
this.opts = mergeRuntimeOptions(this.opts, next);
|
|
178
|
+
if (this.mounted) {
|
|
179
|
+
this.split();
|
|
180
|
+
this.syncHeightObserver();
|
|
181
|
+
}
|
|
130
182
|
}
|
|
131
183
|
lockHeight() {
|
|
132
184
|
if (!this.el) return;
|
|
133
185
|
const h = this.measureHeight();
|
|
134
|
-
if (h > 0) this.
|
|
186
|
+
if (h > 0) this.setManagedStyle("height", `${h}px`);
|
|
135
187
|
}
|
|
136
188
|
unlockHeight() {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
fragment.append(wordSpan);
|
|
153
|
-
}
|
|
154
|
-
if (wordIndex < words.length - 1) fragment.append(this.createSpaceSpan());
|
|
155
|
-
});
|
|
189
|
+
this.restoreManagedStyle("height");
|
|
190
|
+
}
|
|
191
|
+
appendWords(fragment, text) {
|
|
192
|
+
const segments = segmentWords(text, this.opts.locale);
|
|
193
|
+
let wordIndex = 0;
|
|
194
|
+
for (const { segment, isWordLike } of segments) if (isWordLike) {
|
|
195
|
+
const wordSpan = this.createWordSpan(wordIndex, segment);
|
|
196
|
+
if (this.opts.splitMode === "both") this.appendGraphemes(wordSpan, segment);
|
|
197
|
+
else wordSpan.append(document.createTextNode(segment));
|
|
198
|
+
fragment.append(wordSpan);
|
|
199
|
+
wordIndex += 1;
|
|
200
|
+
} else if (isWhitespaceGrapheme(segment)) if (this.opts.splitMode === "both") this.appendGraphemes(fragment, segment);
|
|
201
|
+
else fragment.append(this.createWhitespaceSpan(segment));
|
|
202
|
+
else if (segment.length > 0) if (this.opts.splitMode === "both") this.appendGraphemes(fragment, segment);
|
|
203
|
+
else fragment.append(this.createHiddenTextSpan(segment));
|
|
156
204
|
}
|
|
157
205
|
appendChars(fragment, text) {
|
|
158
|
-
for (const ch of splitIntoGraphemes(text))
|
|
159
|
-
|
|
160
|
-
|
|
206
|
+
for (const ch of splitIntoGraphemes(text, this.opts.locale)) this.appendGrapheme(fragment, ch);
|
|
207
|
+
}
|
|
208
|
+
appendGraphemes(parent, text) {
|
|
209
|
+
for (const ch of splitIntoGraphemes(text, this.opts.locale)) this.appendGrapheme(parent, ch);
|
|
210
|
+
}
|
|
211
|
+
appendGrapheme(parent, ch) {
|
|
212
|
+
if (isWhitespaceGrapheme(ch)) {
|
|
213
|
+
if (this.opts.keepWhitespaceNodes) parent.append(this.createWhitespaceSpan(ch));
|
|
214
|
+
else parent.append(document.createTextNode(ch));
|
|
215
|
+
return;
|
|
161
216
|
}
|
|
217
|
+
parent.append(this.createCharSpan(ch));
|
|
162
218
|
}
|
|
163
|
-
createWordSpan(index, word
|
|
219
|
+
createWordSpan(index, word) {
|
|
164
220
|
const span = document.createElement("span");
|
|
165
221
|
span.classList.add(CLASSNAMES.word);
|
|
166
|
-
|
|
222
|
+
span.setAttribute("aria-hidden", "true");
|
|
223
|
+
if (this.opts.dataAttributes) span.setAttribute("data-word", word);
|
|
167
224
|
if (this.opts.cssVariables) span.style.setProperty(CSS_VAR_WORD_INDEX, String(index));
|
|
168
225
|
return span;
|
|
169
226
|
}
|
|
170
227
|
createCharSpan(ch) {
|
|
171
228
|
const span = document.createElement("span");
|
|
172
229
|
span.textContent = ch;
|
|
230
|
+
span.setAttribute("aria-hidden", "true");
|
|
231
|
+
span.classList.add(CLASSNAMES.char);
|
|
173
232
|
if (this.opts.dataAttributes) span.setAttribute("data-char", ch);
|
|
174
|
-
if (
|
|
175
|
-
|
|
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
|
-
}
|
|
233
|
+
if (this.opts.cssVariables) span.style.setProperty(CSS_VAR_CHAR_INDEX, String(this.charIndex));
|
|
234
|
+
this.charIndex += 1;
|
|
182
235
|
return span;
|
|
183
236
|
}
|
|
184
|
-
|
|
237
|
+
createWhitespaceSpan(text) {
|
|
185
238
|
const span = document.createElement("span");
|
|
186
239
|
span.classList.add(CLASSNAMES.whitespace);
|
|
187
|
-
span.textContent =
|
|
240
|
+
span.textContent = text;
|
|
241
|
+
span.setAttribute("aria-hidden", "true");
|
|
242
|
+
return span;
|
|
243
|
+
}
|
|
244
|
+
createHiddenTextSpan(text) {
|
|
245
|
+
const span = document.createElement("span");
|
|
246
|
+
span.textContent = text;
|
|
247
|
+
span.setAttribute("aria-hidden", "true");
|
|
188
248
|
return span;
|
|
189
249
|
}
|
|
250
|
+
syncAccessibleLabel(text) {
|
|
251
|
+
if (!this.el) return;
|
|
252
|
+
if (this.managedAttributes.has("aria-label")) {
|
|
253
|
+
this.setManagedAttribute("aria-label", text);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (this.el.hasAttribute("aria-label") || this.el.hasAttribute("aria-labelledby")) return;
|
|
257
|
+
this.setManagedAttribute("aria-label", text);
|
|
258
|
+
}
|
|
190
259
|
measureHeight() {
|
|
191
260
|
if (!this.el) return 0;
|
|
192
261
|
this.el.classList.add(MEASURING_CLASS);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
262
|
+
try {
|
|
263
|
+
this.el.offsetHeight;
|
|
264
|
+
let h = this.el.offsetHeight || this.el.clientHeight || 0;
|
|
265
|
+
if (!h) h = Math.round(this.el.getBoundingClientRect().height);
|
|
266
|
+
return Math.max(0, Math.ceil(h));
|
|
267
|
+
} finally {
|
|
268
|
+
this.el.classList.remove(MEASURING_CLASS);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
syncHeightObserver(enable = this.opts.containerHeightVar) {
|
|
272
|
+
if (!this.el) return;
|
|
273
|
+
if (enable) {
|
|
274
|
+
if (typeof ResizeObserver !== "function") {
|
|
275
|
+
this.restoreManagedStyle(CSS_VAR_CONTAINER_HEIGHT);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!this.resizeObserver) {
|
|
279
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
280
|
+
const entry = entries[0];
|
|
281
|
+
if (!entry || !this.el) return;
|
|
282
|
+
const boxSize = entry.borderBoxSize?.[0]?.blockSize;
|
|
283
|
+
const h = Math.max(0, Math.ceil(boxSize ?? entry.contentRect.height));
|
|
284
|
+
if (h > 0) this.setManagedStyle(CSS_VAR_CONTAINER_HEIGHT, `${h}px`);
|
|
285
|
+
});
|
|
286
|
+
this.resizeObserver.observe(this.el);
|
|
287
|
+
}
|
|
288
|
+
} else if (this.resizeObserver) {
|
|
289
|
+
this.resizeObserver.disconnect();
|
|
290
|
+
this.resizeObserver = void 0;
|
|
291
|
+
this.restoreManagedStyle(CSS_VAR_CONTAINER_HEIGHT);
|
|
292
|
+
}
|
|
198
293
|
}
|
|
199
|
-
|
|
294
|
+
setManagedStyle(property, value) {
|
|
200
295
|
if (!this.el) return;
|
|
201
|
-
this.
|
|
202
|
-
|
|
203
|
-
|
|
296
|
+
if (!this.managedStyles.has(property)) this.managedStyles.set(property, {
|
|
297
|
+
value: this.el.style.getPropertyValue(property),
|
|
298
|
+
priority: this.el.style.getPropertyPriority(property)
|
|
204
299
|
});
|
|
205
|
-
this.
|
|
300
|
+
this.el.style.setProperty(property, value);
|
|
301
|
+
}
|
|
302
|
+
restoreManagedStyle(property) {
|
|
303
|
+
if (!this.el) return;
|
|
304
|
+
const previous = this.managedStyles.get(property);
|
|
305
|
+
if (previous === void 0) return;
|
|
306
|
+
if (previous.value) this.el.style.setProperty(property, previous.value, previous.priority);
|
|
307
|
+
else this.el.style.removeProperty(property);
|
|
308
|
+
this.managedStyles.delete(property);
|
|
309
|
+
}
|
|
310
|
+
restoreManagedStyles() {
|
|
311
|
+
for (const property of [...this.managedStyles.keys()]) this.restoreManagedStyle(property);
|
|
312
|
+
}
|
|
313
|
+
setManagedAttribute(name, value) {
|
|
314
|
+
if (!this.el) return;
|
|
315
|
+
if (!this.managedAttributes.has(name)) this.managedAttributes.set(name, this.el.getAttribute(name));
|
|
316
|
+
this.el.setAttribute(name, value);
|
|
317
|
+
}
|
|
318
|
+
restoreManagedAttribute(name) {
|
|
319
|
+
if (!this.el || !this.managedAttributes.has(name)) return;
|
|
320
|
+
const previous = this.managedAttributes.get(name);
|
|
321
|
+
if (previous === null) this.el.removeAttribute(name);
|
|
322
|
+
else if (previous !== void 0) this.el.setAttribute(name, previous);
|
|
323
|
+
this.managedAttributes.delete(name);
|
|
206
324
|
}
|
|
207
325
|
};
|
|
208
326
|
|