fetta 1.1.1 → 1.2.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
@@ -6,7 +6,7 @@ Split text into characters, words, and lines while preserving the original typog
6
6
 
7
7
  ## Features
8
8
 
9
- - **Kerning Compensation** — Measures character positions before splitting, then applies margin adjustments to maintain original spacing
9
+ - **Kerning Compensation** — Measures kerning between character pairs, applies margin adjustments to maintain original spacing
10
10
  - **Nested Elements** — Preserves inline HTML elements (`<a>`, `<em>`, `<strong>`, etc.) with all attributes intact
11
11
  - **Line Detection** — Detects lines based on Y-position clustering, works with any container width
12
12
  - **Dash Handling** — Allows text to wrap naturally after em-dashes, en-dashes, and hyphens
@@ -85,7 +85,6 @@ const result = splitText(element, options);
85
85
  | `onSplit` | `function` | — | Callback after initial split |
86
86
  | `revertOnComplete` | `boolean` | `false` | Auto-revert when animation completes |
87
87
  | `propIndex` | `boolean` | `false` | Add CSS custom properties: `--char-index`, `--word-index`, `--line-index` |
88
- | `willChange` | `boolean` | `false` | Add `will-change: transform, opacity` for performance |
89
88
 
90
89
  #### Return Value
91
90
 
@@ -111,7 +110,7 @@ import { SplitText } from 'fetta/react';
111
110
  | `children` | `ReactElement` | — | Single element to split |
112
111
  | `onSplit` | `function` | — | Called after text is split |
113
112
  | `onResize` | `function` | — | Called on autoSplit re-split |
114
- | `options` | `object` | — | Split options (type, classes, mask, propIndex, willChange) |
113
+ | `options` | `object` | — | Split options (type, classes, mask, propIndex) |
115
114
  | `autoSplit` | `boolean` | `false` | Re-split on container resize |
116
115
  | `revertOnComplete` | `boolean` | `false` | Revert after animation completes |
117
116
  | `inView` | `boolean \| InViewOptions` | `false` | Enable viewport detection |
@@ -266,12 +265,6 @@ Requires:
266
265
  - `IntersectionObserver`
267
266
  - `Intl.Segmenter`
268
267
 
269
- ### Safari
270
-
271
- Kerning compensation is not available in Safari due to its Range API returning integer values instead of sub-pixel precision. Text splitting works normally, just without the margin adjustments.
272
-
273
- When using `revertOnComplete` with character splitting in Safari, font kerning is automatically disabled to prevent visual shift on revert.
274
-
275
268
  ## License
276
269
 
277
270
  MIT
@@ -63,7 +63,89 @@ var INLINE_ELEMENTS = /* @__PURE__ */ new Set([
63
63
  "u",
64
64
  "var"
65
65
  ]);
