@y14e/splitter 0.0.0-stage → 4.0.1
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/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/index.cjs +296 -0
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +294 -0
- package/package.json +62 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yusuke Kamiyamane
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Splitter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Flexible text splitting utility for CSS animations. Supports complex [line breaking rules](https://en.wikipedia.org/wiki/Line_breaking_rules_in_East_Asian_languages) (ja: 禁則処理).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @y14e/splitter
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// npm
|
|
13
|
+
import { createSplitter } from '@y14e/splitter';
|
|
14
|
+
|
|
15
|
+
// CDNs
|
|
16
|
+
import { createSplitter } from 'https://esm.sh/@y14e/splitter@<x.x.x>';
|
|
17
|
+
// or
|
|
18
|
+
import { createSplitter } from 'https://cdn.jsdelivr.net/npm/@y14e/splitter@<x.x.x>/dist/index.js';
|
|
19
|
+
// or
|
|
20
|
+
import { createSplitter } from 'https://esm.unpkg.com/@y14e/splitter@<x.x.x>';
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 📦 APIs
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const cleanup = createSplitter(root, options);
|
|
27
|
+
// => () => void
|
|
28
|
+
//
|
|
29
|
+
// root: HTMLElement
|
|
30
|
+
// options (optional): SplitterOptions
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 🪄 Options
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
interface SplitterOptions {
|
|
37
|
+
concatChar: boolean; // default: false
|
|
38
|
+
noInlineStyle: boolean; // default: false
|
|
39
|
+
noLineBreakingRules: boolean; // default: false
|
|
40
|
+
wordSegmenter: boolean; // default: false
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### `concatChar`
|
|
45
|
+
|
|
46
|
+
If `true`, enables concatenation at the character level.
|
|
47
|
+
|
|
48
|
+
### `noLineBreakingRules`
|
|
49
|
+
|
|
50
|
+
If `true`, disables line breaking rules.
|
|
51
|
+
|
|
52
|
+
### `wordSegmenter`
|
|
53
|
+
|
|
54
|
+
If `true`, uses `Intl.Segmenter` with the `{ granularity: 'word' }` option; useful for CJK (Chinese, Japanese, and Korean) text.
|
|
55
|
+
|
|
56
|
+
## Demo
|
|
57
|
+
|
|
58
|
+
- https://y14e.github.io/splitter/
|
|
59
|
+
- https://y14e.github.io/splitter/test.html
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/constants.ts
|
|
4
|
+
var NOBR_RE = /[[[\P{scx=Han}]&&[\P{scx=Hang}]&&[\P{scx=Hira}]&&[\P{scx=Kana}]&&[\p{L}]]!-,.->@\[-`\{-~\u00A0]+/gv;
|
|
5
|
+
var LBR_PROHIBIT_START_RE = /^[[[\p{Pd}]--[―]]\p{Pe}\p{Pf}\p{Po}\u00A0々〵〻ぁぃぅぇぉっゃゅょゎゕゖ゛-ゞァィゥェォッャュョヮヵヶー-ヾㇰ-ㇿ]|\p{Pi}/v;
|
|
6
|
+
var LBR_PROHIBIT_END_RE = /[\p{Pf}\p{Pi}\p{Ps}\p{Sc}\u00A0]$/u;
|
|
7
|
+
var LBR_INSEPARATABLE_RE = /[―‥…]/u;
|
|
8
|
+
var VISUALLY_HIDDEN_CSS = "border: 0; clip: rect(0, 0, 0, 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; user-select: none; white-space: nowrap; width: 1px;";
|
|
9
|
+
|
|
10
|
+
// src/options.ts
|
|
11
|
+
function resolveOptions(options) {
|
|
12
|
+
let {
|
|
13
|
+
concatChar = false,
|
|
14
|
+
noInlineStyle = false,
|
|
15
|
+
noLineBreakingRules = false,
|
|
16
|
+
wordSegmenter = false
|
|
17
|
+
} = options;
|
|
18
|
+
if (typeof concatChar !== "boolean") {
|
|
19
|
+
console.warn(`Invalid concatChar option. Fallback: false.`);
|
|
20
|
+
concatChar = false;
|
|
21
|
+
}
|
|
22
|
+
if (typeof noInlineStyle !== "boolean") {
|
|
23
|
+
console.warn(`Invalid noInlineStyle option. Fallback: false.`);
|
|
24
|
+
noInlineStyle = false;
|
|
25
|
+
}
|
|
26
|
+
if (typeof noLineBreakingRules !== "boolean") {
|
|
27
|
+
console.warn(`Invalid noLineBreakingRules option. Fallback: false.`);
|
|
28
|
+
noLineBreakingRules = false;
|
|
29
|
+
}
|
|
30
|
+
if (typeof wordSegmenter !== "boolean") {
|
|
31
|
+
console.warn(`Invalid wordSegmenter option. Fallback: false.`);
|
|
32
|
+
wordSegmenter = false;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
concatChar,
|
|
36
|
+
noInlineStyle,
|
|
37
|
+
noLineBreakingRules,
|
|
38
|
+
wordSegmenter
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/index.ts
|
|
43
|
+
function createSplitter(root, options = {}) {
|
|
44
|
+
if (!(root instanceof HTMLElement)) {
|
|
45
|
+
console.warn("Invalid root element");
|
|
46
|
+
return () => {
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const instance = new Splitter(root, options);
|
|
50
|
+
return () => instance.destroy();
|
|
51
|
+
}
|
|
52
|
+
var Splitter = class {
|
|
53
|
+
#rootElement;
|
|
54
|
+
#settings;
|
|
55
|
+
#wordElements = [];
|
|
56
|
+
#charElements = [];
|
|
57
|
+
#original;
|
|
58
|
+
#fragment = null;
|
|
59
|
+
#segmenter = new Intl.Segmenter();
|
|
60
|
+
#isDestroyed = false;
|
|
61
|
+
constructor(root, options = {}) {
|
|
62
|
+
this.#rootElement = root;
|
|
63
|
+
this.#settings = resolveOptions(options);
|
|
64
|
+
this.#original = this.#rootElement.innerHTML;
|
|
65
|
+
this.#initialize();
|
|
66
|
+
}
|
|
67
|
+
destroy() {
|
|
68
|
+
if (this.#isDestroyed) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.#isDestroyed = true;
|
|
72
|
+
this.#rootElement.removeAttribute("data-moji-splitter-initialized");
|
|
73
|
+
this.#rootElement.innerHTML = this.#original ?? "";
|
|
74
|
+
const style = this.#rootElement.style;
|
|
75
|
+
style.removeProperty("--word-count");
|
|
76
|
+
style.removeProperty("--char-count");
|
|
77
|
+
this.#cleanup();
|
|
78
|
+
this.#original = null;
|
|
79
|
+
}
|
|
80
|
+
#initialize() {
|
|
81
|
+
const children = this.#rootElement.childNodes;
|
|
82
|
+
this.#fragment = new DocumentFragment();
|
|
83
|
+
for (const child of children) {
|
|
84
|
+
this.#fragment.appendChild(child.cloneNode(true));
|
|
85
|
+
}
|
|
86
|
+
this.#applyNonBreakingRules();
|
|
87
|
+
this.#split("word");
|
|
88
|
+
const { concatChar, noInlineStyle, noLineBreakingRules } = this.#settings;
|
|
89
|
+
!concatChar && !noLineBreakingRules && this.#applyLineBreakingRules("word");
|
|
90
|
+
this.#split("char");
|
|
91
|
+
concatChar && !noLineBreakingRules && this.#applyLineBreakingRules("char");
|
|
92
|
+
this.#charElements.forEach((char, i) => {
|
|
93
|
+
char.setAttribute("aria-hidden", "true");
|
|
94
|
+
char.style.setProperty("--char-index", String(i));
|
|
95
|
+
});
|
|
96
|
+
if (!noInlineStyle) {
|
|
97
|
+
const spans = this.#fragment.querySelectorAll(
|
|
98
|
+
":is([data-word], [data-char]):not([data-whitespace])"
|
|
99
|
+
);
|
|
100
|
+
for (const span of spans) {
|
|
101
|
+
const { style: style2 } = span;
|
|
102
|
+
style2.setProperty("display", "inline-block");
|
|
103
|
+
[...(this.#segmenter ?? new Intl.Segmenter()).segment(span.textContent)].length && style2.setProperty("white-space", "nowrap");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this.#wordElements.forEach((word, i) => {
|
|
107
|
+
word.translate = false;
|
|
108
|
+
word.style.setProperty("--word-index", String(i));
|
|
109
|
+
if (!word.hasAttribute("data-whitespace")) {
|
|
110
|
+
const alt = document.createElement("span");
|
|
111
|
+
alt.setAttribute("data-alt", "");
|
|
112
|
+
if (!this.#settings.noInlineStyle) {
|
|
113
|
+
alt.style.cssText += VISUALLY_HIDDEN_CSS;
|
|
114
|
+
}
|
|
115
|
+
alt.textContent = word.textContent;
|
|
116
|
+
word.append(alt);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
this.#rootElement.replaceChildren(...this.#fragment.childNodes);
|
|
120
|
+
const { style } = this.#rootElement;
|
|
121
|
+
style.setProperty("--word-count", String(this.#wordElements.length));
|
|
122
|
+
style.setProperty("--char-count", String(this.#charElements.length));
|
|
123
|
+
const whitespaces = this.#rootElement.querySelectorAll(
|
|
124
|
+
":scope > :not([data-word]) [data-char][data-whitespace]"
|
|
125
|
+
);
|
|
126
|
+
for (const whitespace of whitespaces) {
|
|
127
|
+
if (window.getComputedStyle(whitespace).getPropertyValue("display") !== "inline") {
|
|
128
|
+
whitespace.innerHTML = " ";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
this.#cleanup();
|
|
132
|
+
this.#rootElement.setAttribute("data-moji-splitter-initialized", "");
|
|
133
|
+
}
|
|
134
|
+
#applyNonBreakingRules(node = this.#fragment ?? new DocumentFragment()) {
|
|
135
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
136
|
+
const text = node.textContent;
|
|
137
|
+
if (!text || !NOBR_RE.test(text)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
NOBR_RE.lastIndex = 0;
|
|
141
|
+
let lastIndex = 0;
|
|
142
|
+
const fragment = document.createDocumentFragment();
|
|
143
|
+
for (const match of text.matchAll(NOBR_RE)) {
|
|
144
|
+
const index = match.index;
|
|
145
|
+
index > lastIndex && fragment.append(text.slice(lastIndex, index));
|
|
146
|
+
const span = document.createElement("span");
|
|
147
|
+
span.setAttribute("data-_nobr", "");
|
|
148
|
+
const matched = match[0];
|
|
149
|
+
span.textContent = matched;
|
|
150
|
+
fragment.append(span);
|
|
151
|
+
lastIndex = index + matched.length;
|
|
152
|
+
}
|
|
153
|
+
lastIndex < text.length && fragment.append(text.slice(lastIndex));
|
|
154
|
+
if (!(node instanceof Text)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
node.replaceWith(fragment);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let child = node.firstChild;
|
|
161
|
+
while (child) {
|
|
162
|
+
const next = child.nextSibling;
|
|
163
|
+
this.#applyNonBreakingRules(child);
|
|
164
|
+
child = next;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
#split(granularity, node = this.#fragment ?? new DocumentFragment()) {
|
|
168
|
+
let child = node.firstChild;
|
|
169
|
+
const items = granularity === "word" ? this.#wordElements : this.#charElements;
|
|
170
|
+
while (child) {
|
|
171
|
+
const next = child.nextSibling;
|
|
172
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
173
|
+
const segmenter = this.#getSegmenter(granularity, child.parentNode);
|
|
174
|
+
if (!segmenter) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const fragment = document.createDocumentFragment();
|
|
178
|
+
for (const segment of segmenter.segment(
|
|
179
|
+
(child.textContent ?? "").replace(/[\r\n\t]/g, "").replace(/\s{2,}/g, " ")
|
|
180
|
+
)) {
|
|
181
|
+
const span = document.createElement("span");
|
|
182
|
+
const text = segment.segment;
|
|
183
|
+
span.textContent = text;
|
|
184
|
+
text.charCodeAt(0) === 32 && span.setAttribute("data-whitespace", "");
|
|
185
|
+
span.setAttribute(`data-${granularity}`, text);
|
|
186
|
+
items.push(span);
|
|
187
|
+
fragment.append(span);
|
|
188
|
+
}
|
|
189
|
+
child.replaceWith(fragment);
|
|
190
|
+
} else if (granularity === "word" && child instanceof HTMLElement && child.hasAttribute("data-_nobr")) {
|
|
191
|
+
child.removeAttribute("data-_nobr");
|
|
192
|
+
const text = child.textContent ?? "";
|
|
193
|
+
child.setAttribute("data-word", text);
|
|
194
|
+
items.push(child);
|
|
195
|
+
} else if (child.hasChildNodes()) {
|
|
196
|
+
this.#split(granularity, child);
|
|
197
|
+
}
|
|
198
|
+
child = next;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
#applyLineBreakingRules(granularity) {
|
|
202
|
+
let count = 0;
|
|
203
|
+
const items = granularity === "word" ? this.#wordElements : this.#charElements;
|
|
204
|
+
let previous = null;
|
|
205
|
+
while (count < items.length) {
|
|
206
|
+
const item = items[count];
|
|
207
|
+
if (!item) {
|
|
208
|
+
count++;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
let text = item.textContent ?? "";
|
|
212
|
+
if (previous?.textContent?.trim() && LBR_PROHIBIT_START_RE.test(text)) {
|
|
213
|
+
text = (previous.textContent ?? "") + text;
|
|
214
|
+
previous.textContent = text;
|
|
215
|
+
previous.setAttribute(`data-${granularity}`, text);
|
|
216
|
+
item.remove();
|
|
217
|
+
items.splice(count, 1);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
previous = item;
|
|
221
|
+
count++;
|
|
222
|
+
}
|
|
223
|
+
function concat(index, re) {
|
|
224
|
+
const item = items[index];
|
|
225
|
+
if (!item) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const offset = index + 1;
|
|
229
|
+
let text = item.textContent ?? "";
|
|
230
|
+
while (offset < items.length) {
|
|
231
|
+
const next = items[offset];
|
|
232
|
+
if (!next) {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
const nextText = next.textContent ?? "";
|
|
236
|
+
if (!re.test(nextText)) {
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
text += nextText;
|
|
240
|
+
next.remove();
|
|
241
|
+
items.splice(offset, 1);
|
|
242
|
+
}
|
|
243
|
+
item.textContent = text;
|
|
244
|
+
item.setAttribute(`data-${granularity}`, text);
|
|
245
|
+
}
|
|
246
|
+
for (let i = 0; i < items.length; i++) {
|
|
247
|
+
const item = items[i];
|
|
248
|
+
const text = item?.textContent ?? "";
|
|
249
|
+
if (LBR_PROHIBIT_END_RE.test(text)) {
|
|
250
|
+
concat(i, LBR_PROHIBIT_END_RE);
|
|
251
|
+
const next = items[i + 1];
|
|
252
|
+
if (next?.textContent?.trim()) {
|
|
253
|
+
const text2 = (items[i]?.textContent ?? "") + next.textContent;
|
|
254
|
+
next.textContent = text2;
|
|
255
|
+
next.setAttribute(`data-${granularity}`, text2);
|
|
256
|
+
items[i]?.remove();
|
|
257
|
+
items.splice(i, 1);
|
|
258
|
+
i--;
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
LBR_INSEPARATABLE_RE.test(text) && concat(i, LBR_INSEPARATABLE_RE);
|
|
263
|
+
}
|
|
264
|
+
if (granularity === "char") {
|
|
265
|
+
for (const span of (this.#fragment ?? new DocumentFragment()).querySelectorAll("[data-word]:not([data-whitespace])")) {
|
|
266
|
+
const text = span.textContent;
|
|
267
|
+
text ? span.setAttribute("data-word", text) : span.remove();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
#cleanup() {
|
|
272
|
+
this.#wordElements.length = 0;
|
|
273
|
+
this.#charElements.length = 0;
|
|
274
|
+
this.#fragment = null;
|
|
275
|
+
this.#segmenter = null;
|
|
276
|
+
}
|
|
277
|
+
#getSegmenter(granularity, parent) {
|
|
278
|
+
if (granularity === "word" && this.#settings.wordSegmenter) {
|
|
279
|
+
const root = parent?.nodeType === Node.ELEMENT_NODE ? parent : this.#rootElement;
|
|
280
|
+
if (!(root instanceof HTMLElement)) {
|
|
281
|
+
return this.#segmenter;
|
|
282
|
+
}
|
|
283
|
+
const closest = root.closest("[lang]");
|
|
284
|
+
return new Intl.Segmenter(
|
|
285
|
+
closest?.lang || document.documentElement.lang || "en",
|
|
286
|
+
{
|
|
287
|
+
granularity: "word"
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
} else {
|
|
291
|
+
return this.#segmenter;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
exports.createSplitter = createSplitter;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface SplitterOptions {
|
|
2
|
+
concatChar: boolean;
|
|
3
|
+
noInlineStyle: boolean;
|
|
4
|
+
noLineBreakingRules: boolean;
|
|
5
|
+
wordSegmenter: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
declare function createSplitter(root: HTMLElement, options?: Partial<SplitterOptions>): () => void;
|
|
9
|
+
|
|
10
|
+
export { type SplitterOptions, createSplitter };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface SplitterOptions {
|
|
2
|
+
concatChar: boolean;
|
|
3
|
+
noInlineStyle: boolean;
|
|
4
|
+
noLineBreakingRules: boolean;
|
|
5
|
+
wordSegmenter: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
declare function createSplitter(root: HTMLElement, options?: Partial<SplitterOptions>): () => void;
|
|
9
|
+
|
|
10
|
+
export { type SplitterOptions, createSplitter };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var NOBR_RE = /[[[\P{scx=Han}]&&[\P{scx=Hang}]&&[\P{scx=Hira}]&&[\P{scx=Kana}]&&[\p{L}]]!-,.->@\[-`\{-~\u00A0]+/gv;
|
|
3
|
+
var LBR_PROHIBIT_START_RE = /^[[[\p{Pd}]--[―]]\p{Pe}\p{Pf}\p{Po}\u00A0々〵〻ぁぃぅぇぉっゃゅょゎゕゖ゛-ゞァィゥェォッャュョヮヵヶー-ヾㇰ-ㇿ]|\p{Pi}/v;
|
|
4
|
+
var LBR_PROHIBIT_END_RE = /[\p{Pf}\p{Pi}\p{Ps}\p{Sc}\u00A0]$/u;
|
|
5
|
+
var LBR_INSEPARATABLE_RE = /[―‥…]/u;
|
|
6
|
+
var VISUALLY_HIDDEN_CSS = "border: 0; clip: rect(0, 0, 0, 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; user-select: none; white-space: nowrap; width: 1px;";
|
|
7
|
+
|
|
8
|
+
// src/options.ts
|
|
9
|
+
function resolveOptions(options) {
|
|
10
|
+
let {
|
|
11
|
+
concatChar = false,
|
|
12
|
+
noInlineStyle = false,
|
|
13
|
+
noLineBreakingRules = false,
|
|
14
|
+
wordSegmenter = false
|
|
15
|
+
} = options;
|
|
16
|
+
if (typeof concatChar !== "boolean") {
|
|
17
|
+
console.warn(`Invalid concatChar option. Fallback: false.`);
|
|
18
|
+
concatChar = false;
|
|
19
|
+
}
|
|
20
|
+
if (typeof noInlineStyle !== "boolean") {
|
|
21
|
+
console.warn(`Invalid noInlineStyle option. Fallback: false.`);
|
|
22
|
+
noInlineStyle = false;
|
|
23
|
+
}
|
|
24
|
+
if (typeof noLineBreakingRules !== "boolean") {
|
|
25
|
+
console.warn(`Invalid noLineBreakingRules option. Fallback: false.`);
|
|
26
|
+
noLineBreakingRules = false;
|
|
27
|
+
}
|
|
28
|
+
if (typeof wordSegmenter !== "boolean") {
|
|
29
|
+
console.warn(`Invalid wordSegmenter option. Fallback: false.`);
|
|
30
|
+
wordSegmenter = false;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
concatChar,
|
|
34
|
+
noInlineStyle,
|
|
35
|
+
noLineBreakingRules,
|
|
36
|
+
wordSegmenter
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/index.ts
|
|
41
|
+
function createSplitter(root, options = {}) {
|
|
42
|
+
if (!(root instanceof HTMLElement)) {
|
|
43
|
+
console.warn("Invalid root element");
|
|
44
|
+
return () => {
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const instance = new Splitter(root, options);
|
|
48
|
+
return () => instance.destroy();
|
|
49
|
+
}
|
|
50
|
+
var Splitter = class {
|
|
51
|
+
#rootElement;
|
|
52
|
+
#settings;
|
|
53
|
+
#wordElements = [];
|
|
54
|
+
#charElements = [];
|
|
55
|
+
#original;
|
|
56
|
+
#fragment = null;
|
|
57
|
+
#segmenter = new Intl.Segmenter();
|
|
58
|
+
#isDestroyed = false;
|
|
59
|
+
constructor(root, options = {}) {
|
|
60
|
+
this.#rootElement = root;
|
|
61
|
+
this.#settings = resolveOptions(options);
|
|
62
|
+
this.#original = this.#rootElement.innerHTML;
|
|
63
|
+
this.#initialize();
|
|
64
|
+
}
|
|
65
|
+
destroy() {
|
|
66
|
+
if (this.#isDestroyed) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.#isDestroyed = true;
|
|
70
|
+
this.#rootElement.removeAttribute("data-moji-splitter-initialized");
|
|
71
|
+
this.#rootElement.innerHTML = this.#original ?? "";
|
|
72
|
+
const style = this.#rootElement.style;
|
|
73
|
+
style.removeProperty("--word-count");
|
|
74
|
+
style.removeProperty("--char-count");
|
|
75
|
+
this.#cleanup();
|
|
76
|
+
this.#original = null;
|
|
77
|
+
}
|
|
78
|
+
#initialize() {
|
|
79
|
+
const children = this.#rootElement.childNodes;
|
|
80
|
+
this.#fragment = new DocumentFragment();
|
|
81
|
+
for (const child of children) {
|
|
82
|
+
this.#fragment.appendChild(child.cloneNode(true));
|
|
83
|
+
}
|
|
84
|
+
this.#applyNonBreakingRules();
|
|
85
|
+
this.#split("word");
|
|
86
|
+
const { concatChar, noInlineStyle, noLineBreakingRules } = this.#settings;
|
|
87
|
+
!concatChar && !noLineBreakingRules && this.#applyLineBreakingRules("word");
|
|
88
|
+
this.#split("char");
|
|
89
|
+
concatChar && !noLineBreakingRules && this.#applyLineBreakingRules("char");
|
|
90
|
+
this.#charElements.forEach((char, i) => {
|
|
91
|
+
char.setAttribute("aria-hidden", "true");
|
|
92
|
+
char.style.setProperty("--char-index", String(i));
|
|
93
|
+
});
|
|
94
|
+
if (!noInlineStyle) {
|
|
95
|
+
const spans = this.#fragment.querySelectorAll(
|
|
96
|
+
":is([data-word], [data-char]):not([data-whitespace])"
|
|
97
|
+
);
|
|
98
|
+
for (const span of spans) {
|
|
99
|
+
const { style: style2 } = span;
|
|
100
|
+
style2.setProperty("display", "inline-block");
|
|
101
|
+
[...(this.#segmenter ?? new Intl.Segmenter()).segment(span.textContent)].length && style2.setProperty("white-space", "nowrap");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
this.#wordElements.forEach((word, i) => {
|
|
105
|
+
word.translate = false;
|
|
106
|
+
word.style.setProperty("--word-index", String(i));
|
|
107
|
+
if (!word.hasAttribute("data-whitespace")) {
|
|
108
|
+
const alt = document.createElement("span");
|
|
109
|
+
alt.setAttribute("data-alt", "");
|
|
110
|
+
if (!this.#settings.noInlineStyle) {
|
|
111
|
+
alt.style.cssText += VISUALLY_HIDDEN_CSS;
|
|
112
|
+
}
|
|
113
|
+
alt.textContent = word.textContent;
|
|
114
|
+
word.append(alt);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
this.#rootElement.replaceChildren(...this.#fragment.childNodes);
|
|
118
|
+
const { style } = this.#rootElement;
|
|
119
|
+
style.setProperty("--word-count", String(this.#wordElements.length));
|
|
120
|
+
style.setProperty("--char-count", String(this.#charElements.length));
|
|
121
|
+
const whitespaces = this.#rootElement.querySelectorAll(
|
|
122
|
+
":scope > :not([data-word]) [data-char][data-whitespace]"
|
|
123
|
+
);
|
|
124
|
+
for (const whitespace of whitespaces) {
|
|
125
|
+
if (window.getComputedStyle(whitespace).getPropertyValue("display") !== "inline") {
|
|
126
|
+
whitespace.innerHTML = " ";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.#cleanup();
|
|
130
|
+
this.#rootElement.setAttribute("data-moji-splitter-initialized", "");
|
|
131
|
+
}
|
|
132
|
+
#applyNonBreakingRules(node = this.#fragment ?? new DocumentFragment()) {
|
|
133
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
134
|
+
const text = node.textContent;
|
|
135
|
+
if (!text || !NOBR_RE.test(text)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
NOBR_RE.lastIndex = 0;
|
|
139
|
+
let lastIndex = 0;
|
|
140
|
+
const fragment = document.createDocumentFragment();
|
|
141
|
+
for (const match of text.matchAll(NOBR_RE)) {
|
|
142
|
+
const index = match.index;
|
|
143
|
+
index > lastIndex && fragment.append(text.slice(lastIndex, index));
|
|
144
|
+
const span = document.createElement("span");
|
|
145
|
+
span.setAttribute("data-_nobr", "");
|
|
146
|
+
const matched = match[0];
|
|
147
|
+
span.textContent = matched;
|
|
148
|
+
fragment.append(span);
|
|
149
|
+
lastIndex = index + matched.length;
|
|
150
|
+
}
|
|
151
|
+
lastIndex < text.length && fragment.append(text.slice(lastIndex));
|
|
152
|
+
if (!(node instanceof Text)) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
node.replaceWith(fragment);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
let child = node.firstChild;
|
|
159
|
+
while (child) {
|
|
160
|
+
const next = child.nextSibling;
|
|
161
|
+
this.#applyNonBreakingRules(child);
|
|
162
|
+
child = next;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
#split(granularity, node = this.#fragment ?? new DocumentFragment()) {
|
|
166
|
+
let child = node.firstChild;
|
|
167
|
+
const items = granularity === "word" ? this.#wordElements : this.#charElements;
|
|
168
|
+
while (child) {
|
|
169
|
+
const next = child.nextSibling;
|
|
170
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
171
|
+
const segmenter = this.#getSegmenter(granularity, child.parentNode);
|
|
172
|
+
if (!segmenter) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const fragment = document.createDocumentFragment();
|
|
176
|
+
for (const segment of segmenter.segment(
|
|
177
|
+
(child.textContent ?? "").replace(/[\r\n\t]/g, "").replace(/\s{2,}/g, " ")
|
|
178
|
+
)) {
|
|
179
|
+
const span = document.createElement("span");
|
|
180
|
+
const text = segment.segment;
|
|
181
|
+
span.textContent = text;
|
|
182
|
+
text.charCodeAt(0) === 32 && span.setAttribute("data-whitespace", "");
|
|
183
|
+
span.setAttribute(`data-${granularity}`, text);
|
|
184
|
+
items.push(span);
|
|
185
|
+
fragment.append(span);
|
|
186
|
+
}
|
|
187
|
+
child.replaceWith(fragment);
|
|
188
|
+
} else if (granularity === "word" && child instanceof HTMLElement && child.hasAttribute("data-_nobr")) {
|
|
189
|
+
child.removeAttribute("data-_nobr");
|
|
190
|
+
const text = child.textContent ?? "";
|
|
191
|
+
child.setAttribute("data-word", text);
|
|
192
|
+
items.push(child);
|
|
193
|
+
} else if (child.hasChildNodes()) {
|
|
194
|
+
this.#split(granularity, child);
|
|
195
|
+
}
|
|
196
|
+
child = next;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
#applyLineBreakingRules(granularity) {
|
|
200
|
+
let count = 0;
|
|
201
|
+
const items = granularity === "word" ? this.#wordElements : this.#charElements;
|
|
202
|
+
let previous = null;
|
|
203
|
+
while (count < items.length) {
|
|
204
|
+
const item = items[count];
|
|
205
|
+
if (!item) {
|
|
206
|
+
count++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
let text = item.textContent ?? "";
|
|
210
|
+
if (previous?.textContent?.trim() && LBR_PROHIBIT_START_RE.test(text)) {
|
|
211
|
+
text = (previous.textContent ?? "") + text;
|
|
212
|
+
previous.textContent = text;
|
|
213
|
+
previous.setAttribute(`data-${granularity}`, text);
|
|
214
|
+
item.remove();
|
|
215
|
+
items.splice(count, 1);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
previous = item;
|
|
219
|
+
count++;
|
|
220
|
+
}
|
|
221
|
+
function concat(index, re) {
|
|
222
|
+
const item = items[index];
|
|
223
|
+
if (!item) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const offset = index + 1;
|
|
227
|
+
let text = item.textContent ?? "";
|
|
228
|
+
while (offset < items.length) {
|
|
229
|
+
const next = items[offset];
|
|
230
|
+
if (!next) {
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
const nextText = next.textContent ?? "";
|
|
234
|
+
if (!re.test(nextText)) {
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
text += nextText;
|
|
238
|
+
next.remove();
|
|
239
|
+
items.splice(offset, 1);
|
|
240
|
+
}
|
|
241
|
+
item.textContent = text;
|
|
242
|
+
item.setAttribute(`data-${granularity}`, text);
|
|
243
|
+
}
|
|
244
|
+
for (let i = 0; i < items.length; i++) {
|
|
245
|
+
const item = items[i];
|
|
246
|
+
const text = item?.textContent ?? "";
|
|
247
|
+
if (LBR_PROHIBIT_END_RE.test(text)) {
|
|
248
|
+
concat(i, LBR_PROHIBIT_END_RE);
|
|
249
|
+
const next = items[i + 1];
|
|
250
|
+
if (next?.textContent?.trim()) {
|
|
251
|
+
const text2 = (items[i]?.textContent ?? "") + next.textContent;
|
|
252
|
+
next.textContent = text2;
|
|
253
|
+
next.setAttribute(`data-${granularity}`, text2);
|
|
254
|
+
items[i]?.remove();
|
|
255
|
+
items.splice(i, 1);
|
|
256
|
+
i--;
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
LBR_INSEPARATABLE_RE.test(text) && concat(i, LBR_INSEPARATABLE_RE);
|
|
261
|
+
}
|
|
262
|
+
if (granularity === "char") {
|
|
263
|
+
for (const span of (this.#fragment ?? new DocumentFragment()).querySelectorAll("[data-word]:not([data-whitespace])")) {
|
|
264
|
+
const text = span.textContent;
|
|
265
|
+
text ? span.setAttribute("data-word", text) : span.remove();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
#cleanup() {
|
|
270
|
+
this.#wordElements.length = 0;
|
|
271
|
+
this.#charElements.length = 0;
|
|
272
|
+
this.#fragment = null;
|
|
273
|
+
this.#segmenter = null;
|
|
274
|
+
}
|
|
275
|
+
#getSegmenter(granularity, parent) {
|
|
276
|
+
if (granularity === "word" && this.#settings.wordSegmenter) {
|
|
277
|
+
const root = parent?.nodeType === Node.ELEMENT_NODE ? parent : this.#rootElement;
|
|
278
|
+
if (!(root instanceof HTMLElement)) {
|
|
279
|
+
return this.#segmenter;
|
|
280
|
+
}
|
|
281
|
+
const closest = root.closest("[lang]");
|
|
282
|
+
return new Intl.Segmenter(
|
|
283
|
+
closest?.lang || document.documentElement.lang || "en",
|
|
284
|
+
{
|
|
285
|
+
granularity: "word"
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
} else {
|
|
289
|
+
return this.#segmenter;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
export { createSplitter };
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@y14e/splitter",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "4.0.1",
|
|
4
|
+
"description": "Flexible text splitting utility for CSS animations",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"animation",
|
|
7
|
+
"character",
|
|
8
|
+
"css",
|
|
9
|
+
"grapheme",
|
|
10
|
+
"intl-segmenter",
|
|
11
|
+
"line-breaking",
|
|
12
|
+
"kinsoku",
|
|
13
|
+
"segmenter",
|
|
14
|
+
"split",
|
|
15
|
+
"splitter",
|
|
16
|
+
"splitting",
|
|
17
|
+
"text",
|
|
18
|
+
"typescript",
|
|
19
|
+
"utility",
|
|
20
|
+
"word"
|
|
21
|
+
],
|
|
22
|
+
"homepage": "https://github.com/y14e/splitter#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/y14e/splitter/issues"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/y14e/splitter.git"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"author": "Yusuke Kamiyamane",
|
|
32
|
+
"type": "module",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"import": "./dist/index.js",
|
|
37
|
+
"require": "./dist/index.cjs"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"main": "./dist/index.cjs",
|
|
41
|
+
"module": "./dist/index.js",
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"LICENSE",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"lint": "tsc --noEmit",
|
|
51
|
+
"prepublishOnly": "npm run build"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"bun-types": "latest",
|
|
55
|
+
"tsup": "^8.0.0",
|
|
56
|
+
"typescript": "^5.6.0"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=18"
|
|
60
|
+
},
|
|
61
|
+
"allowScripts": {
|
|
62
|
+
"esbuild": true
|
|
63
|
+
}
|
|
64
|
+
}
|