@daformat/react-number-flow-input 1.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/LICENSE +14 -0
- package/README.md +298 -0
- package/dist/NumberFlowInput.d.ts +60 -0
- package/dist/NumberFlowInput.js +3099 -0
- package/dist/NumberFlowInput.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.d.ts +5 -0
- package/dist/styles.js +140 -0
- package/dist/styles.js.map +1 -0
- package/dist/utils/barrelWheel.d.ts +12 -0
- package/dist/utils/barrelWheel.js +83 -0
- package/dist/utils/barrelWheel.js.map +1 -0
- package/dist/utils/changes.d.ts +87 -0
- package/dist/utils/changes.js +794 -0
- package/dist/utils/changes.js.map +1 -0
- package/dist/utils/combineRefs.d.ts +5 -0
- package/dist/utils/combineRefs.js +16 -0
- package/dist/utils/combineRefs.js.map +1 -0
- package/dist/utils/cssEasing.d.ts +24 -0
- package/dist/utils/cssEasing.js +25 -0
- package/dist/utils/cssEasing.js.map +1 -0
- package/dist/utils/formatting.d.ts +33 -0
- package/dist/utils/formatting.js +99 -0
- package/dist/utils/formatting.js.map +1 -0
- package/dist/utils/maybe.d.ts +3 -0
- package/dist/utils/maybe.js +2 -0
- package/dist/utils/maybe.js.map +1 -0
- package/dist/utils/moveElementPreservingAnimation.d.ts +6 -0
- package/dist/utils/moveElementPreservingAnimation.js +61 -0
- package/dist/utils/moveElementPreservingAnimation.js.map +1 -0
- package/dist/utils/nullable.d.ts +12 -0
- package/dist/utils/nullable.js +19 -0
- package/dist/utils/nullable.js.map +1 -0
- package/dist/utils/textCleaning.d.ts +6 -0
- package/dist/utils/textCleaning.js +60 -0
- package/dist/utils/textCleaning.js.map +1 -0
- package/dist/utils/utils.d.ts +33 -0
- package/dist/utils/utils.js +162 -0
- package/dist/utils/utils.js.map +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,3099 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState, } from "react";
|
|
3
|
+
import { injectStyles } from "./styles.js";
|
|
4
|
+
import { cleanupWidthAnimation, clearBarrelWheelsAndSpans, getAllBarrelWheels, getBarrelWheel, repositionBarrelWheel, setWidthConstraints, temporarilyRemoveAncestorsTransform, } from "./utils/barrelWheel.js";
|
|
5
|
+
import { getChanges, getFormattedChanges, getPositionChanges, getReplacementChanges, getReplacementFormattedChanges, } from "./utils/changes.js";
|
|
6
|
+
import { combineRefs } from "./utils/combineRefs.js";
|
|
7
|
+
import { formatValue, getLocaleSeparators, isRawCharacter, } from "./utils/formatting.js";
|
|
8
|
+
import { moveElementPreservingAnimation } from "./utils/moveElementPreservingAnimation.js";
|
|
9
|
+
import { isNonNullable } from "./utils/nullable.js";
|
|
10
|
+
import { cleanText, parseNumberValue } from "./utils/textCleaning.js";
|
|
11
|
+
import { clearWidthStyles, getSelectionRange, hasWidthStyles, isTransparent, measureText, removeTransparentColor, setCursorAtPosition, setCursorPositionInElement, } from "./utils/utils.js";
|
|
12
|
+
export const NumberFlowInput = forwardRef(({ value, defaultValue, onChange, autoAddLeadingZero = false, allowNegative, decimalScale, placeholder, locale, format = false, onFocus, onBlur, className, style, isAllowed, autoFocus = false, ...inputProps }, ref) => {
|
|
13
|
+
// Inject the component's stylesheet exactly once, before any layout
|
|
14
|
+
// effects run so the styles are in place for the very first paint.
|
|
15
|
+
useInsertionEffect(() => {
|
|
16
|
+
injectStyles();
|
|
17
|
+
}, []);
|
|
18
|
+
const spanRef = useRef(null);
|
|
19
|
+
const inputRef = useRef(null);
|
|
20
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
21
|
+
const isControlled = value !== undefined;
|
|
22
|
+
const actualValue = isControlled ? value : uncontrolledValue;
|
|
23
|
+
// Raw display value (unformatted, e.g., "1234.56")
|
|
24
|
+
const [displayValue, setDisplayValue] = useState(actualValue?.toString() ?? "");
|
|
25
|
+
const [, setCursorPosition] = useState(0);
|
|
26
|
+
const { maxLength } = inputProps;
|
|
27
|
+
// Get separators for the locale (or default locale if format is true)
|
|
28
|
+
const separators = useMemo(() => {
|
|
29
|
+
if (locale || format) {
|
|
30
|
+
return getLocaleSeparators(locale);
|
|
31
|
+
}
|
|
32
|
+
return { decimal: ".", group: "," };
|
|
33
|
+
}, [locale, format]);
|
|
34
|
+
const localeSeparators = useMemo(() => {
|
|
35
|
+
return getLocaleSeparators(locale);
|
|
36
|
+
}, [locale]);
|
|
37
|
+
// Format options for the formatValue function
|
|
38
|
+
const formatOptions = useMemo(() => ({ locale, format, autoAddLeadingZero, separators }), [locale, format, autoAddLeadingZero, separators]);
|
|
39
|
+
// Compute the formatted display value
|
|
40
|
+
const formattedDisplayValue = useMemo(() => formatValue(displayValue, formatOptions), [displayValue, formatOptions]);
|
|
41
|
+
// Track previous formatted value for change detection
|
|
42
|
+
const prevFormattedValueRef = useRef(formattedDisplayValue);
|
|
43
|
+
// Undo/Redo history - stores cursor position before and after each change
|
|
44
|
+
const historyRef = useRef([]);
|
|
45
|
+
const historyIndexRef = useRef(-1);
|
|
46
|
+
const isUndoRedoRef = useRef(false);
|
|
47
|
+
// Track if we should prevent the next input event (for leading 0 bug)
|
|
48
|
+
const shouldPreventInputRef = useRef(false);
|
|
49
|
+
const preventInputCursorPosRef = useRef(0);
|
|
50
|
+
// Track ResizeObservers for digits with barrel wheel animations
|
|
51
|
+
const resizeObserversRef = useRef(new Map());
|
|
52
|
+
// Helper to check if a character is a "raw" character (digit, decimal, or minus)
|
|
53
|
+
const isRawChar = useCallback((char) => isRawCharacter(char, separators.decimal), [separators]);
|
|
54
|
+
// Helper to map a raw index to a formatted index
|
|
55
|
+
// Raw: "1234.56" -> Formatted: "1,234.56"
|
|
56
|
+
// Raw index 0 (1) -> Formatted index 0
|
|
57
|
+
// Raw index 1 (2) -> Formatted index 2 (after comma)
|
|
58
|
+
const mapRawToFormattedIndex = useCallback((rawValue, formattedValue, rawIndex) => {
|
|
59
|
+
if (rawIndex <= 0) {
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
if (rawIndex >= rawValue.length) {
|
|
63
|
+
return formattedValue.length;
|
|
64
|
+
}
|
|
65
|
+
// Count how many raw characters we've seen to find the rawIndex'th one
|
|
66
|
+
let rawCount = 0;
|
|
67
|
+
for (let formattedIndex = 0; formattedIndex < formattedValue.length; formattedIndex++) {
|
|
68
|
+
const formattedChar = formattedValue[formattedIndex];
|
|
69
|
+
// Check if this is a raw character (digit, decimal, minus) vs format character (comma, space, etc.)
|
|
70
|
+
if (isRawChar(formattedChar)) {
|
|
71
|
+
if (rawCount === rawIndex) {
|
|
72
|
+
// Found the rawIndex'th raw character
|
|
73
|
+
return formattedIndex;
|
|
74
|
+
}
|
|
75
|
+
rawCount++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return formattedValue.length;
|
|
79
|
+
}, [isRawChar]);
|
|
80
|
+
// Helper to map a formatted index to a raw index
|
|
81
|
+
const mapFormattedToRawIndex = useCallback((rawValue, formattedValue, formattedIndex) => {
|
|
82
|
+
if (formattedIndex <= 0) {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
if (formattedIndex >= formattedValue.length) {
|
|
86
|
+
return rawValue.length;
|
|
87
|
+
}
|
|
88
|
+
let rawIndex = 0;
|
|
89
|
+
for (let i = 0; i < formattedIndex && i < formattedValue.length; i++) {
|
|
90
|
+
const char = formattedValue[i];
|
|
91
|
+
if (isRawChar(char)) {
|
|
92
|
+
rawIndex++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return Math.min(rawIndex, rawValue.length);
|
|
96
|
+
}, [isRawChar]);
|
|
97
|
+
// Helper to format a raw value string (raw always uses '.' as decimal)
|
|
98
|
+
const formatRawValue = useCallback((rawValue) => formatValue(rawValue, formatOptions), [formatOptions]);
|
|
99
|
+
const addToHistory = useCallback((text, cursorPosBefore, cursorPosAfter, value) => {
|
|
100
|
+
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
|
|
101
|
+
historyRef.current.push({
|
|
102
|
+
text,
|
|
103
|
+
cursorPosBefore,
|
|
104
|
+
cursorPosAfter,
|
|
105
|
+
value,
|
|
106
|
+
});
|
|
107
|
+
historyIndexRef.current = historyRef.current.length - 1;
|
|
108
|
+
if (historyRef.current.length > 50) {
|
|
109
|
+
historyRef.current.shift();
|
|
110
|
+
historyIndexRef.current--;
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
113
|
+
// Helper function to reposition all existing barrel wheels
|
|
114
|
+
const repositionAllBarrelWheels = useCallback(() => {
|
|
115
|
+
if (!spanRef.current?.parentElement) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const parentContainer = spanRef.current.parentElement;
|
|
119
|
+
const existingBarrelWheels = parentContainer.querySelectorAll("[data-char-index][data-barrel-wheel]");
|
|
120
|
+
if (existingBarrelWheels.length === 0) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current.parentElement);
|
|
124
|
+
// Get all spans in DOM order and calculate their FINAL widths using measureText
|
|
125
|
+
// This is needed because some spans may be animating their width from 0
|
|
126
|
+
const allSpans = Array.from(spanRef.current.querySelectorAll("[data-char-index]"));
|
|
127
|
+
// Sort by data-char-index to ensure correct order
|
|
128
|
+
allSpans.sort((a, b) => {
|
|
129
|
+
const aIndex = parseInt(a.getAttribute("data-char-index") ?? "0", 10);
|
|
130
|
+
const bIndex = parseInt(b.getAttribute("data-char-index") ?? "0", 10);
|
|
131
|
+
return aIndex - bIndex;
|
|
132
|
+
});
|
|
133
|
+
// Calculate final widths for all spans using measureText
|
|
134
|
+
const spanWidths = new Map();
|
|
135
|
+
allSpans.forEach((span) => {
|
|
136
|
+
const index = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
|
|
137
|
+
if (index >= 0 && span.textContent) {
|
|
138
|
+
// Use measureText to get the accurate final width
|
|
139
|
+
const finalWidth = measureText(span.textContent, span);
|
|
140
|
+
spanWidths.set(index, finalWidth);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
// Get the container's position for relative positioning
|
|
144
|
+
const containerRect = spanRef.current.getBoundingClientRect();
|
|
145
|
+
const parentRect = parentContainer.getBoundingClientRect();
|
|
146
|
+
existingBarrelWheels.forEach((wheel) => {
|
|
147
|
+
const wheelEl = wheel;
|
|
148
|
+
const indexStr = wheelEl.getAttribute("data-char-index");
|
|
149
|
+
if (indexStr !== null) {
|
|
150
|
+
const barrelIndex = parseInt(indexStr, 10);
|
|
151
|
+
if (!isNaN(barrelIndex) && barrelIndex >= 0 && spanRef.current) {
|
|
152
|
+
const charSpan = spanRef.current.querySelector(`[data-char-index="${barrelIndex}"]`);
|
|
153
|
+
if (charSpan) {
|
|
154
|
+
if (!isTransparent(charSpan)) {
|
|
155
|
+
charSpan.style.color = "transparent";
|
|
156
|
+
}
|
|
157
|
+
// Calculate the left position by summing widths of all preceding spans
|
|
158
|
+
let leftPosition = containerRect.left - parentRect.left;
|
|
159
|
+
for (let i = 0; i < barrelIndex; i++) {
|
|
160
|
+
const width = spanWidths.get(i);
|
|
161
|
+
if (width !== undefined) {
|
|
162
|
+
leftPosition += width;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Get the barrel wheel span's dimensions
|
|
166
|
+
const barrelSpanWidth = spanWidths.get(barrelIndex) ?? 0;
|
|
167
|
+
const barrelSpanHeight = charSpan.getBoundingClientRect().height;
|
|
168
|
+
// Position the barrel wheel
|
|
169
|
+
wheelEl.style.left = `${leftPosition}px`;
|
|
170
|
+
wheelEl.style.top = `${containerRect.top - parentRect.top}px`;
|
|
171
|
+
wheelEl.style.width = `${barrelSpanWidth}px`;
|
|
172
|
+
wheelEl.style.height = `${barrelSpanHeight}px`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
cleanup();
|
|
178
|
+
}, []);
|
|
179
|
+
// Helper function to remove barrel wheels at specific indices
|
|
180
|
+
const removeBarrelWheelsAtIndices = useCallback((indices) => {
|
|
181
|
+
if (!spanRef.current) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const parentContainer = spanRef.current.parentElement;
|
|
185
|
+
if (!parentContainer) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
indices.forEach((index) => {
|
|
189
|
+
// Clean up ResizeObserver for this index
|
|
190
|
+
const observer = resizeObserversRef.current.get(index);
|
|
191
|
+
if (observer) {
|
|
192
|
+
observer.disconnect();
|
|
193
|
+
resizeObserversRef.current.delete(index);
|
|
194
|
+
}
|
|
195
|
+
const charSpan = spanRef.current?.querySelector(`[data-char-index="${index}"]`);
|
|
196
|
+
if (charSpan) {
|
|
197
|
+
if (charSpan.hasAttribute("data-width-animate")) {
|
|
198
|
+
cleanupWidthAnimation(charSpan);
|
|
199
|
+
}
|
|
200
|
+
if (isTransparent(charSpan)) {
|
|
201
|
+
charSpan.style.color = "";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const barrelWheel = parentContainer.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
|
|
205
|
+
if (barrelWheel) {
|
|
206
|
+
barrelWheel.remove();
|
|
207
|
+
// After removing barrel wheel, do a final pass to ensure the span at THIS index is not still transparent
|
|
208
|
+
// IMPORTANT: Only check the span at the specific index, not other spans with the same final digit
|
|
209
|
+
// This prevents conflicts when multiple barrel wheels have the same final digit
|
|
210
|
+
if (spanRef.current) {
|
|
211
|
+
const spanAtIndex = spanRef.current.querySelector(`[data-char-index="${index}"]`);
|
|
212
|
+
if (spanAtIndex) {
|
|
213
|
+
const hasBarrelWheel = parentContainer.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
|
|
214
|
+
if (isTransparent(spanAtIndex) && !hasBarrelWheel) {
|
|
215
|
+
spanAtIndex.style.color = "";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}, []);
|
|
222
|
+
const updateValue = useCallback((newText, newCursorPos, selectionStart, selectionEnd, options = {}) => {
|
|
223
|
+
const { skipHistory = false, skipOnChange = false, skipCursor = false, asReplacement = false, } = options;
|
|
224
|
+
if (isAllowed && !isAllowed(Number(newText))) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Clean up stale width animations from fast typing
|
|
228
|
+
if (spanRef.current) {
|
|
229
|
+
spanRef.current
|
|
230
|
+
.querySelectorAll("[data-char-index][data-show]")
|
|
231
|
+
.forEach((span) => {
|
|
232
|
+
const el = span;
|
|
233
|
+
if (hasWidthStyles(el) &&
|
|
234
|
+
!el.hasAttribute("data-width-animate")) {
|
|
235
|
+
clearWidthStyles(el);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const oldText = displayValue;
|
|
240
|
+
const rawCleaned = newText.replace(/[^\d.-]/g, "");
|
|
241
|
+
const { cleanedText: baseCleanedText, leadingZerosRemoved } = cleanText(rawCleaned, autoAddLeadingZero);
|
|
242
|
+
const cleanedText = baseCleanedText;
|
|
243
|
+
if (leadingZerosRemoved > 0 && newCursorPos > 0) {
|
|
244
|
+
newCursorPos = Math.max(0, newCursorPos - leadingZerosRemoved);
|
|
245
|
+
}
|
|
246
|
+
if (autoAddLeadingZero) {
|
|
247
|
+
// Check rawCleaned (before leading zero was added) to detect if we added a leading zero
|
|
248
|
+
if (rawCleaned.startsWith(".")) {
|
|
249
|
+
newCursorPos += 1;
|
|
250
|
+
}
|
|
251
|
+
else if (rawCleaned.startsWith("-.")) {
|
|
252
|
+
newCursorPos += 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const numberValue = parseNumberValue(cleanedText);
|
|
256
|
+
if (!skipOnChange) {
|
|
257
|
+
onChange?.(numberValue);
|
|
258
|
+
}
|
|
259
|
+
setUncontrolledValue(numberValue);
|
|
260
|
+
setDisplayValue(cleanedText);
|
|
261
|
+
setCursorPosition(newCursorPos);
|
|
262
|
+
if (!skipHistory && !isUndoRedoRef.current) {
|
|
263
|
+
addToHistory(cleanedText, selectionEnd, newCursorPos, numberValue);
|
|
264
|
+
}
|
|
265
|
+
// Update DOM with animation
|
|
266
|
+
if (spanRef.current) {
|
|
267
|
+
// Special handling for leading zero removal and decimal point deletion
|
|
268
|
+
let adjustedOldText = oldText;
|
|
269
|
+
let adjustedSelectionStart = selectionStart;
|
|
270
|
+
let adjustedSelectionEnd = selectionEnd;
|
|
271
|
+
let adjustedNewCursorPos = newCursorPos;
|
|
272
|
+
// Check if we deleted a decimal point that was after a leading zero (e.g., "0.122" -> "122")
|
|
273
|
+
const deletedDecimalAfterZero = oldText.startsWith("0.") &&
|
|
274
|
+
!cleanedText.startsWith("0") &&
|
|
275
|
+
cleanedText.length > 0 &&
|
|
276
|
+
oldText.length > cleanedText.length &&
|
|
277
|
+
oldText.includes(".") &&
|
|
278
|
+
!cleanedText.includes(".");
|
|
279
|
+
// Check if we're replacing a single leading "0" with a non-zero digit (e.g., "0" -> "1")
|
|
280
|
+
const replacedLeadingZero = oldText === "0" &&
|
|
281
|
+
cleanedText.length > 0 &&
|
|
282
|
+
cleanedText[0] !== "0" &&
|
|
283
|
+
!cleanedText.startsWith("0.");
|
|
284
|
+
if (deletedDecimalAfterZero) {
|
|
285
|
+
// When deleting "." from "0.122", we get "0122" which becomes "122"
|
|
286
|
+
// We want all digits in "122" to have data-show, so we compare "" with "122"
|
|
287
|
+
adjustedOldText = "";
|
|
288
|
+
adjustedSelectionStart = 0;
|
|
289
|
+
adjustedSelectionEnd = 0;
|
|
290
|
+
adjustedNewCursorPos = cleanedText.length;
|
|
291
|
+
}
|
|
292
|
+
else if (replacedLeadingZero) {
|
|
293
|
+
// Special case: "0" -> "1" (or any non-zero digit)
|
|
294
|
+
// We want to treat this as if we're starting from scratch
|
|
295
|
+
adjustedOldText = "";
|
|
296
|
+
adjustedSelectionStart = 0;
|
|
297
|
+
adjustedSelectionEnd = 0;
|
|
298
|
+
adjustedNewCursorPos = cleanedText.length;
|
|
299
|
+
}
|
|
300
|
+
else if (leadingZerosRemoved > 0 && oldText.length > 0) {
|
|
301
|
+
// If we removed leading zeros, adjust the oldText comparison
|
|
302
|
+
if (oldText.startsWith("0") &&
|
|
303
|
+
oldText.length > 1 &&
|
|
304
|
+
oldText[1] !== ".") {
|
|
305
|
+
// More general case: if oldText was "0123" and we typed "4" to get "01234" which became "1234",
|
|
306
|
+
// we need to adjust the comparison
|
|
307
|
+
const oldWithoutLeadingZeros = oldText.replace(/^0+/, "");
|
|
308
|
+
if (oldWithoutLeadingZeros ===
|
|
309
|
+
cleanedText.slice(0, oldWithoutLeadingZeros.length)) {
|
|
310
|
+
// The old text (without leading zeros) matches the start of new text
|
|
311
|
+
// This means we just added characters at the end
|
|
312
|
+
adjustedOldText = oldWithoutLeadingZeros;
|
|
313
|
+
// Adjust selection and cursor positions to account for removed leading zeros
|
|
314
|
+
adjustedSelectionStart = Math.max(0, selectionStart - leadingZerosRemoved);
|
|
315
|
+
adjustedSelectionEnd = Math.max(0, selectionEnd - leadingZerosRemoved);
|
|
316
|
+
adjustedNewCursorPos = Math.max(0, newCursorPos - leadingZerosRemoved);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Compute formatted versions for display
|
|
321
|
+
const oldFormattedText = prevFormattedValueRef.current;
|
|
322
|
+
const newFormattedText = formatRawValue(cleanedText);
|
|
323
|
+
// When invoked as a wholesale replacement (e.g. `value` prop
|
|
324
|
+
// changed externally), align digits column-by-column so changed
|
|
325
|
+
// digits play barrel-wheel animations instead of all being treated
|
|
326
|
+
// as added.
|
|
327
|
+
const changes = asReplacement
|
|
328
|
+
? getReplacementChanges(adjustedOldText, cleanedText)
|
|
329
|
+
: getChanges(adjustedOldText, cleanedText, adjustedSelectionStart, adjustedSelectionEnd, adjustedNewCursorPos);
|
|
330
|
+
const formattedChanges = asReplacement
|
|
331
|
+
? getReplacementFormattedChanges(oldFormattedText, newFormattedText, separators.decimal)
|
|
332
|
+
: getFormattedChanges(oldFormattedText, newFormattedText, adjustedNewCursorPos, adjustedSelectionStart, adjustedOldText.length, separators.decimal);
|
|
333
|
+
// Detect position changes for x-position animation (used later)
|
|
334
|
+
const positionChanges = getPositionChanges(oldFormattedText, newFormattedText, separators.decimal);
|
|
335
|
+
// For FLIP animation: capture old positions BEFORE any DOM changes
|
|
336
|
+
// Store by character + formatted index
|
|
337
|
+
const oldPositions = new Map();
|
|
338
|
+
if (spanRef.current) {
|
|
339
|
+
const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current);
|
|
340
|
+
const containerRect = spanRef.current.getBoundingClientRect();
|
|
341
|
+
for (let i = 0; i < oldFormattedText.length; i++) {
|
|
342
|
+
const char = oldFormattedText[i];
|
|
343
|
+
// Find the span for this index
|
|
344
|
+
const span = spanRef.current.querySelector(`[data-char-index="${i}"]`);
|
|
345
|
+
// Verify span exists and content matches (DOM might be out of sync)
|
|
346
|
+
if (span && char !== undefined && span.textContent === char) {
|
|
347
|
+
const rect = span.getBoundingClientRect();
|
|
348
|
+
// Key: char + its position in the old formatted string
|
|
349
|
+
const key = `${char}@${i}`;
|
|
350
|
+
oldPositions.set(key, {
|
|
351
|
+
x: rect.left - containerRect.left,
|
|
352
|
+
width: rect.width,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
cleanup();
|
|
357
|
+
}
|
|
358
|
+
// Update the previous formatted value ref for next comparison
|
|
359
|
+
// so barrel wheel and span shifting logic will use the correct old value
|
|
360
|
+
prevFormattedValueRef.current = newFormattedText;
|
|
361
|
+
// Update barrel wheel indices if characters were inserted or deleted before them
|
|
362
|
+
// IMPORTANT: Barrel wheel indices are FORMATTED indices (include separators)
|
|
363
|
+
// We need to map raw selection positions to formatted positions for correct comparison
|
|
364
|
+
if (spanRef.current?.parentElement) {
|
|
365
|
+
const parentContainer = spanRef.current.parentElement;
|
|
366
|
+
const existingBarrelWheels = parentContainer.querySelectorAll("[data-char-index][data-barrel-wheel]");
|
|
367
|
+
// Note: oldFormattedText is captured above before updating prevFormattedValueRef
|
|
368
|
+
existingBarrelWheels.forEach((wheel) => {
|
|
369
|
+
const wheelEl = wheel;
|
|
370
|
+
const oldFormattedIndexStr = wheelEl.getAttribute("data-char-index");
|
|
371
|
+
const finalDigitStr = wheelEl.getAttribute("data-final-digit");
|
|
372
|
+
if (oldFormattedIndexStr !== null) {
|
|
373
|
+
const oldFormattedIndex = parseInt(oldFormattedIndexStr, 10);
|
|
374
|
+
if (!isNaN(oldFormattedIndex) && oldFormattedIndex >= 0) {
|
|
375
|
+
// Convert barrel wheel's formatted index to raw index
|
|
376
|
+
const barrelWheelRawIndex = mapFormattedToRawIndex(adjustedOldText, oldFormattedText, oldFormattedIndex);
|
|
377
|
+
const lengthDiff = cleanedText.length - adjustedOldText.length;
|
|
378
|
+
// hadSelection is true when user had text selected (replacement creates barrel wheel at new position)
|
|
379
|
+
// For single-char insertions, adjustedSelectionStart === adjustedSelectionEnd
|
|
380
|
+
const hadUserSelection = adjustedSelectionStart < adjustedSelectionEnd &&
|
|
381
|
+
lengthDiff >= 0; // Only for insertions/replacements, not deletions
|
|
382
|
+
if (!hadUserSelection &&
|
|
383
|
+
lengthDiff > 0 &&
|
|
384
|
+
adjustedSelectionStart <= barrelWheelRawIndex) {
|
|
385
|
+
// Characters were inserted at or before this index, so shift it forward
|
|
386
|
+
// Calculate new raw index
|
|
387
|
+
const numInserted = adjustedNewCursorPos - adjustedSelectionStart;
|
|
388
|
+
const newRawIndex = barrelWheelRawIndex + numInserted;
|
|
389
|
+
// Map back to formatted index
|
|
390
|
+
const newFormattedIndex = mapRawToFormattedIndex(cleanedText, newFormattedText, newRawIndex);
|
|
391
|
+
wheelEl.setAttribute("data-char-index", newFormattedIndex.toString());
|
|
392
|
+
const observer = resizeObserversRef.current.get(oldFormattedIndex);
|
|
393
|
+
if (observer) {
|
|
394
|
+
resizeObserversRef.current.delete(oldFormattedIndex);
|
|
395
|
+
resizeObserversRef.current.set(newFormattedIndex, observer);
|
|
396
|
+
}
|
|
397
|
+
if (finalDigitStr && spanRef.current) {
|
|
398
|
+
const oldSpan = spanRef.current.querySelector(`[data-char-index="${oldFormattedIndex}"]`);
|
|
399
|
+
if (oldSpan && oldSpan.textContent === finalDigitStr) {
|
|
400
|
+
oldSpan.setAttribute("data-char-index", newFormattedIndex.toString());
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
else if (lengthDiff < 0 &&
|
|
405
|
+
adjustedSelectionStart < barrelWheelRawIndex) {
|
|
406
|
+
// Characters were deleted before this index, so shift it backward
|
|
407
|
+
// Note: No hadUserSelection check for deletions - always shift barrel wheels after deletion point
|
|
408
|
+
const numDeleted = adjustedSelectionEnd - adjustedSelectionStart;
|
|
409
|
+
const newRawIndex = Math.max(0, barrelWheelRawIndex - numDeleted);
|
|
410
|
+
// Only update if the new index is valid and the barrel wheel should still exist
|
|
411
|
+
// Also ensure the barrel wheel is not in the deletion range
|
|
412
|
+
if (newRawIndex < cleanedText.length &&
|
|
413
|
+
barrelWheelRawIndex >= adjustedSelectionEnd) {
|
|
414
|
+
// Map back to formatted index
|
|
415
|
+
const newFormattedIndex = mapRawToFormattedIndex(cleanedText, newFormattedText, newRawIndex);
|
|
416
|
+
wheelEl.setAttribute("data-char-index", newFormattedIndex.toString());
|
|
417
|
+
const observer = resizeObserversRef.current.get(oldFormattedIndex);
|
|
418
|
+
if (observer) {
|
|
419
|
+
resizeObserversRef.current.delete(oldFormattedIndex);
|
|
420
|
+
resizeObserversRef.current.set(newFormattedIndex, observer);
|
|
421
|
+
}
|
|
422
|
+
if (finalDigitStr && spanRef.current) {
|
|
423
|
+
// Find the span that matches the final digit - it might still be at oldIndex
|
|
424
|
+
// or it might have already been shifted
|
|
425
|
+
const oldSpan = spanRef.current.querySelector(`[data-char-index="${oldFormattedIndex}"]`);
|
|
426
|
+
if (oldSpan && oldSpan.textContent === finalDigitStr) {
|
|
427
|
+
oldSpan.setAttribute("data-char-index", newFormattedIndex.toString());
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
// Also check if there's a span at the new index that matches
|
|
431
|
+
const newSpan = spanRef.current.querySelector(`[data-char-index="${newFormattedIndex}"]`);
|
|
432
|
+
if (newSpan &&
|
|
433
|
+
newSpan.textContent === finalDigitStr) {
|
|
434
|
+
// Span is already at the correct index, just ensure it's marked correctly
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
// Search for any transparent span with the final digit
|
|
438
|
+
const allSpans = spanRef.current.querySelectorAll("[data-char-index]");
|
|
439
|
+
Array.from(allSpans).forEach((span) => {
|
|
440
|
+
const spanEl = span;
|
|
441
|
+
if (spanEl.textContent === finalDigitStr &&
|
|
442
|
+
isTransparent(spanEl)) {
|
|
443
|
+
spanEl.setAttribute("data-char-index", newFormattedIndex.toString());
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
// Barrel wheel is now out of bounds, remove it
|
|
452
|
+
const observer = resizeObserversRef.current.get(oldFormattedIndex);
|
|
453
|
+
if (observer) {
|
|
454
|
+
observer.disconnect();
|
|
455
|
+
resizeObserversRef.current.delete(oldFormattedIndex);
|
|
456
|
+
}
|
|
457
|
+
wheelEl.remove();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
// Incrementally update DOM instead of full reconstruction
|
|
465
|
+
if (spanRef.current) {
|
|
466
|
+
// First, update indices of existing spans that need to shift due to insertions or deletions
|
|
467
|
+
// This handles the case where characters are inserted/deleted before existing spans
|
|
468
|
+
// Do this BEFORE collecting spans by index, so the map is correct
|
|
469
|
+
// IMPORTANT: Span indices are FORMATTED (include separators), but selection positions are RAW
|
|
470
|
+
// We need to map between them correctly
|
|
471
|
+
// Note: oldFormattedText is captured at the start of updateValue, before prevFormattedValueRef update
|
|
472
|
+
const lengthDiff = cleanedText.length - adjustedOldText.length;
|
|
473
|
+
// For insertions, hadSelection means user had text selected (replacement scenario)
|
|
474
|
+
// For deletions, we always want to shift spans, so don't check hadSelection
|
|
475
|
+
const hadSelectionForInsert = adjustedSelectionStart < adjustedSelectionEnd && lengthDiff > 0;
|
|
476
|
+
if (lengthDiff !== 0 &&
|
|
477
|
+
(lengthDiff < 0 || !hadSelectionForInsert)) {
|
|
478
|
+
// Collect all spans first
|
|
479
|
+
const allSpans = [];
|
|
480
|
+
let nodeToUpdate = spanRef.current.firstChild;
|
|
481
|
+
while (nodeToUpdate) {
|
|
482
|
+
if (nodeToUpdate instanceof HTMLElement &&
|
|
483
|
+
nodeToUpdate.hasAttribute("data-char-index")) {
|
|
484
|
+
allSpans.push(nodeToUpdate);
|
|
485
|
+
}
|
|
486
|
+
nodeToUpdate = nodeToUpdate.nextSibling;
|
|
487
|
+
}
|
|
488
|
+
// Update indices of spans that need to shift
|
|
489
|
+
allSpans.forEach((span) => {
|
|
490
|
+
const oldFormattedIndexStr = span.getAttribute("data-char-index");
|
|
491
|
+
if (oldFormattedIndexStr !== null) {
|
|
492
|
+
const oldFormattedIndex = parseInt(oldFormattedIndexStr, 10);
|
|
493
|
+
if (!isNaN(oldFormattedIndex) && oldFormattedIndex >= 0) {
|
|
494
|
+
// Convert span's formatted index to raw index
|
|
495
|
+
const spanRawIndex = mapFormattedToRawIndex(adjustedOldText, oldFormattedText, oldFormattedIndex);
|
|
496
|
+
if (lengthDiff > 0) {
|
|
497
|
+
// Characters were inserted - shift spans at and after the insertion point
|
|
498
|
+
if (spanRawIndex >= adjustedSelectionStart &&
|
|
499
|
+
spanRawIndex < adjustedOldText.length) {
|
|
500
|
+
const numInserted = adjustedNewCursorPos - adjustedSelectionStart;
|
|
501
|
+
const newRawIndex = spanRawIndex + numInserted;
|
|
502
|
+
if (newRawIndex < cleanedText.length) {
|
|
503
|
+
const newFormattedIndex = mapRawToFormattedIndex(cleanedText, newFormattedText, newRawIndex);
|
|
504
|
+
span.setAttribute("data-char-index", newFormattedIndex.toString());
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else if (lengthDiff < 0) {
|
|
509
|
+
// Characters were deleted - shift spans after the deletion point backward
|
|
510
|
+
const numDeleted = adjustedSelectionEnd - adjustedSelectionStart;
|
|
511
|
+
if (spanRawIndex >= adjustedSelectionStart &&
|
|
512
|
+
spanRawIndex < adjustedSelectionEnd) {
|
|
513
|
+
// Span is in the deletion range - it will be removed by cleanup logic
|
|
514
|
+
}
|
|
515
|
+
else if (spanRawIndex >= adjustedSelectionEnd) {
|
|
516
|
+
// Span is after the deletion point, shift it backward
|
|
517
|
+
const newRawIndex = Math.max(0, spanRawIndex - numDeleted);
|
|
518
|
+
if (newRawIndex < cleanedText.length) {
|
|
519
|
+
const newFormattedIndex = mapRawToFormattedIndex(cleanedText, newFormattedText, newRawIndex);
|
|
520
|
+
span.setAttribute("data-char-index", newFormattedIndex.toString());
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
// Get all existing spans mapped by index (after updating indices)
|
|
529
|
+
// Also track transparent spans separately to handle them specially
|
|
530
|
+
// Track visible spans by content to find them when indices shift
|
|
531
|
+
const existingSpansByIndex = new Map();
|
|
532
|
+
const allExistingSpans = [];
|
|
533
|
+
const transparentSpans = new Map();
|
|
534
|
+
const transparentSpansByContent = new Map();
|
|
535
|
+
const visibleSpansByContent = new Map();
|
|
536
|
+
const textNodesToRemove = [];
|
|
537
|
+
let node = spanRef.current.firstChild;
|
|
538
|
+
while (node) {
|
|
539
|
+
if (node instanceof HTMLElement &&
|
|
540
|
+
node.hasAttribute("data-char-index")) {
|
|
541
|
+
const index = parseInt(node.getAttribute("data-char-index") ?? "-1", 10);
|
|
542
|
+
if (index >= 0) {
|
|
543
|
+
const isTransparentSpan = isTransparent(node);
|
|
544
|
+
if (isTransparentSpan) {
|
|
545
|
+
transparentSpans.set(index, node);
|
|
546
|
+
const content = node.textContent ?? "";
|
|
547
|
+
if (!transparentSpansByContent.has(content)) {
|
|
548
|
+
transparentSpansByContent.set(content, node);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
// Track visible spans by content for fallback lookup
|
|
553
|
+
const content = node.textContent ?? "";
|
|
554
|
+
if (!visibleSpansByContent.has(content)) {
|
|
555
|
+
visibleSpansByContent.set(content, []);
|
|
556
|
+
}
|
|
557
|
+
visibleSpansByContent.get(content).push(node);
|
|
558
|
+
}
|
|
559
|
+
if (!existingSpansByIndex.has(index) || !isTransparentSpan) {
|
|
560
|
+
existingSpansByIndex.set(index, node);
|
|
561
|
+
}
|
|
562
|
+
allExistingSpans.push(node);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
else if (node.nodeType === Node.TEXT_NODE) {
|
|
566
|
+
textNodesToRemove.push(node);
|
|
567
|
+
}
|
|
568
|
+
node = node.nextSibling;
|
|
569
|
+
}
|
|
570
|
+
// Remove any stray text nodes (from undo/redo or other operations)
|
|
571
|
+
textNodesToRemove.forEach((textNode) => {
|
|
572
|
+
if (textNode.parentNode) {
|
|
573
|
+
textNode.parentNode.removeChild(textNode);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
// Track which spans we've used
|
|
577
|
+
const usedSpans = new Set();
|
|
578
|
+
const newSpans = [];
|
|
579
|
+
let referenceNode = null;
|
|
580
|
+
// Build new structure, reusing existing spans when possible
|
|
581
|
+
// Get parent container once for barrel wheel checks
|
|
582
|
+
const parentContainer = spanRef.current.parentElement;
|
|
583
|
+
// Use formatted text for display (includes thousand separators, etc.)
|
|
584
|
+
for (let i = 0; i < newFormattedText.length; i++) {
|
|
585
|
+
const char = newFormattedText[i];
|
|
586
|
+
const isUnchanged = formattedChanges.unchangedIndices.has(i);
|
|
587
|
+
// For barrel wheel, we need to map from formatted index to raw index
|
|
588
|
+
const rawIndex = mapFormattedToRawIndex(cleanedText, newFormattedText, i);
|
|
589
|
+
// Barrel wheels only apply to digit positions. Without this
|
|
590
|
+
// guard, a separator (e.g. the comma in "1,000") can map to
|
|
591
|
+
// the same raw index as an adjacent digit's barrel wheel and
|
|
592
|
+
// get treated as a wheel position, which strips its
|
|
593
|
+
// `data-flow` and leaves it stuck at width:0.
|
|
594
|
+
const isDigitChar = char !== undefined && /^\d$/.test(char);
|
|
595
|
+
const barrelWheel = isDigitChar
|
|
596
|
+
? changes.barrelWheelIndices.get(rawIndex)
|
|
597
|
+
: undefined;
|
|
598
|
+
// Check if there's a barrel wheel in DOM for this index (indices may have shifted)
|
|
599
|
+
const hasBarrelWheelInDOM = parentContainer?.querySelector(`[data-char-index="${i}"][data-barrel-wheel]`);
|
|
600
|
+
// Also check if there's a transparent span at this index that indicates a barrel wheel
|
|
601
|
+
// (the barrel wheel might have shifted and the span index was updated but barrel wheel query might miss it)
|
|
602
|
+
let hasTransparentSpanWithBarrelWheel = false;
|
|
603
|
+
const spanAtI = existingSpansByIndex.get(i);
|
|
604
|
+
if (spanAtI && isTransparent(spanAtI)) {
|
|
605
|
+
// Check if there's a barrel wheel anywhere that might be associated with this span
|
|
606
|
+
const allBarrelWheels = parentContainer?.querySelectorAll("[data-char-index][data-barrel-wheel]");
|
|
607
|
+
if (allBarrelWheels) {
|
|
608
|
+
// Check if any barrel wheel's final digit matches this span's content
|
|
609
|
+
Array.from(allBarrelWheels).forEach((wheel) => {
|
|
610
|
+
const wheelEl = wheel;
|
|
611
|
+
const finalDigit = wheelEl.getAttribute("data-final-digit");
|
|
612
|
+
if (finalDigit === char) {
|
|
613
|
+
hasTransparentSpanWithBarrelWheel = true;
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const hasBarrelWheel = barrelWheel !== undefined ||
|
|
619
|
+
!!hasBarrelWheelInDOM ||
|
|
620
|
+
hasTransparentSpanWithBarrelWheel;
|
|
621
|
+
// Try to reuse existing span at this index
|
|
622
|
+
let span = existingSpansByIndex.get(i);
|
|
623
|
+
let shouldReuse = false;
|
|
624
|
+
// Only reuse if this index is marked as unchanged (not added)
|
|
625
|
+
// New characters should always get new spans to trigger animations
|
|
626
|
+
const isAdded = formattedChanges.addedIndices.has(i);
|
|
627
|
+
const isUnchangedIndex = formattedChanges.unchangedIndices.has(i);
|
|
628
|
+
// If no exact index match, try to find by content for unchanged characters
|
|
629
|
+
// This handles the case where indices shifted due to separator insertion
|
|
630
|
+
if ((!span || span.textContent !== char) &&
|
|
631
|
+
char &&
|
|
632
|
+
!isAdded &&
|
|
633
|
+
isUnchangedIndex) {
|
|
634
|
+
const candidates = visibleSpansByContent.get(char) ?? [];
|
|
635
|
+
for (const candidate of candidates) {
|
|
636
|
+
if (!usedSpans.has(candidate) &&
|
|
637
|
+
candidate.textContent === char) {
|
|
638
|
+
span = candidate;
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (span &&
|
|
644
|
+
span.textContent === char &&
|
|
645
|
+
!usedSpans.has(span) &&
|
|
646
|
+
!isAdded &&
|
|
647
|
+
isUnchangedIndex) {
|
|
648
|
+
// Check if span is in approximately the right position
|
|
649
|
+
// (within 2 positions is acceptable to avoid unnecessary reordering)
|
|
650
|
+
let currentPos = 0;
|
|
651
|
+
let node = spanRef.current.firstChild;
|
|
652
|
+
while (node && node !== span) {
|
|
653
|
+
if (node instanceof HTMLElement &&
|
|
654
|
+
node.hasAttribute("data-char-index")) {
|
|
655
|
+
currentPos++;
|
|
656
|
+
}
|
|
657
|
+
node = node.nextSibling;
|
|
658
|
+
}
|
|
659
|
+
if (Math.abs(currentPos - i) <= 2) {
|
|
660
|
+
shouldReuse = true;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (shouldReuse && span) {
|
|
664
|
+
// Reuse existing span - ensure textContent matches (defensive check)
|
|
665
|
+
if (span.textContent !== char) {
|
|
666
|
+
span.textContent = char ?? "";
|
|
667
|
+
}
|
|
668
|
+
// Update data-char-index to new position
|
|
669
|
+
span.setAttribute("data-char-index", i.toString());
|
|
670
|
+
// Update attributes if needed
|
|
671
|
+
const shouldHaveFlow = !barrelWheel;
|
|
672
|
+
const shouldHaveShow = isUnchanged;
|
|
673
|
+
const hasFlow = span.hasAttribute("data-flow");
|
|
674
|
+
const hasShow = span.hasAttribute("data-show");
|
|
675
|
+
if (shouldHaveFlow && !hasFlow) {
|
|
676
|
+
span.setAttribute("data-flow", "");
|
|
677
|
+
}
|
|
678
|
+
else if (!shouldHaveFlow && hasFlow) {
|
|
679
|
+
span.removeAttribute("data-flow");
|
|
680
|
+
}
|
|
681
|
+
if (shouldHaveShow && !hasShow) {
|
|
682
|
+
span.setAttribute("data-show", "");
|
|
683
|
+
}
|
|
684
|
+
else if (!shouldHaveShow && hasShow) {
|
|
685
|
+
span.removeAttribute("data-show");
|
|
686
|
+
}
|
|
687
|
+
// Clear any leftover transparency from a previous render's
|
|
688
|
+
// barrel wheel: this span is being reused at a position that
|
|
689
|
+
// does NOT have a new wheel (barrelWheel is falsy in this
|
|
690
|
+
// branch), so it must be visible. Stale transparency can
|
|
691
|
+
// appear when rapid prop changes interrupt wheel cleanup.
|
|
692
|
+
if (!barrelWheel && isTransparent(span)) {
|
|
693
|
+
removeTransparentColor(span);
|
|
694
|
+
}
|
|
695
|
+
usedSpans.add(span);
|
|
696
|
+
// Move to correct position if needed, preserving any ongoing animations
|
|
697
|
+
if (referenceNode) {
|
|
698
|
+
const nextSibling = referenceNode.nextSibling;
|
|
699
|
+
if (span.previousSibling !== referenceNode && nextSibling) {
|
|
700
|
+
moveElementPreservingAnimation(span, spanRef.current, nextSibling);
|
|
701
|
+
}
|
|
702
|
+
else if (!nextSibling &&
|
|
703
|
+
span.parentNode !== spanRef.current) {
|
|
704
|
+
moveElementPreservingAnimation(span, spanRef.current, null);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
referenceNode = span;
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
// Check if there's an existing span that's currently animating (barrel wheel or width)
|
|
711
|
+
// First check if there's a transparent span at this index (might have shifted)
|
|
712
|
+
let existingSpan = existingSpansByIndex.get(i);
|
|
713
|
+
// If no span at this index, check if there's a transparent span that shifted here
|
|
714
|
+
if (!existingSpan && transparentSpans.has(i)) {
|
|
715
|
+
existingSpan = transparentSpans.get(i);
|
|
716
|
+
}
|
|
717
|
+
// Also check all transparent spans to see if any match this character and should be at this index
|
|
718
|
+
// This handles the case where a transparent span shifted but wasn't found in the map
|
|
719
|
+
if (!existingSpan || !isTransparent(existingSpan)) {
|
|
720
|
+
// First, check if there's a transparent span with matching content
|
|
721
|
+
const matchingTransparentSpan = char
|
|
722
|
+
? transparentSpansByContent.get(char)
|
|
723
|
+
: undefined;
|
|
724
|
+
if (matchingTransparentSpan &&
|
|
725
|
+
!usedSpans.has(matchingTransparentSpan) &&
|
|
726
|
+
isTransparent(matchingTransparentSpan)) {
|
|
727
|
+
// Check if there's a barrel wheel that matches this span
|
|
728
|
+
const allBarrelWheels = parentContainer?.querySelectorAll("[data-char-index][data-barrel-wheel]");
|
|
729
|
+
if (allBarrelWheels) {
|
|
730
|
+
for (const wheel of Array.from(allBarrelWheels)) {
|
|
731
|
+
const wheelEl = wheel;
|
|
732
|
+
const wheelIndex = parseInt(wheelEl.getAttribute("data-char-index") ?? "-1", 10);
|
|
733
|
+
const finalDigit = wheelEl.getAttribute("data-final-digit");
|
|
734
|
+
if (finalDigit === char) {
|
|
735
|
+
// This transparent span is associated with a barrel wheel
|
|
736
|
+
// If the barrel wheel is at index i, or if it should be at i (was shifted)
|
|
737
|
+
if (wheelIndex === i) {
|
|
738
|
+
existingSpan = matchingTransparentSpan;
|
|
739
|
+
matchingTransparentSpan.setAttribute("data-char-index", i.toString());
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
else if (wheelIndex > i && lengthDiff < 0) {
|
|
743
|
+
// Barrel wheel was shifted but might not be at i yet - update both
|
|
744
|
+
existingSpan = matchingTransparentSpan;
|
|
745
|
+
matchingTransparentSpan.setAttribute("data-char-index", i.toString());
|
|
746
|
+
wheelEl.setAttribute("data-char-index", i.toString());
|
|
747
|
+
const observer = resizeObserversRef.current.get(wheelIndex);
|
|
748
|
+
if (observer) {
|
|
749
|
+
resizeObserversRef.current.delete(wheelIndex);
|
|
750
|
+
resizeObserversRef.current.set(i, observer);
|
|
751
|
+
}
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const hasWidthAnimation = existingSpan?.hasAttribute("data-width-animate");
|
|
760
|
+
// Check if span is hidden (indicates barrel wheel animation in progress)
|
|
761
|
+
const isHidden = existingSpan?.style.color === "transparent" ||
|
|
762
|
+
existingSpan?.style.color === "rgba(0, 0, 0, 0)" ||
|
|
763
|
+
(existingSpan &&
|
|
764
|
+
window.getComputedStyle(existingSpan).color ===
|
|
765
|
+
"rgba(0, 0, 0, 0)");
|
|
766
|
+
// Don't reuse span if there's a barrel wheel animation for this index
|
|
767
|
+
// The barrel wheel code needs to set up width animation, so let it handle the span
|
|
768
|
+
// Only reuse if it's a width animation without barrel wheel (width animation cleanup)
|
|
769
|
+
const shouldReuseSpan = !hasBarrelWheel &&
|
|
770
|
+
hasWidthAnimation &&
|
|
771
|
+
!isHidden &&
|
|
772
|
+
existingSpan &&
|
|
773
|
+
!usedSpans.has(existingSpan);
|
|
774
|
+
// IMPORTANT: If there's a transparent span at this index, it's part of an ongoing barrel wheel
|
|
775
|
+
// We MUST reuse it, even if changes.barrelWheelIndices doesn't have this index
|
|
776
|
+
// (because the barrel wheel's index may have shifted)
|
|
777
|
+
// Also check if there's a barrel wheel anywhere that matches this character
|
|
778
|
+
let hasMatchingBarrelWheel = hasBarrelWheel || hasBarrelWheelInDOM;
|
|
779
|
+
if (!hasMatchingBarrelWheel && isHidden && existingSpan) {
|
|
780
|
+
// Check all barrel wheels to see if any match this character
|
|
781
|
+
const allBarrelWheels = parentContainer?.querySelectorAll("[data-char-index][data-barrel-wheel]");
|
|
782
|
+
if (allBarrelWheels) {
|
|
783
|
+
for (const wheel of Array.from(allBarrelWheels)) {
|
|
784
|
+
const wheelEl = wheel;
|
|
785
|
+
const finalDigit = wheelEl.getAttribute("data-final-digit");
|
|
786
|
+
if (finalDigit === char) {
|
|
787
|
+
hasMatchingBarrelWheel = true;
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const shouldReuseTransparentSpan = isHidden &&
|
|
794
|
+
existingSpan &&
|
|
795
|
+
!usedSpans.has(existingSpan) &&
|
|
796
|
+
(hasBarrelWheel ||
|
|
797
|
+
hasBarrelWheelInDOM ||
|
|
798
|
+
hasMatchingBarrelWheel);
|
|
799
|
+
// If there's a transparent span (barrel wheel animation), reuse it
|
|
800
|
+
if (shouldReuseTransparentSpan && existingSpan) {
|
|
801
|
+
// Reuse the transparent span - it's part of an ongoing barrel wheel animation
|
|
802
|
+
span = existingSpan;
|
|
803
|
+
// Update textContent if needed (should match the final digit)
|
|
804
|
+
if (span.textContent !== char) {
|
|
805
|
+
span.textContent = char ?? "";
|
|
806
|
+
}
|
|
807
|
+
// Ensure data-char-index is correct
|
|
808
|
+
span.setAttribute("data-char-index", i.toString());
|
|
809
|
+
// Keep it transparent (barrel wheel is still animating)
|
|
810
|
+
span.style.color = "transparent";
|
|
811
|
+
// Don't set data-flow (barrel wheel handles it)
|
|
812
|
+
span.removeAttribute("data-flow");
|
|
813
|
+
if (isUnchanged) {
|
|
814
|
+
span.setAttribute("data-show", "");
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
span.removeAttribute("data-show");
|
|
818
|
+
}
|
|
819
|
+
usedSpans.add(span);
|
|
820
|
+
// Ensure it's in the correct position, preserving any ongoing animations
|
|
821
|
+
if (referenceNode) {
|
|
822
|
+
const nextSibling = referenceNode.nextSibling;
|
|
823
|
+
if (span.previousSibling !== referenceNode && nextSibling) {
|
|
824
|
+
moveElementPreservingAnimation(span, spanRef.current, nextSibling);
|
|
825
|
+
}
|
|
826
|
+
else if (!nextSibling &&
|
|
827
|
+
span.parentNode !== spanRef.current) {
|
|
828
|
+
moveElementPreservingAnimation(span, spanRef.current, null);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
referenceNode = span;
|
|
832
|
+
}
|
|
833
|
+
else if (shouldReuseSpan && existingSpan) {
|
|
834
|
+
// If there's an existing span that's animating width (not barrel wheel), update it
|
|
835
|
+
// Update the existing animating span
|
|
836
|
+
span = existingSpan;
|
|
837
|
+
// Update textContent if it changed (shouldn't happen during barrel wheel, but defensive)
|
|
838
|
+
if (span.textContent !== char) {
|
|
839
|
+
span.textContent = char ?? "";
|
|
840
|
+
}
|
|
841
|
+
// Update data-char-index to ensure it's correct
|
|
842
|
+
span.setAttribute("data-char-index", i.toString());
|
|
843
|
+
// Update attributes
|
|
844
|
+
if (!barrelWheel) {
|
|
845
|
+
span.setAttribute("data-flow", "");
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
span.removeAttribute("data-flow");
|
|
849
|
+
}
|
|
850
|
+
if (isUnchanged) {
|
|
851
|
+
span.setAttribute("data-show", "");
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
span.removeAttribute("data-show");
|
|
855
|
+
}
|
|
856
|
+
usedSpans.add(span);
|
|
857
|
+
// Ensure it's in the correct position, preserving any ongoing animations
|
|
858
|
+
if (referenceNode) {
|
|
859
|
+
const nextSibling = referenceNode.nextSibling;
|
|
860
|
+
if (span.previousSibling !== referenceNode && nextSibling) {
|
|
861
|
+
moveElementPreservingAnimation(span, spanRef.current, nextSibling);
|
|
862
|
+
}
|
|
863
|
+
else if (!nextSibling &&
|
|
864
|
+
span.parentNode !== spanRef.current) {
|
|
865
|
+
moveElementPreservingAnimation(span, spanRef.current, null);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
referenceNode = span;
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
// If there's a barrel wheel animation, we need to ensure the span exists
|
|
872
|
+
// but let the barrel wheel code handle width animation setup
|
|
873
|
+
// So we'll update the existing span if it exists, or create a new one
|
|
874
|
+
if (hasBarrelWheel &&
|
|
875
|
+
existingSpan &&
|
|
876
|
+
!usedSpans.has(existingSpan)) {
|
|
877
|
+
// Update existing span for barrel wheel - barrel wheel code will handle width animation
|
|
878
|
+
span = existingSpan;
|
|
879
|
+
// Preserve old width BEFORE updating textContent to prevent flash
|
|
880
|
+
// Get the current width (which is the old digit's width)
|
|
881
|
+
const cleanup = temporarilyRemoveAncestorsTransform(span);
|
|
882
|
+
const oldWidth = span.getBoundingClientRect().width;
|
|
883
|
+
cleanup();
|
|
884
|
+
// Ensure display is inline-block so width can be applied
|
|
885
|
+
span.style.display = "inline-block";
|
|
886
|
+
// Constrain to old width IMMEDIATELY before updating textContent
|
|
887
|
+
// This prevents flash of natural width when textContent changes
|
|
888
|
+
if (oldWidth > 0) {
|
|
889
|
+
span.style.width = `${oldWidth}px`;
|
|
890
|
+
span.style.minWidth = `${oldWidth}px`;
|
|
891
|
+
span.style.maxWidth = `${oldWidth}px`;
|
|
892
|
+
// Force reflow to ensure width constraint is applied
|
|
893
|
+
void span.offsetWidth;
|
|
894
|
+
}
|
|
895
|
+
// NOW update textContent (span is already constrained, so no flash)
|
|
896
|
+
if (span.textContent !== char) {
|
|
897
|
+
span.textContent = char ?? "";
|
|
898
|
+
}
|
|
899
|
+
// Update data-char-index to ensure it's correct
|
|
900
|
+
span.setAttribute("data-char-index", i.toString());
|
|
901
|
+
// Don't set data-flow for barrel wheel (barrel wheel code handles it)
|
|
902
|
+
span.removeAttribute("data-flow");
|
|
903
|
+
if (isUnchanged) {
|
|
904
|
+
span.setAttribute("data-show", "");
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
span.removeAttribute("data-show");
|
|
908
|
+
}
|
|
909
|
+
// Reset color in case it was hidden from previous animation
|
|
910
|
+
span.style.color = "";
|
|
911
|
+
// Remove data-width-animate if present (barrel wheel code will add it)
|
|
912
|
+
span.removeAttribute("data-width-animate");
|
|
913
|
+
usedSpans.add(span);
|
|
914
|
+
// Ensure it's in the correct position, preserving any ongoing animations
|
|
915
|
+
if (referenceNode) {
|
|
916
|
+
const nextSibling = referenceNode.nextSibling;
|
|
917
|
+
if (span.previousSibling !== referenceNode &&
|
|
918
|
+
nextSibling) {
|
|
919
|
+
moveElementPreservingAnimation(span, spanRef.current, nextSibling);
|
|
920
|
+
}
|
|
921
|
+
else if (!nextSibling &&
|
|
922
|
+
span.parentNode !== spanRef.current) {
|
|
923
|
+
moveElementPreservingAnimation(span, spanRef.current, null);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
referenceNode = span;
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
// Reach this branch when the existing span at this
|
|
930
|
+
// position is transparent (isHidden) but neither this
|
|
931
|
+
// position nor any other wheel in the DOM matches its
|
|
932
|
+
// content (all of hasBarrelWheel / hasBarrelWheelInDOM /
|
|
933
|
+
// hasMatchingBarrelWheel were false — otherwise we would
|
|
934
|
+
// have taken `shouldReuseTransparentSpan` above).
|
|
935
|
+
//
|
|
936
|
+
// That means the transparency is *orphaned* — left
|
|
937
|
+
// behind by a previous render's wheel whose cleanup
|
|
938
|
+
// never landed on this span (rapid prop changes or
|
|
939
|
+
// index reshuffles can do this). Reuse the span but
|
|
940
|
+
// clear its color so the underlying character is
|
|
941
|
+
// visible; if this position truly has a new wheel, the
|
|
942
|
+
// wheel-creation code below will re-hide it.
|
|
943
|
+
if (isHidden &&
|
|
944
|
+
existingSpan &&
|
|
945
|
+
!usedSpans.has(existingSpan)) {
|
|
946
|
+
span = existingSpan;
|
|
947
|
+
if (span.textContent !== char) {
|
|
948
|
+
span.textContent = char ?? "";
|
|
949
|
+
}
|
|
950
|
+
span.setAttribute("data-char-index", i.toString());
|
|
951
|
+
removeTransparentColor(span);
|
|
952
|
+
cleanupWidthAnimation(span);
|
|
953
|
+
if (!barrelWheel) {
|
|
954
|
+
span.setAttribute("data-flow", "");
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
span.removeAttribute("data-flow");
|
|
958
|
+
}
|
|
959
|
+
if (isUnchanged) {
|
|
960
|
+
span.setAttribute("data-show", "");
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
span.removeAttribute("data-show");
|
|
964
|
+
}
|
|
965
|
+
usedSpans.add(span);
|
|
966
|
+
// Ensure it's in the correct position, preserving any ongoing animations
|
|
967
|
+
if (referenceNode) {
|
|
968
|
+
const nextSibling = referenceNode.nextSibling;
|
|
969
|
+
if (span.previousSibling !== referenceNode &&
|
|
970
|
+
nextSibling) {
|
|
971
|
+
moveElementPreservingAnimation(span, spanRef.current, nextSibling);
|
|
972
|
+
}
|
|
973
|
+
else if (!nextSibling &&
|
|
974
|
+
span.parentNode !== spanRef.current) {
|
|
975
|
+
moveElementPreservingAnimation(span, spanRef.current, null);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
referenceNode = span;
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
// Remove existing span at this index if it exists and doesn't match
|
|
982
|
+
if (existingSpan && existingSpan.textContent !== char) {
|
|
983
|
+
// Only remove if not animating (not hidden, not part of barrel wheel, and not in flow animation)
|
|
984
|
+
// Also don't remove if the span's content appears elsewhere in the new text
|
|
985
|
+
// (it might be reused at a different index when separators shift things)
|
|
986
|
+
const hasFlowAnimation = existingSpan.hasAttribute("data-flow");
|
|
987
|
+
const contentWillBeReused = existingSpan.textContent &&
|
|
988
|
+
newFormattedText.includes(existingSpan.textContent);
|
|
989
|
+
const isCurrentlyAnimating = hasFlowAnimation ||
|
|
990
|
+
(isHidden && !hasBarrelWheel) ||
|
|
991
|
+
(hasWidthAnimation && !hasBarrelWheel);
|
|
992
|
+
if (!isCurrentlyAnimating && !contentWillBeReused) {
|
|
993
|
+
existingSpan.remove();
|
|
994
|
+
existingSpansByIndex.delete(i);
|
|
995
|
+
usedSpans.delete(existingSpan);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
// Create new span
|
|
999
|
+
span = document.createElement("span");
|
|
1000
|
+
span.setAttribute("data-char-index", i.toString());
|
|
1001
|
+
span.textContent = char ?? "";
|
|
1002
|
+
if (!barrelWheel) {
|
|
1003
|
+
span.setAttribute("data-flow", "");
|
|
1004
|
+
}
|
|
1005
|
+
if (isUnchanged) {
|
|
1006
|
+
span.setAttribute("data-show", "");
|
|
1007
|
+
}
|
|
1008
|
+
else if (isAdded) {
|
|
1009
|
+
// Animate width from 0 for newly added digits
|
|
1010
|
+
span.style.width = "0px";
|
|
1011
|
+
span.style.minWidth = "0px";
|
|
1012
|
+
span.style.maxWidth = "0px";
|
|
1013
|
+
}
|
|
1014
|
+
// Insert at correct position
|
|
1015
|
+
if (referenceNode) {
|
|
1016
|
+
spanRef.current.insertBefore(span, referenceNode.nextSibling);
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
spanRef.current.insertBefore(span, spanRef.current.firstChild);
|
|
1020
|
+
}
|
|
1021
|
+
referenceNode = span;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
newSpans.push(span);
|
|
1027
|
+
}
|
|
1028
|
+
// Remove unused spans that aren't animating
|
|
1029
|
+
// Also check for spans that are hidden (color: transparent) which indicates barrel wheel animation
|
|
1030
|
+
// IMPORTANT: Check for barrel wheels in DOM, not just formattedChanges.barrelWheelIndices,
|
|
1031
|
+
// because indices may have shifted when characters were inserted before animating digits
|
|
1032
|
+
allExistingSpans.forEach((span) => {
|
|
1033
|
+
if (!usedSpans.has(span)) {
|
|
1034
|
+
const index = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
|
|
1035
|
+
const isHidden = span.style.color === "transparent" ||
|
|
1036
|
+
span.style.color === "rgba(0, 0, 0, 0)" ||
|
|
1037
|
+
window.getComputedStyle(span).color === "rgba(0, 0, 0, 0)";
|
|
1038
|
+
// Check if there's actually a barrel wheel for this index in the DOM
|
|
1039
|
+
// (indices may have shifted, so formattedChanges.barrelWheelIndices might not be accurate)
|
|
1040
|
+
const hasBarrelWheelInDOM = parentContainer?.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
|
|
1041
|
+
// Map formatted index to raw index for barrel wheel check
|
|
1042
|
+
const rawIdx = mapFormattedToRawIndex(cleanedText, newFormattedText, index);
|
|
1043
|
+
// Only consider a barrel-wheel match if this span actually
|
|
1044
|
+
// holds a digit AND the new formatted character at this
|
|
1045
|
+
// position is also a digit. Otherwise a stale digit span
|
|
1046
|
+
// sitting at a position that is now a separator (or out of
|
|
1047
|
+
// bounds) can collide with an adjacent digit's raw barrel
|
|
1048
|
+
// wheel index and incorrectly survive cleanup.
|
|
1049
|
+
const spanIsDigit = /^\d$/.test(span.textContent ?? "");
|
|
1050
|
+
const newCharAtIndex = newFormattedText[index];
|
|
1051
|
+
const newCharIsDigit = newCharAtIndex !== undefined && /^\d$/.test(newCharAtIndex);
|
|
1052
|
+
const hasBarrelWheel = (spanIsDigit &&
|
|
1053
|
+
newCharIsDigit &&
|
|
1054
|
+
changes.barrelWheelIndices.has(rawIdx)) ||
|
|
1055
|
+
!!hasBarrelWheelInDOM;
|
|
1056
|
+
const hasWidthAnimation = span.hasAttribute("data-width-animate");
|
|
1057
|
+
// `isHidden` alone is NOT a reason to keep a span: a span
|
|
1058
|
+
// can be left transparent by an interrupted wheel from a
|
|
1059
|
+
// previous render whose cleanup landed on a different
|
|
1060
|
+
// span. Only true ongoing animations should pin the span
|
|
1061
|
+
// in place.
|
|
1062
|
+
const isCurrentlyAnimating = hasBarrelWheel || hasWidthAnimation;
|
|
1063
|
+
const isOutOfBounds = index >= newFormattedText.length;
|
|
1064
|
+
// If text is empty, remove all spans regardless of animation state
|
|
1065
|
+
// This handles the case where user selects all and deletes
|
|
1066
|
+
if (newFormattedText.length === 0) {
|
|
1067
|
+
span.remove();
|
|
1068
|
+
}
|
|
1069
|
+
else if (isOutOfBounds) {
|
|
1070
|
+
// Out-of-bounds unused spans are always ghosts. If a
|
|
1071
|
+
// wheel still references this index, drop it too so the
|
|
1072
|
+
// wheel doesn't try to un-hide a now-removed span (or
|
|
1073
|
+
// worse, a different span that happens to land here
|
|
1074
|
+
// later).
|
|
1075
|
+
if (hasBarrelWheelInDOM) {
|
|
1076
|
+
hasBarrelWheelInDOM.remove();
|
|
1077
|
+
resizeObserversRef.current.get(index)?.disconnect();
|
|
1078
|
+
resizeObserversRef.current.delete(index);
|
|
1079
|
+
}
|
|
1080
|
+
span.remove();
|
|
1081
|
+
}
|
|
1082
|
+
else if (!isCurrentlyAnimating) {
|
|
1083
|
+
span.remove();
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
// Final verification: ensure all spans have correct textContent
|
|
1088
|
+
// This catches any cases where spans weren't properly updated
|
|
1089
|
+
for (let i = 0; i < newFormattedText.length; i++) {
|
|
1090
|
+
const char = newFormattedText[i];
|
|
1091
|
+
const span = newSpans[i];
|
|
1092
|
+
if (span && span.textContent !== char) {
|
|
1093
|
+
span.textContent = char ?? "";
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// Reposition all barrel wheels after DOM update completes
|
|
1097
|
+
// This ensures barrel wheels stay aligned whenever characters are inserted/deleted
|
|
1098
|
+
// Use requestAnimationFrame to ensure DOM has fully updated
|
|
1099
|
+
requestAnimationFrame(() => {
|
|
1100
|
+
repositionAllBarrelWheels();
|
|
1101
|
+
});
|
|
1102
|
+
// Remove any remaining spans with invalid indices or wrong characters
|
|
1103
|
+
// Also check for duplicate indices and transparent spans that are ghosts
|
|
1104
|
+
const allSpans = Array.from(spanRef.current.querySelectorAll("[data-char-index]"));
|
|
1105
|
+
// Track spans by index to detect duplicates
|
|
1106
|
+
const spansByIndex = new Map();
|
|
1107
|
+
allSpans.forEach((span) => {
|
|
1108
|
+
const index = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
|
|
1109
|
+
if (index >= 0) {
|
|
1110
|
+
if (!spansByIndex.has(index)) {
|
|
1111
|
+
spansByIndex.set(index, []);
|
|
1112
|
+
}
|
|
1113
|
+
spansByIndex.get(index).push(span);
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
// Handle duplicate indices - keep the one that's animating or matches the character, remove others
|
|
1117
|
+
spansByIndex.forEach((spans, index) => {
|
|
1118
|
+
if (spans.length > 1) {
|
|
1119
|
+
// Find the span that should be kept
|
|
1120
|
+
let spanToKeep = null;
|
|
1121
|
+
// First, try to find one that matches the expected character.
|
|
1122
|
+
// The lookup uses the FORMATTED text (not the raw text) since
|
|
1123
|
+
// `index` is a formatted-string index. Mixing them up causes a
|
|
1124
|
+
// stale digit span sitting at a separator's position to be
|
|
1125
|
+
// preferred over the correctly-placed separator span.
|
|
1126
|
+
const expectedChar = newFormattedText[index];
|
|
1127
|
+
for (const span of spans) {
|
|
1128
|
+
if (span.textContent === expectedChar) {
|
|
1129
|
+
const isHidden = span.style.color === "transparent" ||
|
|
1130
|
+
span.style.color === "rgba(0, 0, 0, 0)" ||
|
|
1131
|
+
window.getComputedStyle(span).color ===
|
|
1132
|
+
"rgba(0, 0, 0, 0)";
|
|
1133
|
+
// Prefer non-transparent spans that match the character
|
|
1134
|
+
if (!isHidden) {
|
|
1135
|
+
spanToKeep = span;
|
|
1136
|
+
break;
|
|
1137
|
+
}
|
|
1138
|
+
else if (!spanToKeep) {
|
|
1139
|
+
// Keep transparent one as fallback if it matches
|
|
1140
|
+
spanToKeep = span;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
// If no matching character found, find the animating one
|
|
1145
|
+
if (!spanToKeep) {
|
|
1146
|
+
for (const span of spans) {
|
|
1147
|
+
const isHidden = span.style.color === "transparent" ||
|
|
1148
|
+
span.style.color === "rgba(0, 0, 0, 0)" ||
|
|
1149
|
+
window.getComputedStyle(span).color ===
|
|
1150
|
+
"rgba(0, 0, 0, 0)";
|
|
1151
|
+
const hasBarrelWheelInDOM = parentContainer?.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
|
|
1152
|
+
const hasWidthAnimation = span.hasAttribute("data-width-animate");
|
|
1153
|
+
if (isHidden || hasBarrelWheelInDOM || hasWidthAnimation) {
|
|
1154
|
+
spanToKeep = span;
|
|
1155
|
+
break;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
// If still no span found, keep the first one
|
|
1160
|
+
if (!spanToKeep && spans.length > 0) {
|
|
1161
|
+
const firstSpan = spans.find(() => true);
|
|
1162
|
+
if (firstSpan) {
|
|
1163
|
+
spanToKeep = firstSpan;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
// Remove all other spans at this index
|
|
1167
|
+
if (spanToKeep) {
|
|
1168
|
+
spans.forEach((span) => {
|
|
1169
|
+
if (span !== spanToKeep) {
|
|
1170
|
+
span.remove();
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
// Now check remaining spans for out-of-bounds or wrong characters
|
|
1177
|
+
const remainingSpans = Array.from(spanRef.current.querySelectorAll("[data-char-index]"));
|
|
1178
|
+
remainingSpans.forEach((span) => {
|
|
1179
|
+
const index = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
|
|
1180
|
+
const isHidden = span.style.color === "transparent" ||
|
|
1181
|
+
span.style.color === "rgba(0, 0, 0, 0)" ||
|
|
1182
|
+
window.getComputedStyle(span).color === "rgba(0, 0, 0, 0)";
|
|
1183
|
+
// Check if there's actually a barrel wheel for this index in the DOM
|
|
1184
|
+
const hasBarrelWheelInDOM = parentContainer?.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
|
|
1185
|
+
// Map formatted index to raw index for barrel wheel check
|
|
1186
|
+
const rawIdx = mapFormattedToRawIndex(cleanedText, newFormattedText, index);
|
|
1187
|
+
// Only consider a barrel-wheel match if this span actually
|
|
1188
|
+
// holds a digit; otherwise a separator can collide with an
|
|
1189
|
+
// adjacent digit's barrel-wheel raw index.
|
|
1190
|
+
const spanIsDigit = /^\d$/.test(span.textContent ?? "");
|
|
1191
|
+
const hasBarrelWheel = (spanIsDigit && changes.barrelWheelIndices.has(rawIdx)) ||
|
|
1192
|
+
!!hasBarrelWheelInDOM;
|
|
1193
|
+
const hasWidthAnimation = span.hasAttribute("data-width-animate");
|
|
1194
|
+
// `isHidden` is intentionally NOT part of the "animating"
|
|
1195
|
+
// determination here. An interrupted wheel from a previous
|
|
1196
|
+
// render can leave a span at color:transparent without any
|
|
1197
|
+
// active wheel/width animation; that orphan transparency
|
|
1198
|
+
// shouldn't pin a stale span in the DOM.
|
|
1199
|
+
const isCurrentlyAnimating = hasBarrelWheel || hasWidthAnimation;
|
|
1200
|
+
// If text is empty, remove all spans
|
|
1201
|
+
if (newFormattedText.length === 0) {
|
|
1202
|
+
span.remove();
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
// Remove if index is out of bounds. When the value shrinks
|
|
1206
|
+
// (e.g. 8 digits → 6 digits during fast prop swaps), trailing
|
|
1207
|
+
// spans get left behind at indices that no longer exist; if
|
|
1208
|
+
// they were also transparent the previous heuristic kept
|
|
1209
|
+
// them around as "animating" and they reappeared as ghost
|
|
1210
|
+
// chars once a wheel cleanup eventually un-hid them. Drop
|
|
1211
|
+
// them — and any orphan wheel that still points at this
|
|
1212
|
+
// index — unconditionally.
|
|
1213
|
+
if (index < 0 || index >= newFormattedText.length) {
|
|
1214
|
+
if (hasBarrelWheelInDOM) {
|
|
1215
|
+
hasBarrelWheelInDOM.remove();
|
|
1216
|
+
resizeObserversRef.current.get(index)?.disconnect();
|
|
1217
|
+
resizeObserversRef.current.delete(index);
|
|
1218
|
+
}
|
|
1219
|
+
span.remove();
|
|
1220
|
+
}
|
|
1221
|
+
else if (span.textContent !== newFormattedText[index]) {
|
|
1222
|
+
// Character mismatch - update or remove
|
|
1223
|
+
// If it's a transparent span with wrong character and no barrel wheel, it's a ghost - remove it
|
|
1224
|
+
if (isHidden && !hasBarrelWheelInDOM && !hasBarrelWheel) {
|
|
1225
|
+
span.remove();
|
|
1226
|
+
}
|
|
1227
|
+
else if (!isCurrentlyAnimating) {
|
|
1228
|
+
// Update textContent to match
|
|
1229
|
+
span.textContent = newFormattedText[index] ?? "";
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
// Animate new characters and create barrel wheels
|
|
1235
|
+
// Use requestAnimationFrame to ensure DOM is updated
|
|
1236
|
+
requestAnimationFrame(() => {
|
|
1237
|
+
const flowElements = spanRef.current?.querySelectorAll("[data-flow]");
|
|
1238
|
+
if (flowElements) {
|
|
1239
|
+
Array.from(flowElements).forEach((element) => {
|
|
1240
|
+
const index = parseInt(element.getAttribute("data-char-index") ??
|
|
1241
|
+
"-1", 10);
|
|
1242
|
+
if (element instanceof HTMLElement &&
|
|
1243
|
+
formattedChanges.addedIndices.has(index)) {
|
|
1244
|
+
element.dataset.show = "";
|
|
1245
|
+
const span = spanRef.current;
|
|
1246
|
+
if (span && element.textContent) {
|
|
1247
|
+
const width = measureText(element.textContent, span);
|
|
1248
|
+
element.style.width = `${width}px`;
|
|
1249
|
+
element.style.minWidth = `${width}px`;
|
|
1250
|
+
element.style.maxWidth = `${width}px`;
|
|
1251
|
+
// Remove inline width styles after transition completes
|
|
1252
|
+
const handleTransitionEnd = (e) => {
|
|
1253
|
+
if (["width", "min-width", "max-width"].includes(e.propertyName)) {
|
|
1254
|
+
element.style.width = "";
|
|
1255
|
+
element.style.minWidth = "";
|
|
1256
|
+
element.style.maxWidth = "";
|
|
1257
|
+
element.removeEventListener("transitionend", handleTransitionEnd);
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
element.addEventListener("transitionend", handleTransitionEnd);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
// Create barrel wheels as absolutely positioned elements outside contentEditable
|
|
1266
|
+
// Map raw indices to formatted indices for barrel wheel positioning
|
|
1267
|
+
const barrelWheelIndices = Array.from(changes.barrelWheelIndices.keys());
|
|
1268
|
+
// During rapid prop changes a previous render's wheel can be
|
|
1269
|
+
// left running at a formatted index that this update has no
|
|
1270
|
+
// transition for. The reuse path below only touches wheels
|
|
1271
|
+
// whose index matches one of this update's barrelWheelIndices;
|
|
1272
|
+
// everything else keeps spinning to its (now stale) final
|
|
1273
|
+
// digit, overlapping the new transition visually (multiple
|
|
1274
|
+
// ghosted digits/separators piling up during a spam click).
|
|
1275
|
+
// Finalize and remove those orphan wheels here so only the
|
|
1276
|
+
// current update's wheel cohort is active.
|
|
1277
|
+
// Only snap orphans when this update actually introduces a
|
|
1278
|
+
// new wheel cohort. An "uneventful" update (no transitions of
|
|
1279
|
+
// its own — e.g. a re-render that doesn't change the value)
|
|
1280
|
+
// must leave existing wheels alone or it would kill the
|
|
1281
|
+
// animation that the previous update just started.
|
|
1282
|
+
const wheelSnapParent = spanRef.current?.parentElement;
|
|
1283
|
+
if (barrelWheelIndices.length > 0 && wheelSnapParent) {
|
|
1284
|
+
const intendedFormattedIndices = new Set();
|
|
1285
|
+
for (const rawIdx of barrelWheelIndices) {
|
|
1286
|
+
intendedFormattedIndices.add(mapRawToFormattedIndex(cleanedText, newFormattedText, rawIdx));
|
|
1287
|
+
}
|
|
1288
|
+
const allExistingWheels = wheelSnapParent.querySelectorAll("[data-barrel-wheel][data-char-index]");
|
|
1289
|
+
allExistingWheels.forEach((wheel) => {
|
|
1290
|
+
const wheelEl = wheel;
|
|
1291
|
+
const idxStr = wheelEl.getAttribute("data-char-index");
|
|
1292
|
+
if (idxStr === null) {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
const idx = parseInt(idxStr, 10);
|
|
1296
|
+
if (intendedFormattedIndices.has(idx)) {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const charSpan = spanRef.current?.querySelector(`[data-char-index="${idx}"]`);
|
|
1300
|
+
if (charSpan) {
|
|
1301
|
+
removeTransparentColor(charSpan);
|
|
1302
|
+
cleanupWidthAnimation(charSpan);
|
|
1303
|
+
}
|
|
1304
|
+
resizeObserversRef.current.get(idx)?.disconnect();
|
|
1305
|
+
resizeObserversRef.current.delete(idx);
|
|
1306
|
+
wheelEl.remove();
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current);
|
|
1310
|
+
barrelWheelIndices.forEach((rawIndex) => {
|
|
1311
|
+
const barrelWheelData = changes.barrelWheelIndices.get(rawIndex);
|
|
1312
|
+
// Map raw index to formatted index
|
|
1313
|
+
const index = mapRawToFormattedIndex(cleanedText, newFormattedText, rawIndex);
|
|
1314
|
+
if (!barrelWheelData) {
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const direction = barrelWheelData.direction;
|
|
1318
|
+
const finalDigitStr = barrelWheelData.sequence[barrelWheelData.sequence.length - 1];
|
|
1319
|
+
const finalDigit = finalDigitStr
|
|
1320
|
+
? parseInt(finalDigitStr, 10)
|
|
1321
|
+
: 0;
|
|
1322
|
+
const initialDigitStr = barrelWheelData.sequence[0];
|
|
1323
|
+
// Determine old and new digits based on direction
|
|
1324
|
+
// When direction is "up": sequence = [old, ..., new] so initialDigitStr = old, finalDigitStr = new
|
|
1325
|
+
// When direction is "down": sequence = [new, ..., old] so initialDigitStr = new, finalDigitStr = old
|
|
1326
|
+
const oldDigitStr = direction === "up" ? initialDigitStr : finalDigitStr;
|
|
1327
|
+
const newDigitStr = direction === "up" ? finalDigitStr : initialDigitStr;
|
|
1328
|
+
// Find the span element at this index
|
|
1329
|
+
const charSpan = spanRef.current?.querySelector(`[data-char-index="${index}"]`);
|
|
1330
|
+
if (!charSpan || !(charSpan instanceof HTMLElement)) {
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
// Get position of the character span relative to the parent container
|
|
1334
|
+
const parentContainer = spanRef.current?.parentElement;
|
|
1335
|
+
if (!parentContainer) {
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
// Check if a barrel wheel already exists for this index
|
|
1339
|
+
const existingWheel = parentContainer.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
|
|
1340
|
+
if (existingWheel) {
|
|
1341
|
+
// Reuse existing barrel wheel - update direction and position
|
|
1342
|
+
const existingWrapper = existingWheel.querySelector("[data-barrel-wheel-digits-wrapper]");
|
|
1343
|
+
if (existingWrapper) {
|
|
1344
|
+
// IMPORTANT: Update the character span's textContent to the new digit
|
|
1345
|
+
// This ensures the span has the correct final digit when the animation completes
|
|
1346
|
+
if (charSpan.textContent !== newDigitStr) {
|
|
1347
|
+
charSpan.textContent = newDigitStr ?? "";
|
|
1348
|
+
}
|
|
1349
|
+
existingWheel.setAttribute("data-final-digit", newDigitStr ?? "");
|
|
1350
|
+
requestAnimationFrame(() => {
|
|
1351
|
+
existingWrapper.style.setProperty("--digit-position", newDigitStr ?? "");
|
|
1352
|
+
});
|
|
1353
|
+
const oldDigitWidth = oldDigitStr
|
|
1354
|
+
? measureText(oldDigitStr, charSpan)
|
|
1355
|
+
: 0;
|
|
1356
|
+
const newDigitWidth = newDigitStr
|
|
1357
|
+
? measureText(newDigitStr, charSpan)
|
|
1358
|
+
: 0;
|
|
1359
|
+
if (oldDigitWidth > 0 && newDigitWidth > 0) {
|
|
1360
|
+
const currentWidth = charSpan.getBoundingClientRect().width;
|
|
1361
|
+
setWidthConstraints(charSpan, currentWidth);
|
|
1362
|
+
charSpan.setAttribute("data-width-animate", "");
|
|
1363
|
+
requestAnimationFrame(() => {
|
|
1364
|
+
setWidthConstraints(charSpan, newDigitWidth);
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
repositionBarrelWheel(existingWheel, charSpan, parentContainer);
|
|
1368
|
+
charSpan.style.color = "transparent";
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
// `fromZero` wheels are for digits being added by a value-
|
|
1373
|
+
// prop replacement (no aligned old digit). They start at
|
|
1374
|
+
// width 0 (the slot grows in), the wheel digit rolls from
|
|
1375
|
+
// "0" to the new digit, and the wheel's opacity fades from
|
|
1376
|
+
// 0 → 1. For every other wheel we keep the existing
|
|
1377
|
+
// old-digit → new-digit width animation.
|
|
1378
|
+
const isFromZero = barrelWheelData.fromZero === true;
|
|
1379
|
+
const oldDigitWidth = isFromZero
|
|
1380
|
+
? 0
|
|
1381
|
+
: oldDigitStr
|
|
1382
|
+
? measureText(oldDigitStr, charSpan)
|
|
1383
|
+
: 0;
|
|
1384
|
+
const newDigitWidth = newDigitStr
|
|
1385
|
+
? measureText(newDigitStr, charSpan)
|
|
1386
|
+
: 0;
|
|
1387
|
+
const shouldAnimateWidth = newDigitWidth > 0 && (oldDigitWidth > 0 || isFromZero);
|
|
1388
|
+
const wheel = document.createElement("span");
|
|
1389
|
+
wheel.dataset.barrelWheel = "";
|
|
1390
|
+
wheel.setAttribute("data-direction", direction);
|
|
1391
|
+
wheel.setAttribute("data-final-digit", finalDigit.toString());
|
|
1392
|
+
wheel.setAttribute("data-char-index", index.toString());
|
|
1393
|
+
if (isFromZero) {
|
|
1394
|
+
wheel.dataset.fromZero = "";
|
|
1395
|
+
}
|
|
1396
|
+
const wrapper = document.createElement("div");
|
|
1397
|
+
wrapper.dataset.barrelWheelDigitsWrapper = "";
|
|
1398
|
+
wrapper.style.position = "relative";
|
|
1399
|
+
// Create digits 0-9
|
|
1400
|
+
for (let digit = 0; digit <= 9; digit++) {
|
|
1401
|
+
const digitStr = digit.toString();
|
|
1402
|
+
const digitElement = document.createElement("div");
|
|
1403
|
+
digitElement.dataset.barrelDigit = "";
|
|
1404
|
+
digitElement.setAttribute("data-digit", digitStr);
|
|
1405
|
+
digitElement.textContent = digitStr;
|
|
1406
|
+
digitElement.style.position = "relative";
|
|
1407
|
+
digitElement.style.height = "1em";
|
|
1408
|
+
digitElement.style.lineHeight = "1em";
|
|
1409
|
+
wrapper.appendChild(digitElement);
|
|
1410
|
+
}
|
|
1411
|
+
charSpan.style.color = "transparent";
|
|
1412
|
+
wheel.appendChild(wrapper);
|
|
1413
|
+
parentContainer.appendChild(wheel);
|
|
1414
|
+
// Set initial width synchronously to prevent flash. For
|
|
1415
|
+
// `fromZero` this also pins the slot at 0 before the next
|
|
1416
|
+
// frame's animation target.
|
|
1417
|
+
if (shouldAnimateWidth) {
|
|
1418
|
+
setWidthConstraints(charSpan, oldDigitWidth);
|
|
1419
|
+
void charSpan.offsetWidth;
|
|
1420
|
+
charSpan.setAttribute("data-width-animate", "");
|
|
1421
|
+
void charSpan.offsetWidth;
|
|
1422
|
+
}
|
|
1423
|
+
if (isFromZero) {
|
|
1424
|
+
// Fade the wheel in alongside the digit roll + width
|
|
1425
|
+
// animation. Use a CSS transition so jsdom (which has no
|
|
1426
|
+
// Web Animations API) can still drive it via
|
|
1427
|
+
// `fireEvent.transitionEnd`. Duration/easing match the
|
|
1428
|
+
// barrel-wheel transition so all three land together.
|
|
1429
|
+
wheel.style.opacity = "0";
|
|
1430
|
+
wheel.style.transition =
|
|
1431
|
+
"opacity 0.4s cubic-bezier(.215, .61, .355, 1)";
|
|
1432
|
+
void wheel.offsetWidth;
|
|
1433
|
+
requestAnimationFrame(() => {
|
|
1434
|
+
wheel.style.opacity = "1";
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
// Attach the wheel's transitionend cleanup synchronously so
|
|
1438
|
+
// tests (and rapid back-to-back replacements) that fire
|
|
1439
|
+
// `transitionend` before the deeply-nested rAF chain that
|
|
1440
|
+
// starts the digit-roll has run can still drive the cleanup
|
|
1441
|
+
// path that un-hides the underlying char span.
|
|
1442
|
+
const handleWheelTransitionEnd = () => {
|
|
1443
|
+
const currentIndexStr = wheel.getAttribute("data-char-index");
|
|
1444
|
+
const currentIndex = currentIndexStr !== null
|
|
1445
|
+
? parseInt(currentIndexStr, 10)
|
|
1446
|
+
: index;
|
|
1447
|
+
const finalDigitAttr = wheel.getAttribute("data-final-digit");
|
|
1448
|
+
const resolvedFinalDigit = finalDigitAttr !== null ? finalDigitAttr : newDigitStr;
|
|
1449
|
+
const observer = resizeObserversRef.current.get(currentIndex) ||
|
|
1450
|
+
resizeObserversRef.current.get(index);
|
|
1451
|
+
if (observer) {
|
|
1452
|
+
observer.disconnect();
|
|
1453
|
+
resizeObserversRef.current.delete(currentIndex);
|
|
1454
|
+
resizeObserversRef.current.delete(index);
|
|
1455
|
+
}
|
|
1456
|
+
let targetSpan = null;
|
|
1457
|
+
if (spanRef.current) {
|
|
1458
|
+
const spanAtCurrentIndex = spanRef.current.querySelector(`[data-char-index="${currentIndex}"]`);
|
|
1459
|
+
if (spanAtCurrentIndex) {
|
|
1460
|
+
targetSpan = spanAtCurrentIndex;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
if (!targetSpan &&
|
|
1464
|
+
charSpan instanceof HTMLElement &&
|
|
1465
|
+
charSpan.textContent === resolvedFinalDigit) {
|
|
1466
|
+
const spanIndex = charSpan.getAttribute("data-char-index");
|
|
1467
|
+
if (spanIndex !== currentIndex.toString()) {
|
|
1468
|
+
charSpan.setAttribute("data-char-index", currentIndex.toString());
|
|
1469
|
+
}
|
|
1470
|
+
targetSpan = charSpan;
|
|
1471
|
+
}
|
|
1472
|
+
if (targetSpan instanceof HTMLElement) {
|
|
1473
|
+
cleanupWidthAnimation(targetSpan);
|
|
1474
|
+
removeTransparentColor(targetSpan);
|
|
1475
|
+
targetSpan.removeAttribute("data-flow");
|
|
1476
|
+
targetSpan.style.transition = "none";
|
|
1477
|
+
}
|
|
1478
|
+
if (!targetSpan && spanRef.current) {
|
|
1479
|
+
const spanAtCurrentIndex = spanRef.current.querySelector(`[data-char-index="${currentIndex}"]`);
|
|
1480
|
+
if (spanAtCurrentIndex && isTransparent(spanAtCurrentIndex)) {
|
|
1481
|
+
removeTransparentColor(spanAtCurrentIndex);
|
|
1482
|
+
spanAtCurrentIndex.removeAttribute("data-flow");
|
|
1483
|
+
spanAtCurrentIndex.style.transition = "none";
|
|
1484
|
+
cleanupWidthAnimation(spanAtCurrentIndex);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
wheel.remove();
|
|
1488
|
+
requestAnimationFrame(() => {
|
|
1489
|
+
if (!spanRef.current) {
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const spanAtCurrentIndex = spanRef.current.querySelector(`[data-char-index="${currentIndex}"]`);
|
|
1493
|
+
if (spanAtCurrentIndex && isTransparent(spanAtCurrentIndex)) {
|
|
1494
|
+
const parent = spanRef.current.parentElement;
|
|
1495
|
+
const hasBarrelWheel = parent && getBarrelWheel(parent, currentIndex);
|
|
1496
|
+
if (!hasBarrelWheel) {
|
|
1497
|
+
removeTransparentColor(spanAtCurrentIndex);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
};
|
|
1502
|
+
wrapper.addEventListener("transitionend", handleWheelTransitionEnd, { once: true });
|
|
1503
|
+
wheel.style.position = "absolute";
|
|
1504
|
+
wheel.style.display = "flex";
|
|
1505
|
+
repositionBarrelWheel(wheel, charSpan, parentContainer);
|
|
1506
|
+
// Continuously align the wheel with its underlying span for
|
|
1507
|
+
// the whole lifetime of the wheel. Surrounding spans may grow
|
|
1508
|
+
// (data-flow), shrink, or shift (positionChanges) during the
|
|
1509
|
+
// animation — none of which trigger a resize on the wheel's
|
|
1510
|
+
// own charSpan, so a ResizeObserver alone is not enough. The
|
|
1511
|
+
// loop self-terminates as soon as the wheel is removed from
|
|
1512
|
+
// the DOM (transitionend handler calls `wheel.remove()`).
|
|
1513
|
+
const trackWheelPosition = () => {
|
|
1514
|
+
if (!wheel.isConnected) {
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
if (!charSpan.isConnected || !spanRef.current?.parentElement) {
|
|
1518
|
+
requestAnimationFrame(trackWheelPosition);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
const parent = spanRef.current.parentElement;
|
|
1522
|
+
const trCleanup = temporarilyRemoveAncestorsTransform(charSpan);
|
|
1523
|
+
const rect = charSpan.getBoundingClientRect();
|
|
1524
|
+
const parentRect = parent.getBoundingClientRect();
|
|
1525
|
+
wheel.style.left = `${rect.left - parentRect.left}px`;
|
|
1526
|
+
wheel.style.top = `${rect.top - parentRect.top}px`;
|
|
1527
|
+
wheel.style.width = `${rect.width}px`;
|
|
1528
|
+
wheel.style.height = `${rect.height}px`;
|
|
1529
|
+
trCleanup();
|
|
1530
|
+
requestAnimationFrame(trackWheelPosition);
|
|
1531
|
+
};
|
|
1532
|
+
requestAnimationFrame(trackWheelPosition);
|
|
1533
|
+
requestAnimationFrame(() => {
|
|
1534
|
+
// Verify width constraints are still set
|
|
1535
|
+
if (shouldAnimateWidth) {
|
|
1536
|
+
if (!charSpan.style.width || charSpan.style.width === "") {
|
|
1537
|
+
setWidthConstraints(charSpan, oldDigitWidth);
|
|
1538
|
+
void charSpan.offsetWidth;
|
|
1539
|
+
}
|
|
1540
|
+
if (!charSpan.hasAttribute("data-width-animate")) {
|
|
1541
|
+
charSpan.setAttribute("data-width-animate", "");
|
|
1542
|
+
void charSpan.offsetWidth;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
const initialPosition = oldDigitStr
|
|
1546
|
+
? parseInt(oldDigitStr, 10)
|
|
1547
|
+
: 0;
|
|
1548
|
+
const finalPosition = newDigitStr
|
|
1549
|
+
? parseInt(newDigitStr, 10)
|
|
1550
|
+
: 0;
|
|
1551
|
+
wrapper.style.setProperty("--digit-position", initialPosition.toString());
|
|
1552
|
+
requestAnimationFrame(() => {
|
|
1553
|
+
wrapper.dataset.animating = "";
|
|
1554
|
+
requestAnimationFrame(() => {
|
|
1555
|
+
wrapper.style.setProperty("--digit-position", finalPosition.toString());
|
|
1556
|
+
if (shouldAnimateWidth) {
|
|
1557
|
+
if (!charSpan.style.width ||
|
|
1558
|
+
charSpan.style.width === "") {
|
|
1559
|
+
setWidthConstraints(charSpan, oldDigitWidth);
|
|
1560
|
+
void charSpan.offsetWidth;
|
|
1561
|
+
}
|
|
1562
|
+
if (!charSpan.hasAttribute("data-width-animate")) {
|
|
1563
|
+
charSpan.setAttribute("data-width-animate", "");
|
|
1564
|
+
}
|
|
1565
|
+
if (window.getComputedStyle(charSpan).display !==
|
|
1566
|
+
"inline-block") {
|
|
1567
|
+
charSpan.style.display = "inline-block";
|
|
1568
|
+
}
|
|
1569
|
+
void charSpan.offsetWidth;
|
|
1570
|
+
// Set up ResizeObserver to update barrel wheel position during width animation
|
|
1571
|
+
const existingObserver = resizeObserversRef.current.get(index);
|
|
1572
|
+
if (existingObserver) {
|
|
1573
|
+
existingObserver.disconnect();
|
|
1574
|
+
resizeObserversRef.current.delete(index);
|
|
1575
|
+
}
|
|
1576
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
1577
|
+
if (!spanRef.current || !charSpan) {
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const parent = spanRef.current.parentElement;
|
|
1581
|
+
if (!parent) {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
const bw = getBarrelWheel(parent, index);
|
|
1585
|
+
if (bw) {
|
|
1586
|
+
const cleanup = temporarilyRemoveAncestorsTransform(charSpan);
|
|
1587
|
+
const rect = charSpan.getBoundingClientRect();
|
|
1588
|
+
const parentRect = parent.getBoundingClientRect();
|
|
1589
|
+
bw.style.left = `${rect.left - parentRect.left}px`;
|
|
1590
|
+
bw.style.width = `${rect.width}px`;
|
|
1591
|
+
cleanup();
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
resizeObserver.observe(charSpan);
|
|
1595
|
+
resizeObserversRef.current.set(index, resizeObserver);
|
|
1596
|
+
requestAnimationFrame(() => {
|
|
1597
|
+
void charSpan.offsetWidth;
|
|
1598
|
+
const computedStyle = window.getComputedStyle(charSpan);
|
|
1599
|
+
const transition = computedStyle.transition;
|
|
1600
|
+
if (!transition ||
|
|
1601
|
+
transition === "none" ||
|
|
1602
|
+
transition === "all 0s ease 0s") {
|
|
1603
|
+
// Match the data-width-animate CSS rule and the
|
|
1604
|
+
// barrel wheel's digit-roll transition so the
|
|
1605
|
+
// underlying char animates in lockstep with the
|
|
1606
|
+
// wheel (same duration + easing).
|
|
1607
|
+
charSpan.style.transition =
|
|
1608
|
+
"width 0.4s cubic-bezier(.215, .61, .355, 1), min-width 0.4s cubic-bezier(.215, .61, .355, 1), max-width 0.4s cubic-bezier(.215, .61, .355, 1)";
|
|
1609
|
+
void charSpan.offsetWidth;
|
|
1610
|
+
}
|
|
1611
|
+
setWidthConstraints(charSpan, newDigitWidth);
|
|
1612
|
+
const handleWidthAnimationEnd = (e) => {
|
|
1613
|
+
if (["width", "min-width", "max-width"].includes(e.propertyName)) {
|
|
1614
|
+
cleanupWidthAnimation(charSpan);
|
|
1615
|
+
charSpan.removeEventListener("transitionend", handleWidthAnimationEnd);
|
|
1616
|
+
const observer = resizeObserversRef.current.get(index);
|
|
1617
|
+
if (observer) {
|
|
1618
|
+
observer.disconnect();
|
|
1619
|
+
resizeObserversRef.current.delete(index);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
charSpan.addEventListener("transitionend", handleWidthAnimationEnd);
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
});
|
|
1628
|
+
});
|
|
1629
|
+
});
|
|
1630
|
+
// Apply x-position animations for characters that moved
|
|
1631
|
+
// (separators and digits that crossed group boundaries)
|
|
1632
|
+
if (positionChanges.length > 0 && spanRef.current) {
|
|
1633
|
+
const containerRect = spanRef.current.getBoundingClientRect();
|
|
1634
|
+
positionChanges.forEach((change) => {
|
|
1635
|
+
const span = spanRef.current?.querySelector(`[data-char-index="${change.newIndex}"]`);
|
|
1636
|
+
if (span && span.textContent === change.char) {
|
|
1637
|
+
// Look up old position using the character and its old index
|
|
1638
|
+
const oldKey = `${change.char}@${change.oldIndex}`;
|
|
1639
|
+
const oldPos = oldPositions.get(oldKey);
|
|
1640
|
+
if (oldPos) {
|
|
1641
|
+
const newRect = span.getBoundingClientRect();
|
|
1642
|
+
const newX = newRect.left - containerRect.left;
|
|
1643
|
+
const offsetX = oldPos.x - newX;
|
|
1644
|
+
// Only animate if there's a significant position change
|
|
1645
|
+
if (Math.abs(offsetX) > 1) {
|
|
1646
|
+
// Use Web Animations API for smooth x-position animation
|
|
1647
|
+
span.animate([
|
|
1648
|
+
{ transform: `translateX(${offsetX}px)` },
|
|
1649
|
+
{ transform: "translateX(0)" },
|
|
1650
|
+
], {
|
|
1651
|
+
duration: 250,
|
|
1652
|
+
easing: "cubic-bezier(0.33, 1, 0.68, 1)", // ease-out-cubic
|
|
1653
|
+
fill: "forwards",
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
cleanup();
|
|
1661
|
+
});
|
|
1662
|
+
if (!skipCursor) {
|
|
1663
|
+
const setCursor = () => {
|
|
1664
|
+
if (!spanRef.current) {
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
// Map raw cursor position to formatted position
|
|
1668
|
+
const formattedCursorPos = mapRawToFormattedIndex(cleanedText, newFormattedText, Math.min(newCursorPos, cleanedText.length));
|
|
1669
|
+
setCursorPositionInElement(spanRef.current, formattedCursorPos);
|
|
1670
|
+
};
|
|
1671
|
+
setCursor();
|
|
1672
|
+
requestAnimationFrame(setCursor);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}, [
|
|
1676
|
+
isAllowed,
|
|
1677
|
+
displayValue,
|
|
1678
|
+
onChange,
|
|
1679
|
+
autoAddLeadingZero,
|
|
1680
|
+
repositionAllBarrelWheels,
|
|
1681
|
+
addToHistory,
|
|
1682
|
+
formatRawValue,
|
|
1683
|
+
mapRawToFormattedIndex,
|
|
1684
|
+
mapFormattedToRawIndex,
|
|
1685
|
+
separators,
|
|
1686
|
+
]);
|
|
1687
|
+
// Initialize
|
|
1688
|
+
useEffect(() => {
|
|
1689
|
+
if (spanRef.current && formattedDisplayValue) {
|
|
1690
|
+
spanRef.current.textContent = formattedDisplayValue;
|
|
1691
|
+
}
|
|
1692
|
+
// Initialize history with initial state
|
|
1693
|
+
if (historyRef.current.length === 0) {
|
|
1694
|
+
const initialValue = actualValue;
|
|
1695
|
+
historyRef.current.push({
|
|
1696
|
+
text: displayValue,
|
|
1697
|
+
cursorPosBefore: 0,
|
|
1698
|
+
cursorPosAfter: 0,
|
|
1699
|
+
value: initialValue,
|
|
1700
|
+
});
|
|
1701
|
+
historyIndexRef.current = 0;
|
|
1702
|
+
}
|
|
1703
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1704
|
+
}, []);
|
|
1705
|
+
// Animate the diff when the `value` prop changes externally (i.e. the
|
|
1706
|
+
// parent updated `value` outside of our own onChange flow).
|
|
1707
|
+
//
|
|
1708
|
+
// This is naturally a no-op on the initial mount: `displayValue` is
|
|
1709
|
+
// seeded from `actualValue` in `useState`, so the two are in sync and
|
|
1710
|
+
// the guard below short-circuits until something actually changes.
|
|
1711
|
+
useEffect(() => {
|
|
1712
|
+
const newRawDisplay = actualValue?.toString() ?? "";
|
|
1713
|
+
const currentParsed = ["", "-", ".", "-."].includes(displayValue)
|
|
1714
|
+
? undefined
|
|
1715
|
+
: parseFloat(displayValue);
|
|
1716
|
+
if (currentParsed === actualValue || !spanRef.current) {
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
updateValue(newRawDisplay, newRawDisplay.length, 0, displayValue.length, {
|
|
1720
|
+
skipHistory: true,
|
|
1721
|
+
skipOnChange: true,
|
|
1722
|
+
skipCursor: true,
|
|
1723
|
+
asReplacement: true,
|
|
1724
|
+
});
|
|
1725
|
+
}, [actualValue, displayValue, updateValue]);
|
|
1726
|
+
// Handle format or locale prop changes - animate the transition
|
|
1727
|
+
useEffect(() => {
|
|
1728
|
+
if (!spanRef.current) {
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
const oldFormattedText = prevFormattedValueRef.current;
|
|
1732
|
+
const newFormattedText = formattedDisplayValue;
|
|
1733
|
+
// Skip if no actual change
|
|
1734
|
+
if (oldFormattedText === newFormattedText) {
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
const parentContainer = spanRef.current.parentElement;
|
|
1738
|
+
// Collect existing barrel wheels and their associated spans BEFORE any DOM changes
|
|
1739
|
+
// We'll update their indices and reposition them after the DOM is rebuilt
|
|
1740
|
+
const existingBarrelWheels = [];
|
|
1741
|
+
if (parentContainer) {
|
|
1742
|
+
const cleanup = temporarilyRemoveAncestorsTransform(parentContainer);
|
|
1743
|
+
const parentRect = parentContainer.getBoundingClientRect();
|
|
1744
|
+
const barrelWheels = getAllBarrelWheels(parentContainer);
|
|
1745
|
+
barrelWheels.forEach((wheel) => {
|
|
1746
|
+
const oldIndexStr = wheel.getAttribute("data-char-index");
|
|
1747
|
+
const finalDigit = wheel.getAttribute("data-final-digit") ?? "";
|
|
1748
|
+
if (oldIndexStr !== null) {
|
|
1749
|
+
const oldIndex = parseInt(oldIndexStr, 10);
|
|
1750
|
+
// Find the associated transparent span
|
|
1751
|
+
const span = spanRef.current?.querySelector(`[data-char-index="${oldIndex}"]`);
|
|
1752
|
+
// Capture old x position relative to parent
|
|
1753
|
+
const wheelRect = wheel.getBoundingClientRect();
|
|
1754
|
+
const oldX = wheelRect.left - parentRect.left;
|
|
1755
|
+
existingBarrelWheels.push({
|
|
1756
|
+
wheel,
|
|
1757
|
+
oldIndex,
|
|
1758
|
+
finalDigit,
|
|
1759
|
+
span,
|
|
1760
|
+
oldX,
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
});
|
|
1764
|
+
cleanup();
|
|
1765
|
+
}
|
|
1766
|
+
// Don't clear ResizeObservers - they'll be updated with new indices
|
|
1767
|
+
// Capture old positions BEFORE updating DOM
|
|
1768
|
+
const oldPositions = new Map();
|
|
1769
|
+
const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current);
|
|
1770
|
+
const containerRect = spanRef.current.getBoundingClientRect();
|
|
1771
|
+
const existingSpans = spanRef.current.querySelectorAll("[data-char-index]");
|
|
1772
|
+
existingSpans.forEach((span) => {
|
|
1773
|
+
const el = span;
|
|
1774
|
+
const index = parseInt(el.getAttribute("data-char-index") ?? "-1", 10);
|
|
1775
|
+
const char = el.textContent ?? "";
|
|
1776
|
+
if (index >= 0 && char) {
|
|
1777
|
+
const rect = el.getBoundingClientRect();
|
|
1778
|
+
oldPositions.set(`${char}@${index}`, {
|
|
1779
|
+
x: rect.left - containerRect.left,
|
|
1780
|
+
width: rect.width,
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
cleanup();
|
|
1785
|
+
// Build maps of character positions for matching old -> new
|
|
1786
|
+
const oldDigitPositions = new Map();
|
|
1787
|
+
const oldSeparatorPositions = new Map();
|
|
1788
|
+
const { decimal: localeDecimal } = separators;
|
|
1789
|
+
// Helper to check if a char is "raw" (digit, decimal, or minus) for matching purposes
|
|
1790
|
+
// This is different from the component's isRawChar because we need to consider
|
|
1791
|
+
// both the old and new decimal separators
|
|
1792
|
+
const isRawCharForMatching = (char, _text, textDecimal) => {
|
|
1793
|
+
if (!char) {
|
|
1794
|
+
return false;
|
|
1795
|
+
}
|
|
1796
|
+
if (/[\d-]/.test(char)) {
|
|
1797
|
+
return true;
|
|
1798
|
+
}
|
|
1799
|
+
if (char === ".") {
|
|
1800
|
+
return true;
|
|
1801
|
+
}
|
|
1802
|
+
if (char === localeDecimal) {
|
|
1803
|
+
return true;
|
|
1804
|
+
}
|
|
1805
|
+
// If the text uses this char as its decimal
|
|
1806
|
+
if (textDecimal && char === textDecimal) {
|
|
1807
|
+
return true;
|
|
1808
|
+
}
|
|
1809
|
+
return false;
|
|
1810
|
+
};
|
|
1811
|
+
// Detect which decimal is used in each text
|
|
1812
|
+
const detectDecimal = (text) => {
|
|
1813
|
+
// Count occurrences of possible decimals
|
|
1814
|
+
let dotCount = 0;
|
|
1815
|
+
let commaCount = 0;
|
|
1816
|
+
for (const char of text) {
|
|
1817
|
+
if (char === ".") {
|
|
1818
|
+
dotCount++;
|
|
1819
|
+
}
|
|
1820
|
+
if (char === ",") {
|
|
1821
|
+
commaCount++;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
// A decimal separator appears at most once
|
|
1825
|
+
// Group separators appear multiple times
|
|
1826
|
+
if (dotCount === 1 && commaCount > 1) {
|
|
1827
|
+
return ".";
|
|
1828
|
+
} // e.g., "1,234.56"
|
|
1829
|
+
if (commaCount === 1 && dotCount > 1) {
|
|
1830
|
+
return ",";
|
|
1831
|
+
} // e.g., "1.234,56" (German)
|
|
1832
|
+
if (dotCount === 1 && commaCount === 0) {
|
|
1833
|
+
return ".";
|
|
1834
|
+
} // e.g., "1.56"
|
|
1835
|
+
if (commaCount === 1 && dotCount === 0) {
|
|
1836
|
+
return ",";
|
|
1837
|
+
} // e.g., "1,56"
|
|
1838
|
+
if (dotCount === 0 && commaCount === 0) {
|
|
1839
|
+
return null;
|
|
1840
|
+
} // no decimal
|
|
1841
|
+
// If both appear once, assume the last one is decimal
|
|
1842
|
+
const lastDot = text.lastIndexOf(".");
|
|
1843
|
+
const lastComma = text.lastIndexOf(",");
|
|
1844
|
+
if (lastDot > lastComma) {
|
|
1845
|
+
return ".";
|
|
1846
|
+
}
|
|
1847
|
+
if (lastComma > lastDot) {
|
|
1848
|
+
return ",";
|
|
1849
|
+
}
|
|
1850
|
+
return null;
|
|
1851
|
+
};
|
|
1852
|
+
const oldTextDecimal = detectDecimal(oldFormattedText);
|
|
1853
|
+
const newTextDecimal = detectDecimal(newFormattedText);
|
|
1854
|
+
// Helper to normalize decimal separators for matching
|
|
1855
|
+
// Both "." and "," (when used as decimal) should be treated as the same character
|
|
1856
|
+
const normalizeForMatching = (char, textDecimal) => {
|
|
1857
|
+
// If this char is the decimal for its text, normalize to "DECIMAL"
|
|
1858
|
+
if (char === "." || char === textDecimal) {
|
|
1859
|
+
// Check if it's actually a decimal (not a group separator)
|
|
1860
|
+
if (char === "." && textDecimal === ".") {
|
|
1861
|
+
return "DECIMAL";
|
|
1862
|
+
}
|
|
1863
|
+
if (char === "," && textDecimal === ",") {
|
|
1864
|
+
return "DECIMAL";
|
|
1865
|
+
}
|
|
1866
|
+
if (char === "." && textDecimal === null) {
|
|
1867
|
+
return "DECIMAL";
|
|
1868
|
+
} // assume it's decimal
|
|
1869
|
+
if (char === localeDecimal) {
|
|
1870
|
+
return "DECIMAL";
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
return char;
|
|
1874
|
+
};
|
|
1875
|
+
for (let i = 0; i < oldFormattedText.length; i++) {
|
|
1876
|
+
const char = oldFormattedText[i] ?? "";
|
|
1877
|
+
if (!char) {
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
const isRaw = isRawCharForMatching(char, oldFormattedText, oldTextDecimal);
|
|
1881
|
+
const map = isRaw ? oldDigitPositions : oldSeparatorPositions;
|
|
1882
|
+
const normalizedChar = normalizeForMatching(char, oldTextDecimal);
|
|
1883
|
+
if (!map.has(normalizedChar)) {
|
|
1884
|
+
map.set(normalizedChar, []);
|
|
1885
|
+
}
|
|
1886
|
+
map.get(normalizedChar).push(i);
|
|
1887
|
+
}
|
|
1888
|
+
// Track which old positions have been matched
|
|
1889
|
+
const usedOldPositions = new Set();
|
|
1890
|
+
// First pass: match old characters to new characters
|
|
1891
|
+
const oldToNewMapping = new Map(); // oldIndex -> newIndex
|
|
1892
|
+
const newToOldMapping = new Map(); // newIndex -> oldIndex
|
|
1893
|
+
for (let newIdx = 0; newIdx < newFormattedText.length; newIdx++) {
|
|
1894
|
+
const char = newFormattedText[newIdx] ?? "";
|
|
1895
|
+
if (!char) {
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
const isRaw = isRawCharForMatching(char, newFormattedText, newTextDecimal);
|
|
1899
|
+
const posMap = isRaw ? oldDigitPositions : oldSeparatorPositions;
|
|
1900
|
+
const normalizedChar = normalizeForMatching(char, newTextDecimal);
|
|
1901
|
+
const oldIndices = posMap.get(normalizedChar) ?? [];
|
|
1902
|
+
for (const oldIdx of oldIndices) {
|
|
1903
|
+
if (!usedOldPositions.has(oldIdx)) {
|
|
1904
|
+
usedOldPositions.add(oldIdx);
|
|
1905
|
+
oldToNewMapping.set(oldIdx, newIdx);
|
|
1906
|
+
newToOldMapping.set(newIdx, oldIdx);
|
|
1907
|
+
break;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
// Find separators that need to be removed (in old but not matched)
|
|
1912
|
+
const separatorsToRemove = [];
|
|
1913
|
+
for (let i = 0; i < oldFormattedText.length; i++) {
|
|
1914
|
+
const char = oldFormattedText[i] ?? "";
|
|
1915
|
+
if (!char) {
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
const isRaw = isRawCharForMatching(char, oldFormattedText, oldTextDecimal);
|
|
1919
|
+
if (!isRaw && !usedOldPositions.has(i)) {
|
|
1920
|
+
separatorsToRemove.push({ char, oldIndex: i });
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
const mergedItems = [];
|
|
1924
|
+
// Add new characters
|
|
1925
|
+
for (let i = 0; i < newFormattedText.length; i++) {
|
|
1926
|
+
const char = newFormattedText[i] ?? "";
|
|
1927
|
+
if (!char) {
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
const isRaw = isRawCharForMatching(char, newFormattedText, newTextDecimal);
|
|
1931
|
+
const isNewSeparator = !isRaw && !newToOldMapping.has(i);
|
|
1932
|
+
mergedItems.push({
|
|
1933
|
+
type: "new",
|
|
1934
|
+
char,
|
|
1935
|
+
newIndex: i,
|
|
1936
|
+
isNewSeparator: isNewSeparator && oldFormattedText.length > 0,
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
// Insert removing separators at their visual positions
|
|
1940
|
+
// We need to figure out where they should go based on surrounding characters
|
|
1941
|
+
separatorsToRemove.forEach(({ char, oldIndex }) => {
|
|
1942
|
+
// Find the position in the merged array where this separator should go
|
|
1943
|
+
// It should be after any new characters that come from old positions before it
|
|
1944
|
+
// and before any new characters that come from old positions after it
|
|
1945
|
+
let insertPosition = 0;
|
|
1946
|
+
for (let i = 0; i < mergedItems.length; i++) {
|
|
1947
|
+
const item = mergedItems[i];
|
|
1948
|
+
if (item?.type === "new") {
|
|
1949
|
+
const oldIdx = newToOldMapping.get(item.newIndex);
|
|
1950
|
+
if (oldIdx !== undefined && oldIdx < oldIndex) {
|
|
1951
|
+
insertPosition = i + 1;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
mergedItems.splice(insertPosition, 0, {
|
|
1956
|
+
type: "removing",
|
|
1957
|
+
char,
|
|
1958
|
+
oldIndex,
|
|
1959
|
+
});
|
|
1960
|
+
});
|
|
1961
|
+
// Update barrel wheel indices using the mapping BEFORE clearing the container
|
|
1962
|
+
const barrelWheelNewIndices = new Map();
|
|
1963
|
+
const barrelWheelSpansToPreserve = new Set();
|
|
1964
|
+
existingBarrelWheels.forEach(({ wheel, oldIndex, span }) => {
|
|
1965
|
+
const newIndex = oldToNewMapping.get(oldIndex);
|
|
1966
|
+
if (newIndex !== undefined) {
|
|
1967
|
+
// Update the barrel wheel's index
|
|
1968
|
+
wheel.setAttribute("data-char-index", newIndex.toString());
|
|
1969
|
+
barrelWheelNewIndices.set(wheel, newIndex);
|
|
1970
|
+
// Update ResizeObserver mapping
|
|
1971
|
+
const observer = resizeObserversRef.current.get(oldIndex);
|
|
1972
|
+
if (observer) {
|
|
1973
|
+
resizeObserversRef.current.delete(oldIndex);
|
|
1974
|
+
resizeObserversRef.current.set(newIndex, observer);
|
|
1975
|
+
}
|
|
1976
|
+
// Mark span to preserve
|
|
1977
|
+
if (span) {
|
|
1978
|
+
barrelWheelSpansToPreserve.add(span);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
else {
|
|
1982
|
+
// Barrel wheel's character was removed - clean up
|
|
1983
|
+
const observer = resizeObserversRef.current.get(oldIndex);
|
|
1984
|
+
if (observer) {
|
|
1985
|
+
observer.disconnect();
|
|
1986
|
+
resizeObserversRef.current.delete(oldIndex);
|
|
1987
|
+
}
|
|
1988
|
+
wheel.remove();
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
// Clear the container but preserve barrel wheel spans (we'll update them)
|
|
1992
|
+
const allChildren = Array.from(spanRef.current.childNodes);
|
|
1993
|
+
allChildren.forEach((child) => {
|
|
1994
|
+
if (child instanceof HTMLElement &&
|
|
1995
|
+
barrelWheelSpansToPreserve.has(child)) {
|
|
1996
|
+
// Keep this span - it's associated with an active barrel wheel
|
|
1997
|
+
// But temporarily remove it so we can reinsert at correct position
|
|
1998
|
+
child.remove();
|
|
1999
|
+
}
|
|
2000
|
+
else {
|
|
2001
|
+
// Remove this span
|
|
2002
|
+
if (child.parentNode) {
|
|
2003
|
+
child.parentNode.removeChild(child);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
});
|
|
2007
|
+
// Create spans based on merged sequence
|
|
2008
|
+
const newSpans = [];
|
|
2009
|
+
const addedSeparatorSpans = [];
|
|
2010
|
+
const removingSpans = [];
|
|
2011
|
+
const cleanup2 = temporarilyRemoveAncestorsTransform(spanRef.current);
|
|
2012
|
+
mergedItems.forEach((item) => {
|
|
2013
|
+
if (!item) {
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (item.type === "new") {
|
|
2017
|
+
// Check if there's a barrel wheel span that should be at this index
|
|
2018
|
+
let span = null;
|
|
2019
|
+
for (const { wheel, span: bwSpan, finalDigit, } of existingBarrelWheels) {
|
|
2020
|
+
const newIndex = barrelWheelNewIndices.get(wheel);
|
|
2021
|
+
if (newIndex === item.newIndex && bwSpan) {
|
|
2022
|
+
// Reuse the barrel wheel span
|
|
2023
|
+
span = bwSpan;
|
|
2024
|
+
span.setAttribute("data-char-index", item.newIndex.toString());
|
|
2025
|
+
// Update text content to match the new character (decimal separator might have changed)
|
|
2026
|
+
if (span.textContent !== item.char && item.char !== finalDigit) {
|
|
2027
|
+
// Only update if not the final digit (barrel wheel is still animating)
|
|
2028
|
+
span.textContent = item.char;
|
|
2029
|
+
}
|
|
2030
|
+
break;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (!span) {
|
|
2034
|
+
// Create new span
|
|
2035
|
+
span = document.createElement("span");
|
|
2036
|
+
span.textContent = item.char;
|
|
2037
|
+
span.setAttribute("data-char-index", item.newIndex.toString());
|
|
2038
|
+
}
|
|
2039
|
+
if (item.isNewSeparator) {
|
|
2040
|
+
// New separator - animate in with width from 0 and slide up
|
|
2041
|
+
span.setAttribute("data-flow", "");
|
|
2042
|
+
spanRef.current.appendChild(span);
|
|
2043
|
+
const finalWidth = span.getBoundingClientRect().width;
|
|
2044
|
+
span.style.width = "0px";
|
|
2045
|
+
span.style.minWidth = "0px";
|
|
2046
|
+
span.style.maxWidth = "0px";
|
|
2047
|
+
addedSeparatorSpans.push({ span, finalWidth });
|
|
2048
|
+
}
|
|
2049
|
+
else {
|
|
2050
|
+
// Existing character or digit - show immediately (unless it's a barrel wheel span)
|
|
2051
|
+
if (!barrelWheelSpansToPreserve.has(span)) {
|
|
2052
|
+
span.setAttribute("data-flow", "");
|
|
2053
|
+
span.setAttribute("data-show", "");
|
|
2054
|
+
}
|
|
2055
|
+
spanRef.current.appendChild(span);
|
|
2056
|
+
}
|
|
2057
|
+
newSpans.push(span);
|
|
2058
|
+
}
|
|
2059
|
+
else {
|
|
2060
|
+
// Removing separator - keep in flow, will animate out
|
|
2061
|
+
const span = document.createElement("span");
|
|
2062
|
+
span.textContent = item.char;
|
|
2063
|
+
const oldKey = `${item.char}@${item.oldIndex}`;
|
|
2064
|
+
const oldPos = oldPositions.get(oldKey);
|
|
2065
|
+
span.setAttribute("data-flow", "");
|
|
2066
|
+
span.setAttribute("data-show", "");
|
|
2067
|
+
span.setAttribute("data-removing", "");
|
|
2068
|
+
span.style.overflow = "visible";
|
|
2069
|
+
span.style.display = "inline-block"; // Required for width animation on inline elements
|
|
2070
|
+
if (oldPos) {
|
|
2071
|
+
span.style.width = `${oldPos.width}px`;
|
|
2072
|
+
span.style.minWidth = `${oldPos.width}px`;
|
|
2073
|
+
span.style.maxWidth = `${oldPos.width}px`;
|
|
2074
|
+
}
|
|
2075
|
+
spanRef.current.appendChild(span);
|
|
2076
|
+
removingSpans.push(span);
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
cleanup2();
|
|
2080
|
+
// Force reflow
|
|
2081
|
+
void spanRef.current.offsetWidth;
|
|
2082
|
+
const cleanup3 = temporarilyRemoveAncestorsTransform(spanRef.current);
|
|
2083
|
+
// Get new container rect for position calculations
|
|
2084
|
+
const newContainerRect = spanRef.current.getBoundingClientRect();
|
|
2085
|
+
// Apply x-position animations for digits that moved
|
|
2086
|
+
newSpans.forEach((span) => {
|
|
2087
|
+
const char = span.textContent ?? "";
|
|
2088
|
+
if (!char) {
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
const isSeparator = !isRawChar(char);
|
|
2092
|
+
if (isSeparator) {
|
|
2093
|
+
return;
|
|
2094
|
+
} // Don't animate x for separators, they use width animation
|
|
2095
|
+
// Find the old position for this character using the mapping
|
|
2096
|
+
const newIndex = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
|
|
2097
|
+
const oldIndex = newToOldMapping.get(newIndex);
|
|
2098
|
+
if (oldIndex !== undefined) {
|
|
2099
|
+
const oldKey = `${char}@${oldIndex}`;
|
|
2100
|
+
const oldPos = oldPositions.get(oldKey);
|
|
2101
|
+
if (oldPos) {
|
|
2102
|
+
const newRect = span.getBoundingClientRect();
|
|
2103
|
+
const newX = newRect.left - newContainerRect.left;
|
|
2104
|
+
const offsetX = oldPos.x - newX;
|
|
2105
|
+
if (Math.abs(offsetX) > 1) {
|
|
2106
|
+
span.animate([
|
|
2107
|
+
{ transform: `translateX(${offsetX}px)` },
|
|
2108
|
+
{ transform: "translateX(0)" },
|
|
2109
|
+
], {
|
|
2110
|
+
duration: 400,
|
|
2111
|
+
easing: "cubic-bezier(.215, .61, .355, 1)",
|
|
2112
|
+
fill: "forwards",
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
cleanup3();
|
|
2119
|
+
// Trigger animations in next frame
|
|
2120
|
+
requestAnimationFrame(() => {
|
|
2121
|
+
// Animate in new separators (width from 0 to final + slide up)
|
|
2122
|
+
addedSeparatorSpans.forEach(({ span, finalWidth }) => {
|
|
2123
|
+
span.setAttribute("data-show", "");
|
|
2124
|
+
span.style.width = `${finalWidth}px`;
|
|
2125
|
+
span.style.minWidth = `${finalWidth}px`;
|
|
2126
|
+
span.style.maxWidth = `${finalWidth}px`;
|
|
2127
|
+
// Clean up inline styles after transition
|
|
2128
|
+
const handleTransitionEnd = (e) => {
|
|
2129
|
+
if (e.propertyName === "width") {
|
|
2130
|
+
span.style.width = "";
|
|
2131
|
+
span.style.minWidth = "";
|
|
2132
|
+
span.style.maxWidth = "";
|
|
2133
|
+
span.style.overflow = "";
|
|
2134
|
+
span.style.display = "";
|
|
2135
|
+
span.removeEventListener("transitionend", handleTransitionEnd);
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
span.addEventListener("transitionend", handleTransitionEnd);
|
|
2139
|
+
});
|
|
2140
|
+
// Clean up any digits that might have width styles (from previous animations)
|
|
2141
|
+
newSpans.forEach((span) => {
|
|
2142
|
+
const isSeparator = !isRawChar(span.textContent ?? "");
|
|
2143
|
+
if (!isSeparator && span.style.width) {
|
|
2144
|
+
const handleTransitionEnd = (e) => {
|
|
2145
|
+
if (e.propertyName === "width") {
|
|
2146
|
+
span.style.width = "";
|
|
2147
|
+
span.style.minWidth = "";
|
|
2148
|
+
span.style.maxWidth = "";
|
|
2149
|
+
span.style.display = "";
|
|
2150
|
+
span.removeEventListener("transitionend", handleTransitionEnd);
|
|
2151
|
+
}
|
|
2152
|
+
};
|
|
2153
|
+
span.addEventListener("transitionend", handleTransitionEnd);
|
|
2154
|
+
}
|
|
2155
|
+
});
|
|
2156
|
+
// Animate out removed separators (width to 0 + slide down)
|
|
2157
|
+
removingSpans.forEach((span) => {
|
|
2158
|
+
span.removeAttribute("data-show");
|
|
2159
|
+
span.setAttribute("data-hide", "");
|
|
2160
|
+
span.style.width = "0px";
|
|
2161
|
+
span.style.minWidth = "0px";
|
|
2162
|
+
span.style.maxWidth = "0px";
|
|
2163
|
+
// Remove after animation completes
|
|
2164
|
+
const handleTransitionEnd = (e) => {
|
|
2165
|
+
if (e.propertyName === "translate" || e.propertyName === "width") {
|
|
2166
|
+
span.removeEventListener("transitionend", handleTransitionEnd);
|
|
2167
|
+
// Only remove when both animations are done
|
|
2168
|
+
if (span.style.width === "0px" &&
|
|
2169
|
+
span.getAttribute("data-hide") !== null) {
|
|
2170
|
+
span.remove();
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
};
|
|
2174
|
+
span.addEventListener("transitionend", handleTransitionEnd);
|
|
2175
|
+
});
|
|
2176
|
+
// Reposition existing barrel wheels after DOM changes with x-position animation
|
|
2177
|
+
// We need to calculate the FINAL x position (after all width animations complete)
|
|
2178
|
+
if (parentContainer && existingBarrelWheels.length > 0) {
|
|
2179
|
+
// Create a set of removing span indices for quick lookup
|
|
2180
|
+
const removingSpanSet = new Set(removingSpans.map((s) => s));
|
|
2181
|
+
// Create a map of new separator spans to their final widths
|
|
2182
|
+
const separatorFinalWidths = new Map();
|
|
2183
|
+
addedSeparatorSpans.forEach(({ span, finalWidth }) => {
|
|
2184
|
+
separatorFinalWidths.set(span, finalWidth);
|
|
2185
|
+
});
|
|
2186
|
+
existingBarrelWheels.forEach(({ wheel, oldX }) => {
|
|
2187
|
+
const newIndex = barrelWheelNewIndices.get(wheel);
|
|
2188
|
+
if (newIndex === undefined) {
|
|
2189
|
+
return; // Was removed
|
|
2190
|
+
}
|
|
2191
|
+
// Find the span at the new index
|
|
2192
|
+
const span = spanRef.current?.querySelector(`[data-char-index="${newIndex}"]`);
|
|
2193
|
+
if (span && spanRef.current) {
|
|
2194
|
+
const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current);
|
|
2195
|
+
const parentRect = parentContainer.getBoundingClientRect();
|
|
2196
|
+
const containerRect = spanRef.current.getBoundingClientRect();
|
|
2197
|
+
// Calculate final x position by summing final widths of all elements before this span
|
|
2198
|
+
let finalX = containerRect.left - parentRect.left;
|
|
2199
|
+
let foundSpan = false;
|
|
2200
|
+
// Iterate through all children in order
|
|
2201
|
+
for (const child of Array.from(spanRef.current.children)) {
|
|
2202
|
+
if (child === span) {
|
|
2203
|
+
foundSpan = true;
|
|
2204
|
+
break;
|
|
2205
|
+
}
|
|
2206
|
+
const childEl = child;
|
|
2207
|
+
// Determine the final width of this element
|
|
2208
|
+
let elementFinalWidth;
|
|
2209
|
+
if (removingSpanSet.has(childEl)) {
|
|
2210
|
+
// Removing separator - final width is 0
|
|
2211
|
+
elementFinalWidth = 0;
|
|
2212
|
+
}
|
|
2213
|
+
else if (separatorFinalWidths.has(childEl)) {
|
|
2214
|
+
// New separator - use the final width (after animation)
|
|
2215
|
+
elementFinalWidth = separatorFinalWidths.get(childEl);
|
|
2216
|
+
}
|
|
2217
|
+
else {
|
|
2218
|
+
// Regular span - use measureText to get natural width
|
|
2219
|
+
const text = childEl.textContent ?? "";
|
|
2220
|
+
if (text) {
|
|
2221
|
+
elementFinalWidth = measureText(text, childEl);
|
|
2222
|
+
}
|
|
2223
|
+
else {
|
|
2224
|
+
elementFinalWidth = childEl.getBoundingClientRect().width;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
finalX += elementFinalWidth;
|
|
2228
|
+
}
|
|
2229
|
+
if (!foundSpan) {
|
|
2230
|
+
// Fallback to current position
|
|
2231
|
+
const spanRect = span.getBoundingClientRect();
|
|
2232
|
+
finalX = spanRect.left - parentRect.left;
|
|
2233
|
+
}
|
|
2234
|
+
// Get span dimensions
|
|
2235
|
+
const spanRect = span.getBoundingClientRect();
|
|
2236
|
+
const spanWidth = measureText(span.textContent ?? "", span);
|
|
2237
|
+
// Calculate x offset for animation (from old position to final position)
|
|
2238
|
+
const offsetX = oldX - finalX;
|
|
2239
|
+
cleanup();
|
|
2240
|
+
// Set final position immediately
|
|
2241
|
+
wheel.style.left = `${finalX}px`;
|
|
2242
|
+
wheel.style.top = `${spanRect.top - parentRect.top}px`;
|
|
2243
|
+
wheel.style.width = `${spanWidth}px`;
|
|
2244
|
+
wheel.style.height = `${spanRect.height}px`;
|
|
2245
|
+
// Animate x position if there's a significant change.
|
|
2246
|
+
// Match the digit-roll + data-width-animate timing (0.4s
|
|
2247
|
+
// ease-out-cubic) so the wheel reaches its final position at
|
|
2248
|
+
// the same moment the underlying span settles, avoiding any
|
|
2249
|
+
// visual jump when the wheel is removed.
|
|
2250
|
+
if (Math.abs(offsetX) > 1) {
|
|
2251
|
+
wheel.animate([
|
|
2252
|
+
{ transform: `translateX(${offsetX}px)` },
|
|
2253
|
+
{ transform: "translateX(0)" },
|
|
2254
|
+
], {
|
|
2255
|
+
duration: 400,
|
|
2256
|
+
easing: "cubic-bezier(.215, .61, .355, 1)",
|
|
2257
|
+
fill: "forwards",
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
// Ensure the span is transparent (barrel wheel is visible)
|
|
2261
|
+
span.style.color = "transparent";
|
|
2262
|
+
}
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
});
|
|
2266
|
+
// Update the ref to match current formatted value
|
|
2267
|
+
prevFormattedValueRef.current = formattedDisplayValue;
|
|
2268
|
+
// Only run when format or locale changes, not on initial mount
|
|
2269
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2270
|
+
}, [format, locale, formattedDisplayValue, isRawChar]);
|
|
2271
|
+
// Cleanup ResizeObservers on unmount
|
|
2272
|
+
useEffect(() => {
|
|
2273
|
+
const observers = resizeObserversRef.current;
|
|
2274
|
+
return () => {
|
|
2275
|
+
observers.forEach((observer) => observer.disconnect());
|
|
2276
|
+
observers.clear();
|
|
2277
|
+
};
|
|
2278
|
+
}, []);
|
|
2279
|
+
const applyHistoryItemWithCursor = useCallback((historyItem, cursorPos) => {
|
|
2280
|
+
isUndoRedoRef.current = true;
|
|
2281
|
+
setDisplayValue(historyItem.text);
|
|
2282
|
+
setUncontrolledValue(historyItem.value);
|
|
2283
|
+
onChange?.(historyItem.value);
|
|
2284
|
+
setCursorPosition(cursorPos);
|
|
2285
|
+
if (spanRef.current) {
|
|
2286
|
+
clearBarrelWheelsAndSpans(spanRef.current, spanRef.current.parentElement);
|
|
2287
|
+
spanRef.current.textContent = historyItem.text;
|
|
2288
|
+
spanRef.current.focus();
|
|
2289
|
+
// History stores raw cursor positions (no separators). After the
|
|
2290
|
+
// format effect rebuilds the DOM with separators we need to land
|
|
2291
|
+
// the cursor at the FORMATTED equivalent — otherwise a raw index
|
|
2292
|
+
// like 4 in "1,234" lands between the "3" and the "4".
|
|
2293
|
+
const clampedRawPos = Math.min(cursorPos, historyItem.text.length);
|
|
2294
|
+
const formattedText = formatRawValue(historyItem.text);
|
|
2295
|
+
const formattedCursorPos = mapRawToFormattedIndex(historyItem.text, formattedText, clampedRawPos);
|
|
2296
|
+
// Run synchronously while the DOM still contains the plain raw
|
|
2297
|
+
// text we just wrote (no [data-char-index] spans yet) — use the
|
|
2298
|
+
// raw position so the cursor lands in a sane spot. The rAF call
|
|
2299
|
+
// runs after React's commit phase and the format effect have
|
|
2300
|
+
// rebuilt the spans, so we use the formatted position there.
|
|
2301
|
+
const restoreCursorRaw = () => {
|
|
2302
|
+
if (!spanRef.current) {
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
spanRef.current.focus();
|
|
2306
|
+
setCursorAtPosition(spanRef.current, clampedRawPos);
|
|
2307
|
+
};
|
|
2308
|
+
const restoreCursorFormatted = () => {
|
|
2309
|
+
if (!spanRef.current) {
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
spanRef.current.focus();
|
|
2313
|
+
setCursorPositionInElement(spanRef.current, formattedCursorPos);
|
|
2314
|
+
isUndoRedoRef.current = false;
|
|
2315
|
+
};
|
|
2316
|
+
restoreCursorRaw();
|
|
2317
|
+
requestAnimationFrame(restoreCursorFormatted);
|
|
2318
|
+
}
|
|
2319
|
+
}, [onChange, formatRawValue, mapRawToFormattedIndex]);
|
|
2320
|
+
const applyHistoryItem = useCallback((historyItem, isUndo) => {
|
|
2321
|
+
if (!historyItem) {
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
// For redo: use cursorPosAfter from the item
|
|
2325
|
+
const targetCursorPos = isUndo
|
|
2326
|
+
? historyItem.cursorPosBefore
|
|
2327
|
+
: historyItem.cursorPosAfter;
|
|
2328
|
+
applyHistoryItemWithCursor(historyItem, targetCursorPos);
|
|
2329
|
+
}, [applyHistoryItemWithCursor]);
|
|
2330
|
+
const handleKeyDown = useCallback((event) => {
|
|
2331
|
+
const key = event.key;
|
|
2332
|
+
// Get current state (raw, unformatted)
|
|
2333
|
+
const currentText = displayValue;
|
|
2334
|
+
const currentFormattedText = formattedDisplayValue;
|
|
2335
|
+
const selection = window.getSelection();
|
|
2336
|
+
const range = selection?.getRangeAt(0);
|
|
2337
|
+
if (!range || !spanRef.current) {
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
if (!selection) {
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
// Get selection range in formatted positions
|
|
2344
|
+
const { start: formattedStart, end: formattedEnd } = getSelectionRange(spanRef.current, selection);
|
|
2345
|
+
// Convert to raw positions for working with displayValue
|
|
2346
|
+
const start = mapFormattedToRawIndex(currentText, currentFormattedText, formattedStart);
|
|
2347
|
+
const end = mapFormattedToRawIndex(currentText, currentFormattedText, formattedEnd);
|
|
2348
|
+
// Handle special keys
|
|
2349
|
+
if ((event.metaKey || event.ctrlKey) && key === "Backspace") {
|
|
2350
|
+
event.preventDefault();
|
|
2351
|
+
// Remove barrel wheels for all indices being deleted (from 0 to end)
|
|
2352
|
+
const indicesToRemove = [];
|
|
2353
|
+
for (let i = 0; i < end; i++) {
|
|
2354
|
+
indicesToRemove.push(i);
|
|
2355
|
+
}
|
|
2356
|
+
removeBarrelWheelsAtIndices(indicesToRemove);
|
|
2357
|
+
const newText = currentText.slice(end);
|
|
2358
|
+
updateValue(newText, 0, 0, end);
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
if (event.metaKey || event.ctrlKey) {
|
|
2362
|
+
// prevent rich text formatting shortcuts
|
|
2363
|
+
if (["b", "i", "u", "k"].includes(key.toLowerCase())) {
|
|
2364
|
+
event.preventDefault();
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
// Handle Undo (Cmd+Z / Ctrl+Z)
|
|
2368
|
+
if (key.toLowerCase() === "z" && !event.shiftKey) {
|
|
2369
|
+
event.preventDefault();
|
|
2370
|
+
if (historyIndexRef.current > 0) {
|
|
2371
|
+
// Get cursor position from current item BEFORE decrementing
|
|
2372
|
+
const cursorPos = historyRef.current[historyIndexRef.current]?.cursorPosBefore ??
|
|
2373
|
+
0;
|
|
2374
|
+
historyIndexRef.current--;
|
|
2375
|
+
// Restore text from previous item, but use cursor position from current item
|
|
2376
|
+
const prevItem = historyRef.current[historyIndexRef.current];
|
|
2377
|
+
if (prevItem) {
|
|
2378
|
+
applyHistoryItemWithCursor(prevItem, cursorPos);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
// Handle Redo (Cmd+Shift+Z / Ctrl+Y or Ctrl+Shift+Z)
|
|
2384
|
+
if ((key.toLowerCase() === "z" && event.shiftKey) ||
|
|
2385
|
+
key.toLowerCase() === "y") {
|
|
2386
|
+
event.preventDefault();
|
|
2387
|
+
if (historyIndexRef.current < historyRef.current.length - 1) {
|
|
2388
|
+
historyIndexRef.current++;
|
|
2389
|
+
// For redo, use cursorPosAfter from the item we're restoring to
|
|
2390
|
+
applyHistoryItem(historyRef.current[historyIndexRef.current], false);
|
|
2391
|
+
}
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
// Handle Cut (Cmd+X / Ctrl+X)
|
|
2395
|
+
if (key.toLowerCase() === "x") {
|
|
2396
|
+
event.preventDefault();
|
|
2397
|
+
// Copy to clipboard (browser handles this automatically, but we need to handle the deletion)
|
|
2398
|
+
if (start !== end) {
|
|
2399
|
+
const selectedText = currentText.slice(start, end);
|
|
2400
|
+
// Try to copy to clipboard, but don't fail if clipboard API is not available (e.g., in tests)
|
|
2401
|
+
if (typeof navigator !== "undefined" &&
|
|
2402
|
+
navigator.clipboard &&
|
|
2403
|
+
navigator.clipboard.writeText) {
|
|
2404
|
+
navigator.clipboard.writeText(selectedText).catch(() => {
|
|
2405
|
+
// Fallback if clipboard API fails
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
// Delete the selected text
|
|
2409
|
+
const newText = currentText.slice(0, start) + currentText.slice(end);
|
|
2410
|
+
updateValue(newText, start, start, end);
|
|
2411
|
+
}
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
// Handle Alt/Cmd+ArrowLeft/ArrowRight (move to start/end)
|
|
2416
|
+
if ((event.metaKey || event.ctrlKey || event.altKey) &&
|
|
2417
|
+
(key === "ArrowLeft" || key === "ArrowRight")) {
|
|
2418
|
+
event.preventDefault();
|
|
2419
|
+
if (!spanRef.current) {
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
const selection = window.getSelection();
|
|
2423
|
+
if (!selection) {
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2426
|
+
// Use formatted text length for target position
|
|
2427
|
+
const targetPos = key === "ArrowLeft" ? 0 : currentFormattedText.length;
|
|
2428
|
+
if (event.shiftKey) {
|
|
2429
|
+
// Extend selection to start/end
|
|
2430
|
+
// Use the selection's anchor point as the anchor (formatted position)
|
|
2431
|
+
let anchorPos = formattedStart;
|
|
2432
|
+
if (selection.anchorNode &&
|
|
2433
|
+
spanRef.current.contains(selection.anchorNode)) {
|
|
2434
|
+
const anchorRange = document.createRange();
|
|
2435
|
+
anchorRange.selectNodeContents(spanRef.current);
|
|
2436
|
+
anchorRange.setEnd(selection.anchorNode, selection.anchorOffset);
|
|
2437
|
+
anchorPos = anchorRange.toString().length;
|
|
2438
|
+
}
|
|
2439
|
+
// Find both anchor and target nodes/offsets
|
|
2440
|
+
let currentPos = 0;
|
|
2441
|
+
const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
|
|
2442
|
+
let anchorNode = null;
|
|
2443
|
+
let anchorOffset = 0;
|
|
2444
|
+
let targetNode = null;
|
|
2445
|
+
let targetOffset = 0;
|
|
2446
|
+
let node;
|
|
2447
|
+
while ((node = walker.nextNode())) {
|
|
2448
|
+
const nodeLength = node.textContent?.length ?? 0;
|
|
2449
|
+
// Find anchor node (selection anchor position)
|
|
2450
|
+
if (!anchorNode && currentPos + nodeLength >= anchorPos) {
|
|
2451
|
+
anchorNode = node;
|
|
2452
|
+
anchorOffset = anchorPos - currentPos;
|
|
2453
|
+
}
|
|
2454
|
+
// Find target node
|
|
2455
|
+
if (!targetNode && currentPos + nodeLength >= targetPos) {
|
|
2456
|
+
targetNode = node;
|
|
2457
|
+
targetOffset = targetPos - currentPos;
|
|
2458
|
+
}
|
|
2459
|
+
if (anchorNode && targetNode) {
|
|
2460
|
+
break;
|
|
2461
|
+
}
|
|
2462
|
+
currentPos += nodeLength;
|
|
2463
|
+
}
|
|
2464
|
+
if (anchorNode && targetNode) {
|
|
2465
|
+
const range = document.createRange();
|
|
2466
|
+
// Set range from anchor to target (direction matters for selection direction)
|
|
2467
|
+
if (key === "ArrowLeft") {
|
|
2468
|
+
// Selecting backwards - anchor stays, extend to start
|
|
2469
|
+
range.setStart(targetNode, targetOffset);
|
|
2470
|
+
range.setEnd(anchorNode, anchorOffset);
|
|
2471
|
+
}
|
|
2472
|
+
else {
|
|
2473
|
+
// Selecting forwards - anchor stays, extend to end
|
|
2474
|
+
range.setStart(anchorNode, anchorOffset);
|
|
2475
|
+
range.setEnd(targetNode, targetOffset);
|
|
2476
|
+
}
|
|
2477
|
+
selection.removeAllRanges();
|
|
2478
|
+
selection.addRange(range);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
else {
|
|
2482
|
+
// Move cursor to start/end
|
|
2483
|
+
let currentPos = 0;
|
|
2484
|
+
const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
|
|
2485
|
+
let node;
|
|
2486
|
+
while ((node = walker.nextNode())) {
|
|
2487
|
+
const nodeLength = node.textContent?.length ?? 0;
|
|
2488
|
+
if (currentPos + nodeLength >= targetPos) {
|
|
2489
|
+
const offset = targetPos - currentPos;
|
|
2490
|
+
const range = document.createRange();
|
|
2491
|
+
range.setStart(node, offset);
|
|
2492
|
+
range.collapse(true);
|
|
2493
|
+
selection.removeAllRanges();
|
|
2494
|
+
selection.addRange(range);
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
currentPos += nodeLength;
|
|
2498
|
+
}
|
|
2499
|
+
// Fallback
|
|
2500
|
+
const range = document.createRange();
|
|
2501
|
+
range.selectNodeContents(spanRef.current);
|
|
2502
|
+
range.collapse(key === "ArrowLeft");
|
|
2503
|
+
selection.removeAllRanges();
|
|
2504
|
+
selection.addRange(range);
|
|
2505
|
+
}
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
const allowedKeys = [
|
|
2509
|
+
"Backspace",
|
|
2510
|
+
"Delete",
|
|
2511
|
+
"ArrowLeft",
|
|
2512
|
+
"ArrowRight",
|
|
2513
|
+
"Tab",
|
|
2514
|
+
"Home",
|
|
2515
|
+
"End",
|
|
2516
|
+
];
|
|
2517
|
+
if (allowedKeys.includes(key)) {
|
|
2518
|
+
// Handle Backspace and Delete ourselves
|
|
2519
|
+
if (key === "Backspace") {
|
|
2520
|
+
event.preventDefault();
|
|
2521
|
+
if (start === end) {
|
|
2522
|
+
// No selection, delete character before cursor
|
|
2523
|
+
if (start > 0) {
|
|
2524
|
+
// Remove barrel wheel at the position being deleted
|
|
2525
|
+
removeBarrelWheelsAtIndices([start - 1]);
|
|
2526
|
+
const newText = currentText.slice(0, start - 1) + currentText.slice(start);
|
|
2527
|
+
updateValue(newText, start - 1, start - 1, start);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
else {
|
|
2531
|
+
// Has selection, delete selected text
|
|
2532
|
+
// Remove barrel wheels for all indices in the selection range
|
|
2533
|
+
const indicesToRemove = [];
|
|
2534
|
+
for (let i = start; i < end; i++) {
|
|
2535
|
+
indicesToRemove.push(i);
|
|
2536
|
+
}
|
|
2537
|
+
removeBarrelWheelsAtIndices(indicesToRemove);
|
|
2538
|
+
const newText = currentText.slice(0, start) + currentText.slice(end);
|
|
2539
|
+
updateValue(newText, start, start, end);
|
|
2540
|
+
}
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
if (key === "Delete") {
|
|
2544
|
+
event.preventDefault();
|
|
2545
|
+
if (start === end) {
|
|
2546
|
+
// No selection
|
|
2547
|
+
if (event.metaKey || event.ctrlKey) {
|
|
2548
|
+
// Ctrl/Cmd+Delete: delete all characters after cursor
|
|
2549
|
+
if (start < currentText.length) {
|
|
2550
|
+
// Remove barrel wheels for all indices being deleted
|
|
2551
|
+
const indicesToRemove = [];
|
|
2552
|
+
for (let i = start; i < currentText.length; i++) {
|
|
2553
|
+
indicesToRemove.push(i);
|
|
2554
|
+
}
|
|
2555
|
+
removeBarrelWheelsAtIndices(indicesToRemove);
|
|
2556
|
+
const newText = currentText.slice(0, start);
|
|
2557
|
+
updateValue(newText, start, start, currentText.length);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
else {
|
|
2561
|
+
// Delete: delete one character after cursor
|
|
2562
|
+
if (start < currentText.length) {
|
|
2563
|
+
// Remove barrel wheel at the position being deleted
|
|
2564
|
+
removeBarrelWheelsAtIndices([start]);
|
|
2565
|
+
const newText = currentText.slice(0, start) + currentText.slice(start + 1);
|
|
2566
|
+
updateValue(newText, start, start, start + 1);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
else {
|
|
2571
|
+
// Has selection, delete selected text
|
|
2572
|
+
// Remove barrel wheels for all indices in the selection range
|
|
2573
|
+
const indicesToRemove = [];
|
|
2574
|
+
for (let i = start; i < end; i++) {
|
|
2575
|
+
indicesToRemove.push(i);
|
|
2576
|
+
}
|
|
2577
|
+
removeBarrelWheelsAtIndices(indicesToRemove);
|
|
2578
|
+
const newText = currentText.slice(0, start) + currentText.slice(end);
|
|
2579
|
+
updateValue(newText, start, start, end);
|
|
2580
|
+
}
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
// Handle ArrowLeft and ArrowRight to move cursor by one character
|
|
2584
|
+
// For formatted numbers, we need to work with formatted positions and skip separators
|
|
2585
|
+
if (key === "ArrowLeft" || key === "ArrowRight") {
|
|
2586
|
+
event.preventDefault();
|
|
2587
|
+
if (!spanRef.current) {
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
const selection = window.getSelection();
|
|
2591
|
+
if (!selection) {
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
// Helper to check if a character is a separator (not digit, dot, or minus)
|
|
2595
|
+
const isSeparator = (char) => {
|
|
2596
|
+
if (!char) {
|
|
2597
|
+
return false;
|
|
2598
|
+
}
|
|
2599
|
+
return new RegExp(`[^\\d.${localeSeparators.decimal}-]`).test(char);
|
|
2600
|
+
};
|
|
2601
|
+
// Get current cursor position in formatted text
|
|
2602
|
+
const { start: formattedCursorStart, end: formattedCursorEnd } = getSelectionRange(spanRef.current, selection);
|
|
2603
|
+
// Calculate target position in formatted text
|
|
2604
|
+
let targetFormattedPos;
|
|
2605
|
+
if (event.shiftKey) {
|
|
2606
|
+
// Extend selection - use formatted positions directly
|
|
2607
|
+
const getPositionFromNode = (node, offset) => {
|
|
2608
|
+
if (!node || !spanRef.current?.contains(node)) {
|
|
2609
|
+
return formattedCursorStart;
|
|
2610
|
+
}
|
|
2611
|
+
const range = document.createRange();
|
|
2612
|
+
range.setStart(spanRef.current, 0);
|
|
2613
|
+
range.setEnd(node, offset);
|
|
2614
|
+
return range.toString().length;
|
|
2615
|
+
};
|
|
2616
|
+
let anchorPos = getPositionFromNode(selection.anchorNode, selection.anchorOffset);
|
|
2617
|
+
let focusPos = getPositionFromNode(selection.focusNode, selection.focusOffset);
|
|
2618
|
+
if (anchorPos === focusPos &&
|
|
2619
|
+
formattedCursorStart === formattedCursorEnd) {
|
|
2620
|
+
anchorPos = formattedCursorStart;
|
|
2621
|
+
focusPos = formattedCursorStart;
|
|
2622
|
+
}
|
|
2623
|
+
// Move focus, skipping separators
|
|
2624
|
+
if (key === "ArrowLeft") {
|
|
2625
|
+
targetFormattedPos = Math.max(0, focusPos - 1);
|
|
2626
|
+
// Skip over separators when moving left
|
|
2627
|
+
while (targetFormattedPos > 0 &&
|
|
2628
|
+
isSeparator(currentFormattedText[targetFormattedPos])) {
|
|
2629
|
+
targetFormattedPos--;
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
else {
|
|
2633
|
+
targetFormattedPos = Math.min(currentFormattedText.length, focusPos + 1);
|
|
2634
|
+
// Skip over separators when moving right
|
|
2635
|
+
while (targetFormattedPos < currentFormattedText.length &&
|
|
2636
|
+
isSeparator(currentFormattedText[targetFormattedPos])) {
|
|
2637
|
+
targetFormattedPos++;
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
// Find nodes for selection
|
|
2641
|
+
let currentPos = 0;
|
|
2642
|
+
const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
|
|
2643
|
+
let anchorNode = null;
|
|
2644
|
+
let anchorOffset = 0;
|
|
2645
|
+
let targetNode = null;
|
|
2646
|
+
let targetOffset = 0;
|
|
2647
|
+
let node;
|
|
2648
|
+
while ((node = walker.nextNode())) {
|
|
2649
|
+
const nodeLength = node.textContent?.length ?? 0;
|
|
2650
|
+
if (!anchorNode && currentPos + nodeLength >= anchorPos) {
|
|
2651
|
+
anchorNode = node;
|
|
2652
|
+
anchorOffset = anchorPos - currentPos;
|
|
2653
|
+
}
|
|
2654
|
+
if (!targetNode &&
|
|
2655
|
+
currentPos + nodeLength >= targetFormattedPos) {
|
|
2656
|
+
targetNode = node;
|
|
2657
|
+
targetOffset = targetFormattedPos - currentPos;
|
|
2658
|
+
}
|
|
2659
|
+
if (anchorNode && targetNode) {
|
|
2660
|
+
break;
|
|
2661
|
+
}
|
|
2662
|
+
currentPos += nodeLength;
|
|
2663
|
+
}
|
|
2664
|
+
if (targetNode) {
|
|
2665
|
+
try {
|
|
2666
|
+
selection.extend(targetNode, targetOffset);
|
|
2667
|
+
}
|
|
2668
|
+
catch {
|
|
2669
|
+
if (anchorNode) {
|
|
2670
|
+
const range = document.createRange();
|
|
2671
|
+
range.setStart(anchorNode, anchorOffset);
|
|
2672
|
+
range.setEnd(targetNode, targetOffset);
|
|
2673
|
+
selection.removeAllRanges();
|
|
2674
|
+
selection.addRange(range);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
else {
|
|
2680
|
+
// Move cursor (no shift key)
|
|
2681
|
+
if (formattedCursorStart !== formattedCursorEnd) {
|
|
2682
|
+
// There's a selection - move to start or end based on arrow direction
|
|
2683
|
+
targetFormattedPos =
|
|
2684
|
+
key === "ArrowLeft"
|
|
2685
|
+
? formattedCursorStart
|
|
2686
|
+
: formattedCursorEnd;
|
|
2687
|
+
}
|
|
2688
|
+
else {
|
|
2689
|
+
// No selection - move cursor by one position, skipping separators
|
|
2690
|
+
if (key === "ArrowLeft") {
|
|
2691
|
+
targetFormattedPos = Math.max(0, formattedCursorStart - 1);
|
|
2692
|
+
// Skip over separators when moving left
|
|
2693
|
+
while (targetFormattedPos > 0 &&
|
|
2694
|
+
isSeparator(currentFormattedText[targetFormattedPos])) {
|
|
2695
|
+
targetFormattedPos--;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
else {
|
|
2699
|
+
targetFormattedPos = Math.min(currentFormattedText.length, formattedCursorStart + 1);
|
|
2700
|
+
// Skip over separators when moving right
|
|
2701
|
+
while (targetFormattedPos < currentFormattedText.length &&
|
|
2702
|
+
isSeparator(currentFormattedText[targetFormattedPos])) {
|
|
2703
|
+
targetFormattedPos++;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
// Find target node using formatted position
|
|
2708
|
+
let currentPos = 0;
|
|
2709
|
+
const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
|
|
2710
|
+
let node;
|
|
2711
|
+
while ((node = walker.nextNode())) {
|
|
2712
|
+
const nodeLength = node.textContent?.length ?? 0;
|
|
2713
|
+
if (currentPos + nodeLength >= targetFormattedPos) {
|
|
2714
|
+
const offset = targetFormattedPos - currentPos;
|
|
2715
|
+
const range = document.createRange();
|
|
2716
|
+
range.setStart(node, offset);
|
|
2717
|
+
range.collapse(true);
|
|
2718
|
+
selection.removeAllRanges();
|
|
2719
|
+
selection.addRange(range);
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
currentPos += nodeLength;
|
|
2723
|
+
}
|
|
2724
|
+
// Fallback to start/end
|
|
2725
|
+
const range = document.createRange();
|
|
2726
|
+
range.selectNodeContents(spanRef.current);
|
|
2727
|
+
range.collapse(key === "ArrowLeft");
|
|
2728
|
+
selection.removeAllRanges();
|
|
2729
|
+
selection.addRange(range);
|
|
2730
|
+
}
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
// Allow other navigation keys
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
if (event.ctrlKey || event.metaKey) {
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
// Handle character input
|
|
2740
|
+
if (/^\d$/.test(key)) {
|
|
2741
|
+
// Prevent typing more decimals than allowed
|
|
2742
|
+
const decimalPart = currentText.split(".")[1];
|
|
2743
|
+
if (isNonNullable(decimalScale) &&
|
|
2744
|
+
decimalPart &&
|
|
2745
|
+
decimalPart.length >= decimalScale &&
|
|
2746
|
+
Math.abs(end - start) !== 1) {
|
|
2747
|
+
const decimalPosition = currentText.indexOf(".");
|
|
2748
|
+
const isCursorInDecimalPart = start > decimalPosition;
|
|
2749
|
+
if (isCursorInDecimalPart) {
|
|
2750
|
+
event.preventDefault();
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
// Prevent typing digit when cursor is at position 0 and text starts with "-"
|
|
2755
|
+
if (currentText.startsWith("-") && start === 0 && end === 0) {
|
|
2756
|
+
event.preventDefault();
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
// Prevent adding 0 when there's already a leading 0 and cursor is before/after it
|
|
2760
|
+
// Also prevent adding 0 at the beginning of a number (would create leading zero)
|
|
2761
|
+
if (key === "0") {
|
|
2762
|
+
let shouldPrevent = false;
|
|
2763
|
+
let restorePos = start;
|
|
2764
|
+
// Check if text starts with "0" (including "0.")
|
|
2765
|
+
if (currentText.startsWith("0") && currentText.length > 0) {
|
|
2766
|
+
// Cursor is at position 0 (before the 0) - prevent typing another 0
|
|
2767
|
+
if (start === 0) {
|
|
2768
|
+
shouldPrevent = true;
|
|
2769
|
+
restorePos = start;
|
|
2770
|
+
}
|
|
2771
|
+
// Cursor is at position 1 (right after the 0) - prevent typing another 0
|
|
2772
|
+
// This applies even if followed by "." (e.g., "0.1121" should not become "00.1121")
|
|
2773
|
+
else if (start === 1 && end === 1) {
|
|
2774
|
+
shouldPrevent = true;
|
|
2775
|
+
restorePos = start;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
// Check if text starts with "-0" (including "-0.")
|
|
2779
|
+
else if (currentText.startsWith("-0") && currentText.length > 1) {
|
|
2780
|
+
// Cursor is at position 1 (right after "-") or 2 (right after "-0")
|
|
2781
|
+
// Prevent typing 0 at position 1 if we already have "-0" (whether followed by "." or not)
|
|
2782
|
+
if (start === 1) {
|
|
2783
|
+
shouldPrevent = true;
|
|
2784
|
+
restorePos = start;
|
|
2785
|
+
}
|
|
2786
|
+
else if (start === 2 && end === 2) {
|
|
2787
|
+
// Also prevent at position 2 (even if followed by ".")
|
|
2788
|
+
// This applies even if followed by "." (e.g., "-0.1121" should not become "-00.1121")
|
|
2789
|
+
shouldPrevent = true;
|
|
2790
|
+
restorePos = start;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
// Prevent adding 0 at the beginning of a number (would create leading zero like "012")
|
|
2794
|
+
// BUT allow it when text starts with "." (e.g., ".1121" -> "0.1121")
|
|
2795
|
+
else if (start === 0 &&
|
|
2796
|
+
currentText.length > 0 &&
|
|
2797
|
+
!currentText.startsWith("0") &&
|
|
2798
|
+
!currentText.startsWith("-") &&
|
|
2799
|
+
!currentText.startsWith(".")) {
|
|
2800
|
+
// Typing 0 at position 0 of a number like "12" would create "012" which gets cleaned to "12"
|
|
2801
|
+
// So we should prevent it (unless text starts with ".")
|
|
2802
|
+
shouldPrevent = true;
|
|
2803
|
+
restorePos = start;
|
|
2804
|
+
}
|
|
2805
|
+
// Prevent adding 0 after minus in negative number (would create leading zero like "-012")
|
|
2806
|
+
// BUT allow it when the next character is "." (e.g., "-.1121" -> "-0.1121")
|
|
2807
|
+
else if (currentText.startsWith("-") &&
|
|
2808
|
+
start === 1 &&
|
|
2809
|
+
currentText.length > 1 &&
|
|
2810
|
+
currentText[1] !== "0" &&
|
|
2811
|
+
currentText[1] !== ".") {
|
|
2812
|
+
// Typing 0 at position 1 after "-" in a number like "-12" would create "-012" which gets cleaned to "-12"
|
|
2813
|
+
// So we should prevent it (unless the next character is ".")
|
|
2814
|
+
shouldPrevent = true;
|
|
2815
|
+
restorePos = start;
|
|
2816
|
+
}
|
|
2817
|
+
if (shouldPrevent) {
|
|
2818
|
+
event.preventDefault();
|
|
2819
|
+
event.stopPropagation();
|
|
2820
|
+
// Mark that we should prevent the next input event
|
|
2821
|
+
shouldPreventInputRef.current = true;
|
|
2822
|
+
preventInputCursorPosRef.current = restorePos;
|
|
2823
|
+
// Restore cursor to original position - use both immediate and deferred restoration
|
|
2824
|
+
// to catch any browser default behavior
|
|
2825
|
+
if (spanRef.current) {
|
|
2826
|
+
const restoreCursor = () => {
|
|
2827
|
+
if (!spanRef.current) {
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
const selection = window.getSelection();
|
|
2831
|
+
if (!selection) {
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
let currentPos = 0;
|
|
2835
|
+
const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
|
|
2836
|
+
let node;
|
|
2837
|
+
while ((node = walker.nextNode())) {
|
|
2838
|
+
const nodeLength = node.textContent?.length ?? 0;
|
|
2839
|
+
if (currentPos + nodeLength >= restorePos) {
|
|
2840
|
+
const offset = Math.min(restorePos - currentPos, nodeLength);
|
|
2841
|
+
const range = document.createRange();
|
|
2842
|
+
range.setStart(node, offset);
|
|
2843
|
+
range.collapse(true);
|
|
2844
|
+
selection.removeAllRanges();
|
|
2845
|
+
selection.addRange(range);
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
currentPos += nodeLength;
|
|
2849
|
+
}
|
|
2850
|
+
// Fallback
|
|
2851
|
+
const range = document.createRange();
|
|
2852
|
+
range.selectNodeContents(spanRef.current);
|
|
2853
|
+
range.collapse(true);
|
|
2854
|
+
selection.removeAllRanges();
|
|
2855
|
+
selection.addRange(range);
|
|
2856
|
+
};
|
|
2857
|
+
// Try immediately
|
|
2858
|
+
restoreCursor();
|
|
2859
|
+
// Also try after a microtask to catch any delayed browser behavior
|
|
2860
|
+
Promise.resolve().then(restoreCursor);
|
|
2861
|
+
// And after a short timeout as a final safeguard
|
|
2862
|
+
setTimeout(restoreCursor, 0);
|
|
2863
|
+
requestAnimationFrame(restoreCursor);
|
|
2864
|
+
}
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
event.preventDefault();
|
|
2869
|
+
// Check maxLength before inserting
|
|
2870
|
+
const newLength = currentText.length - (end - start) + 1;
|
|
2871
|
+
if (maxLength !== undefined && newLength > maxLength) {
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
const newText = currentText.slice(0, start) + key + currentText.slice(end);
|
|
2875
|
+
updateValue(newText, start + 1, start, end);
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
// Prevent default for other character inputs
|
|
2879
|
+
event.preventDefault();
|
|
2880
|
+
// Handle decimal point input - accept both '.' and locale decimal separator
|
|
2881
|
+
const { decimal } = localeSeparators;
|
|
2882
|
+
if (key === "." || key === decimal) {
|
|
2883
|
+
// Only allow one decimal point (internally stored as '.')
|
|
2884
|
+
if (!currentText.includes(".")) {
|
|
2885
|
+
// Prevent typing decimal when cursor is at position 0 and text starts with "-"
|
|
2886
|
+
if (currentText.startsWith("-") && start === 0 && end === 0) {
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
// Check maxLength before inserting
|
|
2890
|
+
const newLength = currentText.length - (end - start) + 1;
|
|
2891
|
+
if (maxLength !== undefined && newLength > maxLength) {
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
// Prevent if no decimals allowed
|
|
2895
|
+
if (decimalScale === 0) {
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2898
|
+
// Always insert '.' internally (will be displayed as locale decimal)
|
|
2899
|
+
const newText = currentText.slice(0, start) + "." + currentText.slice(end);
|
|
2900
|
+
const decimalPart = newText.split(".")[1];
|
|
2901
|
+
const shouldCropDecimalPart = isNonNullable(decimalScale) &&
|
|
2902
|
+
decimalPart &&
|
|
2903
|
+
decimalPart.length > decimalScale;
|
|
2904
|
+
if (shouldCropDecimalPart) {
|
|
2905
|
+
const decimalPosition = newText.indexOf(".");
|
|
2906
|
+
updateValue(newText.slice(0, decimalPosition + decimalScale + 1), start + 1, start, end);
|
|
2907
|
+
}
|
|
2908
|
+
else {
|
|
2909
|
+
updateValue(newText, start + 1, start, end);
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
if (key === "-") {
|
|
2915
|
+
// Only allow minus at the beginning, and only if there isn't already one
|
|
2916
|
+
const hasMinus = currentText.startsWith("-");
|
|
2917
|
+
if (start === 0 && !hasMinus && allowNegative) {
|
|
2918
|
+
// Check maxLength before inserting
|
|
2919
|
+
const newLength = currentText.length - (end - start) + 1;
|
|
2920
|
+
if (maxLength !== undefined && newLength > maxLength) {
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
// Insert minus at the beginning (can replace selection)
|
|
2924
|
+
const newText = key + currentText.slice(end);
|
|
2925
|
+
updateValue(newText, start + 1, start, end);
|
|
2926
|
+
}
|
|
2927
|
+
// If there's already a minus, ignore the input (don't toggle or insert)
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
}, [
|
|
2931
|
+
decimalScale,
|
|
2932
|
+
allowNegative,
|
|
2933
|
+
displayValue,
|
|
2934
|
+
formattedDisplayValue,
|
|
2935
|
+
mapFormattedToRawIndex,
|
|
2936
|
+
localeSeparators,
|
|
2937
|
+
removeBarrelWheelsAtIndices,
|
|
2938
|
+
updateValue,
|
|
2939
|
+
applyHistoryItemWithCursor,
|
|
2940
|
+
applyHistoryItem,
|
|
2941
|
+
maxLength,
|
|
2942
|
+
]);
|
|
2943
|
+
const handleCopy = useCallback((event) => {
|
|
2944
|
+
const selection = window.getSelection();
|
|
2945
|
+
const range = selection?.getRangeAt(0);
|
|
2946
|
+
if (!range || !spanRef.current) {
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
if (!selection) {
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
const { start: formattedStart, end: formattedEnd } = getSelectionRange(spanRef.current, selection);
|
|
2953
|
+
const start = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedStart);
|
|
2954
|
+
const end = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedEnd);
|
|
2955
|
+
if (start === end) {
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
const selectedText = displayValue.slice(start, end);
|
|
2959
|
+
event.clipboardData.setData("text/plain", selectedText);
|
|
2960
|
+
event.preventDefault();
|
|
2961
|
+
}, [displayValue, formattedDisplayValue, mapFormattedToRawIndex]);
|
|
2962
|
+
const handleCut = useCallback((event) => {
|
|
2963
|
+
const selection = window.getSelection();
|
|
2964
|
+
const range = selection?.getRangeAt(0);
|
|
2965
|
+
if (!range || !spanRef.current) {
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
if (!selection) {
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
const { start: formattedStart, end: formattedEnd } = getSelectionRange(spanRef.current, selection);
|
|
2972
|
+
const start = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedStart);
|
|
2973
|
+
const end = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedEnd);
|
|
2974
|
+
if (start === end) {
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
const selectedText = displayValue.slice(start, end);
|
|
2978
|
+
event.clipboardData.setData("text/plain", selectedText);
|
|
2979
|
+
const newText = displayValue.slice(0, start) + displayValue.slice(end);
|
|
2980
|
+
setTimeout(() => {
|
|
2981
|
+
updateValue(newText, start, start, end);
|
|
2982
|
+
}, 0);
|
|
2983
|
+
}, [
|
|
2984
|
+
displayValue,
|
|
2985
|
+
formattedDisplayValue,
|
|
2986
|
+
mapFormattedToRawIndex,
|
|
2987
|
+
updateValue,
|
|
2988
|
+
]);
|
|
2989
|
+
const handleBeforeInput = useCallback((event) => {
|
|
2990
|
+
if (shouldPreventInputRef.current) {
|
|
2991
|
+
event.preventDefault();
|
|
2992
|
+
const restorePos = preventInputCursorPosRef.current;
|
|
2993
|
+
shouldPreventInputRef.current = false;
|
|
2994
|
+
const restoreCursor = () => {
|
|
2995
|
+
if (spanRef.current) {
|
|
2996
|
+
setCursorPositionInElement(spanRef.current, restorePos);
|
|
2997
|
+
}
|
|
2998
|
+
};
|
|
2999
|
+
restoreCursor();
|
|
3000
|
+
Promise.resolve().then(restoreCursor);
|
|
3001
|
+
setTimeout(restoreCursor, 0);
|
|
3002
|
+
requestAnimationFrame(restoreCursor);
|
|
3003
|
+
}
|
|
3004
|
+
}, []);
|
|
3005
|
+
const handleInput = useCallback(() => {
|
|
3006
|
+
// Reset the prevent flag after input is processed
|
|
3007
|
+
if (shouldPreventInputRef.current) {
|
|
3008
|
+
shouldPreventInputRef.current = false;
|
|
3009
|
+
}
|
|
3010
|
+
}, []);
|
|
3011
|
+
const handlePaste = useCallback((event) => {
|
|
3012
|
+
event.preventDefault();
|
|
3013
|
+
let pastedText = event.clipboardData.getData("text");
|
|
3014
|
+
// Convert locale decimal separator to '.' for internal storage
|
|
3015
|
+
const { decimal } = localeSeparators;
|
|
3016
|
+
if (decimal !== ".") {
|
|
3017
|
+
// Replace locale decimal with '.' and also accept '.' as-is
|
|
3018
|
+
pastedText = pastedText.replace(new RegExp(`\\${decimal}`, "g"), ".");
|
|
3019
|
+
}
|
|
3020
|
+
// Validate: only allow digits, optional minus at start, optional single decimal
|
|
3021
|
+
if (!/^-?\d*\.?\d*$/.test(pastedText)) {
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
// Should we prevent negative numbers?
|
|
3025
|
+
if (!allowNegative && pastedText.startsWith("-")) {
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
// Prevent pasting more than requested decimal scale
|
|
3029
|
+
const decimalPart = pastedText.split(".")[1];
|
|
3030
|
+
if (isNonNullable(decimalScale) &&
|
|
3031
|
+
decimalPart &&
|
|
3032
|
+
decimalPart.length > decimalScale) {
|
|
3033
|
+
if (decimalScale === 0) {
|
|
3034
|
+
pastedText = pastedText.slice(0, pastedText.indexOf("."));
|
|
3035
|
+
}
|
|
3036
|
+
else {
|
|
3037
|
+
pastedText = pastedText.slice(0, pastedText.indexOf(".") + 1 + decimalScale);
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
const selection = window.getSelection();
|
|
3041
|
+
const range = selection?.getRangeAt(0);
|
|
3042
|
+
if (!range || !spanRef.current) {
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
if (!selection) {
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
const { start: formattedStart, end: formattedEnd } = getSelectionRange(spanRef.current, selection);
|
|
3049
|
+
const start = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedStart);
|
|
3050
|
+
const end = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedEnd);
|
|
3051
|
+
// Truncate pasted text if it would exceed maxLength
|
|
3052
|
+
if (maxLength !== undefined) {
|
|
3053
|
+
const availableLength = maxLength - (displayValue.length - (end - start));
|
|
3054
|
+
if (availableLength <= 0) {
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
if (pastedText.length > availableLength) {
|
|
3058
|
+
pastedText = pastedText.slice(0, availableLength);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
const newText = displayValue.slice(0, start) + pastedText + displayValue.slice(end);
|
|
3062
|
+
if (/^-?\d*\.?\d*$/.test(newText)) {
|
|
3063
|
+
updateValue(newText, start + pastedText.length, start, end);
|
|
3064
|
+
}
|
|
3065
|
+
}, [
|
|
3066
|
+
decimalScale,
|
|
3067
|
+
allowNegative,
|
|
3068
|
+
localeSeparators,
|
|
3069
|
+
mapFormattedToRawIndex,
|
|
3070
|
+
displayValue,
|
|
3071
|
+
formattedDisplayValue,
|
|
3072
|
+
maxLength,
|
|
3073
|
+
updateValue,
|
|
3074
|
+
]);
|
|
3075
|
+
useEffect(() => {
|
|
3076
|
+
if (autoFocus) {
|
|
3077
|
+
spanRef.current?.focus();
|
|
3078
|
+
}
|
|
3079
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3080
|
+
}, []);
|
|
3081
|
+
return (_jsx(_Fragment, { children: _jsx("span", { className: className, "data-numberflow-input-root": "", style: {
|
|
3082
|
+
display: "inline-flex",
|
|
3083
|
+
...style,
|
|
3084
|
+
}, children: _jsxs("span", { "data-numberflow-input-wrapper": "", style: {
|
|
3085
|
+
display: "inline-flex",
|
|
3086
|
+
overflow: "hidden",
|
|
3087
|
+
}, children: [_jsx("span", { role: "textbox", tabIndex: 0, ref: combineRefs(spanRef, ref), contentEditable: true, inputMode: "decimal", suppressContentEditableWarning: true, onKeyDown: handleKeyDown, onBeforeInput: handleBeforeInput, onInput: handleInput, onCopy: handleCopy, onPaste: handlePaste, onCut: handleCut, onFocus: onFocus, onBlur: onBlur, "data-numberflow-input-contenteditable": "", style: {
|
|
3088
|
+
display: "inline-block",
|
|
3089
|
+
}, "data-placeholder": placeholder }), _jsx("input", { ref: inputRef, ...inputProps, type: "string", inputMode: "decimal", readOnly: true, tabIndex: -1, "data-numberflow-input-real-input": "", value: actualValue?.toString() ?? "", style: {
|
|
3090
|
+
height: "1px",
|
|
3091
|
+
left: "-9999px",
|
|
3092
|
+
opacity: 0,
|
|
3093
|
+
pointerEvents: "none",
|
|
3094
|
+
position: "absolute",
|
|
3095
|
+
width: "1px",
|
|
3096
|
+
} })] }) }) }));
|
|
3097
|
+
});
|
|
3098
|
+
NumberFlowInput.displayName = "NumberFlowInput";
|
|
3099
|
+
//# sourceMappingURL=NumberFlowInput.js.map
|