66
- var isSafari = typeof navigator !== "undefined" && /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
66
+ var isSafariBrowser = null;
67
+ function isSafari() {
68
+ if (isSafariBrowser !== null) return isSafariBrowser;
69
+ if (typeof navigator === "undefined") return false;
70
+ isSafariBrowser = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
71
+ return isSafariBrowser;
72
+ }
73
+ function measureKerningCanvas(element, chars) {
74
+ const kerningMap = /* @__PURE__ */ new Map();
75
+ if (chars.length < 2) return kerningMap;
76
+ const canvas = document.createElement("canvas");
77
+ const ctx = canvas.getContext("2d");
78
+ if (!ctx) return kerningMap;
79
+ const styles = getComputedStyle(element);
80
+ ctx.font = `${styles.fontStyle} ${styles.fontWeight} ${styles.fontSize} ${styles.fontFamily}`;
81
+ if (styles.letterSpacing && styles.letterSpacing !== "normal") {
82
+ ctx.letterSpacing = styles.letterSpacing;
83
+ }
84
+ if (styles.wordSpacing && styles.wordSpacing !== "normal") {
85
+ ctx.wordSpacing = styles.wordSpacing;
86
+ }
87
+ if ("fontVariantLigatures" in ctx) ctx.fontVariantLigatures = "none";
88
+ const charWidths = /* @__PURE__ */ new Map();
89
+ for (const char of new Set(chars)) {
90
+ charWidths.set(char, ctx.measureText(char).width);
91
+ }
92
+ for (let i = 0; i < chars.length - 1; i++) {
93
+ const char1 = chars[i];
94
+ const char2 = chars[i + 1];
95
+ const pairWidth = ctx.measureText(char1 + char2).width;
96
+ const kerning = pairWidth - charWidths.get(char1) - charWidths.get(char2);
97
+ if (Math.abs(kerning) > 0.01) {
98
+ kerningMap.set(i + 1, kerning);
99
+ }
100
+ }
101
+ return kerningMap;
102
+ }
103
+ function measureKerningDOM(element, chars) {
104
+ const kerningMap = /* @__PURE__ */ new Map();
105
+ if (chars.length < 2) return kerningMap;
106
+ const measurer = document.createElement("span");
107
+ measurer.style.cssText = `
108
+ position: absolute;
109
+ visibility: hidden;
110
+ white-space: pre;
111
+ `;
112
+ const styles = getComputedStyle(element);
113
+ measurer.style.font = styles.font;
114
+ measurer.style.letterSpacing = styles.letterSpacing;
115
+ measurer.style.wordSpacing = styles.wordSpacing;
116
+ measurer.style.fontKerning = styles.fontKerning;
117
+ measurer.style.fontVariantLigatures = "none";
118
+ const webkitSmoothing = styles.webkitFontSmoothing || styles["-webkit-font-smoothing"];
119
+ const mozSmoothing = styles.MozOsxFontSmoothing || styles["-moz-osx-font-smoothing"];
120
+ if (webkitSmoothing) {
121
+ measurer.style.webkitFontSmoothing = webkitSmoothing;
122
+ }
123
+ if (mozSmoothing) {
124
+ measurer.style.MozOsxFontSmoothing = mozSmoothing;
125
+ }
126
+ element.appendChild(measurer);
127
+ const charWidths = /* @__PURE__ */ new Map();
128
+ for (const char of new Set(chars)) {
129
+ measurer.textContent = char;
130
+ charWidths.set(char, measurer.getBoundingClientRect().width);
131
+ }
132
+ for (let i = 0; i < chars.length - 1; i++) {
133
+ const char1 = chars[i];
134
+ const char2 = chars[i + 1];
135
+ measurer.textContent = char1 + char2;
136
+ const pairWidth = measurer.getBoundingClientRect().width;
137
+ const kerning = pairWidth - charWidths.get(char1) - charWidths.get(char2);
138
+ if (Math.abs(kerning) > 0.01) {
139
+ kerningMap.set(i + 1, kerning);
140
+ }
141
+ }
142
+ element.removeChild(measurer);
143
+ return kerningMap;
144
+ }
145
+ function measureKerning(element, chars) {
146
+ if (chars.length < 2) return /* @__PURE__ */ new Map();
147
+ return isSafari() ? measureKerningDOM(element, chars) : measureKerningCanvas(element, chars);
148
+ }
67
149
  var srOnlyStylesInjected = false;
68
150
  function injectSrOnlyStyles() {
69
151
  if (srOnlyStylesInjected || typeof document === "undefined") return;
@@ -191,24 +273,20 @@ function buildAncestorChain(textNode, rootElement, ancestorCache) {
191
273
  }
192
274
  return ancestors;
193
275
  }
194
- function measureOriginalText(element, splitChars, trackAncestors) {
195
- const range = document.createRange();
276
+ function collectTextStructure(element, trackAncestors) {
196
277
  const words = [];
197
278
  const ancestorCache = trackAncestors ? /* @__PURE__ */ new WeakMap() : null;
198
279
  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
199
280
  let node;
200
281
  let currentWord = [];
201
- let wordStartLeft = null;
202
282
  let noSpaceBeforeNext = false;
203
283
  const pushWord = () => {
204
284
  if (currentWord.length > 0) {
205
285
  words.push({
206
286
  chars: currentWord,
207
- startLeft: wordStartLeft != null ? wordStartLeft : 0,
208
287
  noSpaceBefore: noSpaceBeforeNext
209
288
  });
210
289
  currentWord = [];
211
- wordStartLeft = null;
212
290
  noSpaceBeforeNext = false;
213
291
  }
214
292
  };
@@ -217,29 +295,16 @@ function measureOriginalText(element, splitChars, trackAncestors) {
217
295
  const text = node.textContent || "";
218
296
  const ancestors = trackAncestors ? buildAncestorChain(node, element, ancestorCache) : emptyAncestors;
219
297
  const graphemes = segmentGraphemes(text);
220
- let charOffset = 0;
221
298
  for (const grapheme of graphemes) {
222
299
  if (grapheme === " " || grapheme === "\n" || grapheme === " ") {
223
300
  pushWord();
224
- charOffset += grapheme.length;
225
301
  continue;
226
302
  }
227
- if (splitChars) {
228
- range.setStart(node, charOffset);
229
- range.setEnd(node, charOffset + grapheme.length);
230
- const rect = range.getBoundingClientRect();
231
- if (wordStartLeft === null) {
232
- wordStartLeft = rect.left;
233
- }
234
- currentWord.push({ char: grapheme, left: rect.left, ancestors });
235
- } else {
236
- currentWord.push({ char: grapheme, left: 0, ancestors });
237
- }
303
+ currentWord.push({ char: grapheme, ancestors });
238
304
  if (BREAK_CHARS.has(grapheme)) {
239
305
  pushWord();
240
306
  noSpaceBeforeNext = true;
241
307
  }
242
- charOffset += grapheme.length;
243
308
  }
244
309
  }
245
310
  pushWord();
@@ -259,9 +324,6 @@ function createSpan(className, index, display = "inline-block", options) {
259
324
  span.style.display = display;
260
325
  span.style.position = "relative";
261
326
  span.style.textDecoration = "inherit";
262
- if (options == null ? void 0 : options.willChange) {
263
- span.style.willChange = "transform, opacity";
264
- }
265
327
  if (options == null ? void 0 : options.ariaHidden) {
266
328
  span.setAttribute("aria-hidden", "true");
267
329
  }
@@ -311,7 +373,6 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
311
373
  measuredWords.forEach((measuredWord, wordIndex) => {
312
374
  const wordSpan = createSpan(wordClass, wordIndex, "inline-block", {
313
375
  propIndex: options == null ? void 0 : options.propIndex,
314
- willChange: options == null ? void 0 : options.willChange,
315
376
  propName: "word",
316
377
  ariaHidden: options == null ? void 0 : options.ariaHidden
317
378
  });
@@ -324,17 +385,11 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
324
385
  measuredWord.chars.forEach((measuredChar, charIndexInWord) => {
325
386
  const charSpan = createSpan(charClass, globalCharIndex, "inline-block", {
326
387
  propIndex: options == null ? void 0 : options.propIndex,
327
- willChange: options == null ? void 0 : options.willChange,
328
388
  propName: "char",
329
389
  ariaHidden: options == null ? void 0 : options.ariaHidden
330
390
  });
331
391
  charSpan.textContent = measuredChar.char;
332
392
  globalCharIndex++;
333
- if (charIndexInWord > 0) {
334
- const prevCharLeft = measuredWord.chars[charIndexInWord - 1].left;
335
- const gap = measuredChar.left - prevCharLeft;
336
- charSpan.dataset.expectedGap = gap.toString();
337
- }
338
393
  if ((options == null ? void 0 : options.mask) === "chars") {
339
394
  const charWrapper = createMaskWrapper("inline-block");
340
395
  charWrapper.appendChild(charSpan);
@@ -352,19 +407,13 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
352
407
  }
353
408
  charGroups.forEach((group) => {
354
409
  group.chars.forEach((measuredChar) => {
355
- const charIndexInWord = measuredWord.chars.indexOf(measuredChar);
410
+ measuredWord.chars.indexOf(measuredChar);
356
411
  const charSpan = createSpan(charClass, globalCharIndex, "inline-block", {
357
412
  propIndex: options == null ? void 0 : options.propIndex,
358
- willChange: options == null ? void 0 : options.willChange,
359
413
  propName: "char"
360
414
  });
361
415
  charSpan.textContent = measuredChar.char;
362
416
  globalCharIndex++;
363
- if (charIndexInWord > 0) {
364
- const prevCharLeft = measuredWord.chars[charIndexInWord - 1].left;
365
- const gap = measuredChar.left - prevCharLeft;
366
- charSpan.dataset.expectedGap = gap.toString();
367
- }
368
417
  if ((options == null ? void 0 : options.mask) === "chars") {
369
418
  const charWrapper = createMaskWrapper("inline-block");
370
419
  charWrapper.appendChild(charSpan);
@@ -461,21 +510,18 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
461
510
  i++;
462
511
  }
463
512
  }
464
- if (splitChars && allChars.length > 1 && !isSafari) {
465
- const positions = allChars.map((c) => c.getBoundingClientRect().left);
466
- for (let i2 = 1; i2 < allChars.length; i2++) {
467
- const charSpan = allChars[i2];
468
- const expectedGap = charSpan.dataset.expectedGap;
469
- if (expectedGap !== void 0) {
470
- const originalGap = parseFloat(expectedGap);
471
- const currentGap = positions[i2] - positions[i2 - 1];
472
- const delta = originalGap - currentGap;
473
- if (Math.abs(delta) < 20) {
474
- const roundedDelta = Math.round(delta * 100) / 100;
513
+ if (splitChars && allWords.length > 0) {
514
+ for (const wordSpan of allWords) {
515
+ const wordChars = Array.from(wordSpan.querySelectorAll(`.${charClass}`));
516
+ if (wordChars.length < 2) continue;
517
+ const charStrings = wordChars.map((c) => c.textContent || "");
518
+ const kerningMap = measureKerning(element, charStrings);
519
+ for (const [charIndex, kerning] of kerningMap) {
520
+ const charSpan = wordChars[charIndex];
521
+ if (charSpan && Math.abs(kerning) < 20) {
475
522
  const targetElement = (options == null ? void 0 : options.mask) === "chars" && charSpan.parentElement ? charSpan.parentElement : charSpan;
476
- targetElement.style.marginLeft = `${roundedDelta}px`;
523
+ targetElement.style.marginLeft = `${kerning}px`;
477
524
  }
478
- delete charSpan.dataset.expectedGap;
479
525
  }
480
526
  }
481
527
  }
@@ -486,7 +532,6 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
486
532
  lineGroups.forEach((words, lineIndex) => {
487
533
  const lineSpan = createSpan(lineClass, lineIndex, "block", {
488
534
  propIndex: options == null ? void 0 : options.propIndex,
489
- willChange: options == null ? void 0 : options.willChange,
490
535
  propName: "line",
491
536
  ariaHidden: options == null ? void 0 : options.ariaHidden
492
537
  });
@@ -583,7 +628,6 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
583
628
  lineGroups.forEach((wrappers, lineIndex) => {
584
629
  const lineSpan = createSpan(lineClass, lineIndex, "block", {
585
630
  propIndex: options == null ? void 0 : options.propIndex,
586
- willChange: options == null ? void 0 : options.willChange,
587
631
  propName: "line"
588
632
  });
589
633
  allLines.push(lineSpan);
@@ -625,8 +669,7 @@ function splitText(element, {
625
669
  onResize,
626
670
  onSplit,
627
671
  revertOnComplete = false,
628
- propIndex = false,
629
- willChange = true
672
+ propIndex = false
630
673
  } = {}) {
631
674
  var _a;
632
675
  if (!(element instanceof HTMLElement)) {
@@ -666,12 +709,8 @@ function splitText(element, {
666
709
  if (splitChars) {
667
710
  element.style.fontVariantLigatures = "none";
668
711
  }
669
- if (isSafari && splitChars && revertOnComplete) {
670
- element.style.fontKerning = "none";
671
- }
672
712
  const trackAncestors = hasInlineDescendants(element);
673
- const measureChars = splitChars && !isSafari;
674
- const measuredWords = measureOriginalText(element, measureChars, trackAncestors);
713
+ const measuredWords = collectTextStructure(element, trackAncestors);
675
714
  const { chars, words, lines } = performSplit(
676
715
  element,
677
716
  measuredWords,
@@ -681,7 +720,7 @@ function splitText(element, {
681
720
  splitChars,
682
721
  splitWords,
683
722
  splitLines,
684
- { propIndex, willChange, mask, ariaHidden: !trackAncestors }
723
+ { propIndex, mask, ariaHidden: !trackAncestors }
685
724
  );
686
725
  currentChars = chars;
687
726
  currentWords = words;
@@ -720,9 +759,6 @@ function splitText(element, {
720
759
  if (splitChars) {
721
760
  element.style.fontVariantLigatures = "none";
722
761
  }
723
- if (isSafari && splitChars && revertOnComplete) {
724
- element.style.fontKerning = "none";
725
- }
726
762
  dispose();
727
763
  };
728
764
  if (autoSplit) {
@@ -749,7 +785,7 @@ function splitText(element, {
749
785
  element.innerHTML = originalHTML;
750
786
  requestAnimationFrame(() => {
751
787
  if (!isActive) return;
752
- const newMeasuredWords = measureOriginalText(element, measureChars, trackAncestors);
788
+ const newMeasuredWords = collectTextStructure(element, trackAncestors);
753
789
  const result = performSplit(
754
790
  element,
755
791
  newMeasuredWords,
@@ -759,7 +795,7 @@ function splitText(element, {
759
795
  splitChars,
760
796
  splitWords,
761
797
  splitLines,
762
- { propIndex, willChange, mask, ariaHidden: !trackAncestors }
798
+ { propIndex, mask, ariaHidden: !trackAncestors }
763
799
  );
764
800
  currentChars = result.chars;
765
801
  currentWords = result.words;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Custom splitText implementation with built-in kerning compensation.
3
- * Measures character positions before splitting, applies compensation,
4
- * then detects lines based on actual rendered positions.
3
+ * Measures kerning between character pairs, splits text into spans,
4
+ * applies margin compensation, and detects lines based on rendered positions.
5
5
  */
6
6
  /**
7
7
  * Configuration options for the splitText function.
@@ -42,8 +42,6 @@ interface SplitTextOptions {
42
42
  revertOnComplete?: boolean;
43
43
  /** Add CSS custom properties (--char-index, --word-index, --line-index) */
44
44
  propIndex?: boolean;
45
- /** Add will-change: transform, opacity to split elements for better animation performance (default: true) */
46
- willChange?: boolean;
47
45
  }
48
46
  /**
49
47
  * Result returned by splitText containing arrays of split elements and a revert function.
@@ -110,6 +108,6 @@ interface SplitTextResult {
110
108
  * });
111
109
  * ```
112
110
  */
113
- declare function splitText(element: HTMLElement, { type, charClass, wordClass, lineClass, mask, autoSplit, onResize, onSplit, revertOnComplete, propIndex, willChange, }?: SplitTextOptions): SplitTextResult;
111
+ declare function splitText(element: HTMLElement, { type, charClass, wordClass, lineClass, mask, autoSplit, onResize, onSplit, revertOnComplete, propIndex, }?: SplitTextOptions): SplitTextResult;
114
112
 
115
113
  export { type SplitTextOptions, type SplitTextResult, splitText };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { splitText } from './chunk-6EZFFMJ3.js';
1
+ export { splitText } from './chunk-PRR25BMJ.js';
package/dist/react.d.ts CHANGED
@@ -10,7 +10,6 @@ interface SplitTextOptions {
10
10
  /** Apply overflow mask wrapper to elements for reveal animations */
11
11
  mask?: "lines" | "words" | "chars";
12
12
  propIndex?: boolean;
13
- willChange?: boolean;
14
13
  }
15
14
  interface InViewOptions {
16
15
  /** How much of the element must be visible (0-1). Default: 0 */
package/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- import { splitText, __spreadProps, __spreadValues, normalizeToPromise } from './chunk-6EZFFMJ3.js';
1
+ import { splitText, __spreadProps, __spreadValues, normalizeToPromise } from './chunk-PRR25BMJ.js';
2
2
  import { forwardRef, useRef, useCallback, useState, useLayoutEffect, useEffect, isValidElement, cloneElement } from 'react';
3
3
  import { jsx } from 'react/jsx-runtime';
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fetta",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Text splitting library with kerning compensation for animations",
5
5
  "type": "module",
6
6
  "sideEffects": false,