@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,794 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a character is a separator (not a digit, decimal point, or minus)
|
|
3
|
+
* @param char - The character to check
|
|
4
|
+
* @param localeDecimal - Optional locale-specific decimal separator (e.g., "," for fr-FR)
|
|
5
|
+
*/
|
|
6
|
+
const isSeparator = (char, localeDecimal) => {
|
|
7
|
+
if (!char) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
// Check against standard raw characters
|
|
11
|
+
if (/[\d.\-]/.test(char)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
// Also check against locale decimal separator
|
|
15
|
+
if (localeDecimal && char === localeDecimal) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Count separators in a string, returning a map of char -> count
|
|
22
|
+
* @param str - The string to count separators in
|
|
23
|
+
* @param localeDecimal - Optional locale-specific decimal separator
|
|
24
|
+
*/
|
|
25
|
+
const countSeparators = (str, localeDecimal) => {
|
|
26
|
+
const counts = new Map();
|
|
27
|
+
for (const char of str) {
|
|
28
|
+
if (isSeparator(char, localeDecimal)) {
|
|
29
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return counts;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Maps a raw (unformatted) cursor position to a formatted cursor position.
|
|
36
|
+
* Counts non-separator characters up to the raw position.
|
|
37
|
+
* @param rawPos - Position in raw (unformatted) string
|
|
38
|
+
* @param formattedStr - The formatted string
|
|
39
|
+
* @param localeDecimal - Optional locale-specific decimal separator
|
|
40
|
+
*/
|
|
41
|
+
const mapRawPosToFormattedPos = (rawPos, formattedStr, localeDecimal) => {
|
|
42
|
+
let rawCount = 0;
|
|
43
|
+
for (let i = 0; i < formattedStr.length; i++) {
|
|
44
|
+
if (rawCount === rawPos) {
|
|
45
|
+
return i;
|
|
46
|
+
}
|
|
47
|
+
if (!isSeparator(formattedStr[i], localeDecimal)) {
|
|
48
|
+
rawCount++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return formattedStr.length;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Detects which characters in the formatted string are new vs unchanged.
|
|
55
|
+
* Uses cursor position to correctly identify which specific characters are new,
|
|
56
|
+
* especially important when there are repeated identical characters.
|
|
57
|
+
* Special handling for separators: they only animate if they're truly new,
|
|
58
|
+
* not just shifted in position.
|
|
59
|
+
*
|
|
60
|
+
* @param oldFormatted - The old formatted string
|
|
61
|
+
* @param newFormatted - The new formatted string
|
|
62
|
+
* @param rawCursorPos - Cursor position in raw (unformatted) text after the change
|
|
63
|
+
* @param rawSelectionStart - Selection start in raw text before the change
|
|
64
|
+
* @param rawOldLength - Length of old raw text
|
|
65
|
+
* @param localeDecimal - Optional locale-specific decimal separator (e.g., "," for fr-FR)
|
|
66
|
+
*/
|
|
67
|
+
export const getFormattedChanges = (oldFormatted, newFormatted, rawCursorPos, rawSelectionStart, rawOldLength, localeDecimal) => {
|
|
68
|
+
const addedIndices = new Set();
|
|
69
|
+
const unchangedIndices = new Set();
|
|
70
|
+
if (!oldFormatted) {
|
|
71
|
+
for (let i = 0; i < newFormatted.length; i++) {
|
|
72
|
+
addedIndices.add(i);
|
|
73
|
+
}
|
|
74
|
+
return { addedIndices, unchangedIndices };
|
|
75
|
+
}
|
|
76
|
+
// Count separators to determine which are truly new
|
|
77
|
+
const oldSeparatorCounts = countSeparators(oldFormatted, localeDecimal);
|
|
78
|
+
const newSeparatorCounts = countSeparators(newFormatted, localeDecimal);
|
|
79
|
+
const newSeparatorAmounts = new Map();
|
|
80
|
+
for (const [char, newCount] of newSeparatorCounts) {
|
|
81
|
+
const oldCount = oldSeparatorCounts.get(char) ?? 0;
|
|
82
|
+
const trulyNew = Math.max(0, newCount - oldCount);
|
|
83
|
+
newSeparatorAmounts.set(char, trulyNew);
|
|
84
|
+
}
|
|
85
|
+
// Extract non-separator characters (keeping locale decimal as a raw char)
|
|
86
|
+
const extractNonSep = (str) => {
|
|
87
|
+
let result = "";
|
|
88
|
+
for (const char of str) {
|
|
89
|
+
if (!isSeparator(char, localeDecimal)) {
|
|
90
|
+
result += char;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
};
|
|
95
|
+
const oldNonSep = extractNonSep(oldFormatted);
|
|
96
|
+
const newNonSep = extractNonSep(newFormatted);
|
|
97
|
+
// If cursor info is provided, use position-based diff
|
|
98
|
+
// This correctly handles repeated identical characters
|
|
99
|
+
if (rawCursorPos !== undefined &&
|
|
100
|
+
rawSelectionStart !== undefined &&
|
|
101
|
+
rawOldLength !== undefined) {
|
|
102
|
+
const numInserted = newNonSep.length - oldNonSep.length;
|
|
103
|
+
// Characters inserted at rawSelectionStart, cursor moved to rawCursorPos
|
|
104
|
+
// So inserted characters are from rawSelectionStart to rawCursorPos (exclusive)
|
|
105
|
+
const insertStartRaw = rawSelectionStart;
|
|
106
|
+
const insertEndRaw = rawCursorPos;
|
|
107
|
+
// Map raw positions to formatted positions
|
|
108
|
+
const insertStartFormatted = mapRawPosToFormattedPos(insertStartRaw, newFormatted, localeDecimal);
|
|
109
|
+
const insertEndFormatted = mapRawPosToFormattedPos(insertEndRaw, newFormatted, localeDecimal);
|
|
110
|
+
// Track separators that should animate (truly new ones)
|
|
111
|
+
// We want to animate new separators that appear in the inserted region
|
|
112
|
+
const remainingNewSeparators = new Map(newSeparatorAmounts);
|
|
113
|
+
for (let idx = 0; idx < newFormatted.length; idx++) {
|
|
114
|
+
const char = newFormatted[idx];
|
|
115
|
+
if (isSeparator(char, localeDecimal)) {
|
|
116
|
+
// For separators: animate if truly new AND in/near the insertion region
|
|
117
|
+
const remaining = remainingNewSeparators.get(char ?? "") ?? 0;
|
|
118
|
+
if (remaining > 0 &&
|
|
119
|
+
idx >= insertStartFormatted &&
|
|
120
|
+
idx < insertEndFormatted) {
|
|
121
|
+
addedIndices.add(idx);
|
|
122
|
+
remainingNewSeparators.set(char ?? "", remaining - 1);
|
|
123
|
+
}
|
|
124
|
+
else if (remaining > 0 && numInserted > 0) {
|
|
125
|
+
// New separator but outside insertion region - still mark as new
|
|
126
|
+
// This handles cases where separator appears due to digit insertion
|
|
127
|
+
addedIndices.add(idx);
|
|
128
|
+
remainingNewSeparators.set(char ?? "", remaining - 1);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
unchangedIndices.add(idx);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
// For non-separator characters: use position-based logic
|
|
136
|
+
if (idx >= insertStartFormatted && idx < insertEndFormatted) {
|
|
137
|
+
// This character is in the inserted region
|
|
138
|
+
addedIndices.add(idx);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
unchangedIndices.add(idx);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { addedIndices, unchangedIndices };
|
|
146
|
+
}
|
|
147
|
+
// Fallback: use LCS-based detection when cursor info not available
|
|
148
|
+
// (This is the old behavior, kept for backwards compatibility)
|
|
149
|
+
const m = oldNonSep.length;
|
|
150
|
+
const n = newNonSep.length;
|
|
151
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
152
|
+
for (let i = 1; i <= m; i++) {
|
|
153
|
+
const prevRow = dp[i - 1];
|
|
154
|
+
const currentRow = dp[i];
|
|
155
|
+
if (!prevRow || !currentRow) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (let j = 1; j <= n; j++) {
|
|
159
|
+
if (oldNonSep[i - 1] === newNonSep[j - 1]) {
|
|
160
|
+
currentRow[j] = (prevRow[j - 1] ?? 0) + 1;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
currentRow[j] = Math.max(prevRow[j] ?? 0, currentRow[j - 1] ?? 0);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
let i = m;
|
|
168
|
+
let j = n;
|
|
169
|
+
const lcsNewNonSepIndices = new Set();
|
|
170
|
+
while (i > 0 && j > 0) {
|
|
171
|
+
const prevRow = dp[i - 1];
|
|
172
|
+
const currentRow = dp[i];
|
|
173
|
+
if (!prevRow || !currentRow) {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (oldNonSep[i - 1] === newNonSep[j - 1]) {
|
|
177
|
+
lcsNewNonSepIndices.add(j - 1);
|
|
178
|
+
i--;
|
|
179
|
+
j--;
|
|
180
|
+
}
|
|
181
|
+
else if ((prevRow[j] ?? 0) > (currentRow[j - 1] ?? 0)) {
|
|
182
|
+
i--;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
j--;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const nonSepIndexToFormattedIndex = new Map();
|
|
189
|
+
let nonSepIdx = 0;
|
|
190
|
+
for (let idx = 0; idx < newFormatted.length; idx++) {
|
|
191
|
+
if (!isSeparator(newFormatted[idx], localeDecimal)) {
|
|
192
|
+
nonSepIndexToFormattedIndex.set(nonSepIdx, idx);
|
|
193
|
+
nonSepIdx++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const remainingNewSeparators = new Map(newSeparatorAmounts);
|
|
197
|
+
for (let idx = 0; idx < newFormatted.length; idx++) {
|
|
198
|
+
const char = newFormatted[idx];
|
|
199
|
+
if (isSeparator(char, localeDecimal)) {
|
|
200
|
+
const remaining = remainingNewSeparators.get(char ?? "") ?? 0;
|
|
201
|
+
if (remaining > 0) {
|
|
202
|
+
addedIndices.add(idx);
|
|
203
|
+
remainingNewSeparators.set(char ?? "", remaining - 1);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
unchangedIndices.add(idx);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
let nonSepIdxForThis = -1;
|
|
211
|
+
for (const [nsi, fi] of nonSepIndexToFormattedIndex) {
|
|
212
|
+
if (fi === idx) {
|
|
213
|
+
nonSepIdxForThis = nsi;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (nonSepIdxForThis >= 0 && lcsNewNonSepIndices.has(nonSepIdxForThis)) {
|
|
218
|
+
unchangedIndices.add(idx);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
addedIndices.add(idx);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { addedIndices, unchangedIndices };
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* Build a barrel-wheel sequence + direction for a digit-to-digit transition.
|
|
229
|
+
* @internal
|
|
230
|
+
*/
|
|
231
|
+
const buildBarrelWheel = (oldChar, newChar) => {
|
|
232
|
+
const oldDigit = parseInt(oldChar, 10);
|
|
233
|
+
const newDigit = parseInt(newChar, 10);
|
|
234
|
+
const direction = newDigit > oldDigit ? "up" : "down";
|
|
235
|
+
const sequence = [];
|
|
236
|
+
if (direction === "up") {
|
|
237
|
+
for (let i = oldDigit; i <= newDigit; i++) {
|
|
238
|
+
sequence.push(i.toString());
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
for (let i = newDigit; i <= oldDigit; i++) {
|
|
243
|
+
sequence.push(i.toString());
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { sequence, direction };
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* Split a raw value into [sign?][integer][.decimal?] with their start positions.
|
|
250
|
+
* @internal
|
|
251
|
+
*/
|
|
252
|
+
const splitRawParts = (value) => {
|
|
253
|
+
let start = 0;
|
|
254
|
+
let sign = "";
|
|
255
|
+
if (value[0] === "-") {
|
|
256
|
+
sign = "-";
|
|
257
|
+
start = 1;
|
|
258
|
+
}
|
|
259
|
+
const dotIdx = value.indexOf(".", start);
|
|
260
|
+
const int = dotIdx === -1 ? value.slice(start) : value.slice(start, dotIdx);
|
|
261
|
+
const dec = dotIdx === -1 ? null : value.slice(dotIdx + 1);
|
|
262
|
+
return {
|
|
263
|
+
sign,
|
|
264
|
+
int,
|
|
265
|
+
dec,
|
|
266
|
+
intStart: start,
|
|
267
|
+
dotIdx,
|
|
268
|
+
decStart: dotIdx === -1 ? -1 : dotIdx + 1,
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
/**
|
|
272
|
+
* Diff two values as a wholesale replacement (used when the `value` prop
|
|
273
|
+
* changes externally rather than via the user typing). Aligns integer digits
|
|
274
|
+
* from the right and decimal digits from the left, so digits in the same
|
|
275
|
+
* "column" produce barrel-wheel animations and any extra digits are added/
|
|
276
|
+
* removed at the edges.
|
|
277
|
+
*/
|
|
278
|
+
export const getReplacementChanges = (oldValue, newValue) => {
|
|
279
|
+
const changes = {
|
|
280
|
+
addedIndices: new Set(),
|
|
281
|
+
unchangedIndices: new Set(),
|
|
282
|
+
barrelWheelIndices: new Map(),
|
|
283
|
+
removedDigitWheels: new Map(),
|
|
284
|
+
};
|
|
285
|
+
if (!oldValue) {
|
|
286
|
+
for (let i = 0; i < newValue.length; i++) {
|
|
287
|
+
const ch = newValue[i];
|
|
288
|
+
if (ch && /^\d$/.test(ch)) {
|
|
289
|
+
const wheel = buildBarrelWheel("0", ch);
|
|
290
|
+
wheel.fromZero = true;
|
|
291
|
+
changes.barrelWheelIndices.set(i, wheel);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
changes.addedIndices.add(i);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return changes;
|
|
298
|
+
}
|
|
299
|
+
if (!newValue) {
|
|
300
|
+
for (let i = 0; i < oldValue.length; i++) {
|
|
301
|
+
const ch = oldValue[i];
|
|
302
|
+
if (ch && /^\d$/.test(ch)) {
|
|
303
|
+
changes.removedDigitWheels.set(i, {
|
|
304
|
+
oldChar: ch,
|
|
305
|
+
oldFormattedIndex: -1,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return changes;
|
|
310
|
+
}
|
|
311
|
+
const oldParts = splitRawParts(oldValue);
|
|
312
|
+
const newParts = splitRawParts(newValue);
|
|
313
|
+
// Sign (always at index 0 in raw)
|
|
314
|
+
if (newParts.sign) {
|
|
315
|
+
if (oldParts.sign) {
|
|
316
|
+
changes.unchangedIndices.add(0);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
changes.addedIndices.add(0);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Integer part: right-align
|
|
323
|
+
const oldIntLen = oldParts.int.length;
|
|
324
|
+
const newIntLen = newParts.int.length;
|
|
325
|
+
// Track which old integer positions get consumed by the alignment so we
|
|
326
|
+
// can flag the leftover ones (digits dropped off the left edge) as
|
|
327
|
+
// removed-digit wheels.
|
|
328
|
+
const consumedOldIntPositions = new Set();
|
|
329
|
+
for (let pos = 0; pos < newIntLen; pos++) {
|
|
330
|
+
const newIdx = newParts.intStart + pos;
|
|
331
|
+
const newChar = newParts.int[pos];
|
|
332
|
+
if (!newChar) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const distFromEnd = newIntLen - 1 - pos;
|
|
336
|
+
const oldPos = oldIntLen - 1 - distFromEnd;
|
|
337
|
+
const oldChar = oldPos >= 0 ? oldParts.int[oldPos] : undefined;
|
|
338
|
+
if (oldChar !== undefined) {
|
|
339
|
+
consumedOldIntPositions.add(oldPos);
|
|
340
|
+
}
|
|
341
|
+
if (oldChar === undefined) {
|
|
342
|
+
// No aligned old digit at this slot. If the new char is a digit it
|
|
343
|
+
// should animate in as a wheel from "0"; otherwise (separators)
|
|
344
|
+
// keep the existing flow-animation behavior.
|
|
345
|
+
if (/^\d$/.test(newChar)) {
|
|
346
|
+
const wheel = buildBarrelWheel("0", newChar);
|
|
347
|
+
wheel.fromZero = true;
|
|
348
|
+
changes.barrelWheelIndices.set(newIdx, wheel);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
changes.addedIndices.add(newIdx);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
else if (oldChar === newChar) {
|
|
355
|
+
changes.unchangedIndices.add(newIdx);
|
|
356
|
+
}
|
|
357
|
+
else if (/^\d$/.test(oldChar) && /^\d$/.test(newChar)) {
|
|
358
|
+
changes.barrelWheelIndices.set(newIdx, buildBarrelWheel(oldChar, newChar));
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
changes.addedIndices.add(newIdx);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Old integer positions left over (because newIntLen < oldIntLen) drop
|
|
365
|
+
// off the left edge. Each old digit position becomes a removed-digit
|
|
366
|
+
// wheel; non-digit chars (separators) are not tracked here so they keep
|
|
367
|
+
// their existing "fade out" path.
|
|
368
|
+
for (let oldPos = 0; oldPos < oldIntLen; oldPos++) {
|
|
369
|
+
if (consumedOldIntPositions.has(oldPos)) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const oldChar = oldParts.int[oldPos];
|
|
373
|
+
if (oldChar && /^\d$/.test(oldChar)) {
|
|
374
|
+
const oldRawIdx = oldParts.intStart + oldPos;
|
|
375
|
+
changes.removedDigitWheels.set(oldRawIdx, {
|
|
376
|
+
oldChar,
|
|
377
|
+
oldFormattedIndex: -1,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// Decimal point
|
|
382
|
+
if (newParts.dotIdx !== -1) {
|
|
383
|
+
if (oldParts.dotIdx !== -1) {
|
|
384
|
+
changes.unchangedIndices.add(newParts.dotIdx);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
changes.addedIndices.add(newParts.dotIdx);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Decimal part: left-align
|
|
391
|
+
if (newParts.dec !== null && newParts.decStart !== -1) {
|
|
392
|
+
const oldDec = oldParts.dec ?? "";
|
|
393
|
+
for (let pos = 0; pos < newParts.dec.length; pos++) {
|
|
394
|
+
const newIdx = newParts.decStart + pos;
|
|
395
|
+
const newChar = newParts.dec[pos];
|
|
396
|
+
if (!newChar) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const oldChar = pos < oldDec.length ? oldDec[pos] : undefined;
|
|
400
|
+
if (oldChar === undefined) {
|
|
401
|
+
if (/^\d$/.test(newChar)) {
|
|
402
|
+
const wheel = buildBarrelWheel("0", newChar);
|
|
403
|
+
wheel.fromZero = true;
|
|
404
|
+
changes.barrelWheelIndices.set(newIdx, wheel);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
changes.addedIndices.add(newIdx);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else if (oldChar === newChar) {
|
|
411
|
+
changes.unchangedIndices.add(newIdx);
|
|
412
|
+
}
|
|
413
|
+
else if (/^\d$/.test(oldChar) && /^\d$/.test(newChar)) {
|
|
414
|
+
changes.barrelWheelIndices.set(newIdx, buildBarrelWheel(oldChar, newChar));
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
changes.addedIndices.add(newIdx);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Old decimal positions left over (because newDec.length < oldDec.length)
|
|
421
|
+
// drop off the right edge. Each old digit position becomes a
|
|
422
|
+
// removed-digit wheel.
|
|
423
|
+
if (oldParts.decStart !== -1) {
|
|
424
|
+
for (let pos = newParts.dec.length; pos < oldDec.length; pos++) {
|
|
425
|
+
const oldChar = oldDec[pos];
|
|
426
|
+
if (oldChar && /^\d$/.test(oldChar)) {
|
|
427
|
+
const oldRawIdx = oldParts.decStart + pos;
|
|
428
|
+
changes.removedDigitWheels.set(oldRawIdx, {
|
|
429
|
+
oldChar,
|
|
430
|
+
oldFormattedIndex: -1,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else if (oldParts.dec !== null && oldParts.decStart !== -1) {
|
|
437
|
+
// Old value had a decimal part, new value has none: every old decimal
|
|
438
|
+
// digit is a removed digit.
|
|
439
|
+
for (let pos = 0; pos < oldParts.dec.length; pos++) {
|
|
440
|
+
const oldChar = oldParts.dec[pos];
|
|
441
|
+
if (oldChar && /^\d$/.test(oldChar)) {
|
|
442
|
+
const oldRawIdx = oldParts.decStart + pos;
|
|
443
|
+
changes.removedDigitWheels.set(oldRawIdx, {
|
|
444
|
+
oldChar,
|
|
445
|
+
oldFormattedIndex: -1,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return changes;
|
|
451
|
+
};
|
|
452
|
+
/**
|
|
453
|
+
* Formatted-space variant of {@link getReplacementChanges}. Returns
|
|
454
|
+
* added/unchanged indices in the *formatted* string, aligned the same way
|
|
455
|
+
* (integers right-aligned, decimals left-aligned).
|
|
456
|
+
*/
|
|
457
|
+
export const getReplacementFormattedChanges = (oldFormatted, newFormatted, localeDecimal) => {
|
|
458
|
+
const addedIndices = new Set();
|
|
459
|
+
const unchangedIndices = new Set();
|
|
460
|
+
if (!oldFormatted) {
|
|
461
|
+
for (let i = 0; i < newFormatted.length; i++) {
|
|
462
|
+
addedIndices.add(i);
|
|
463
|
+
}
|
|
464
|
+
return { addedIndices, unchangedIndices };
|
|
465
|
+
}
|
|
466
|
+
if (!newFormatted) {
|
|
467
|
+
return { addedIndices, unchangedIndices };
|
|
468
|
+
}
|
|
469
|
+
const oldDecIdx = oldFormatted.indexOf(localeDecimal);
|
|
470
|
+
const newDecIdx = newFormatted.indexOf(localeDecimal);
|
|
471
|
+
const oldIntStr = oldDecIdx === -1 ? oldFormatted : oldFormatted.slice(0, oldDecIdx);
|
|
472
|
+
const newIntStr = newDecIdx === -1 ? newFormatted : newFormatted.slice(0, newDecIdx);
|
|
473
|
+
const oldIntLen = oldIntStr.length;
|
|
474
|
+
const newIntLen = newIntStr.length;
|
|
475
|
+
for (let pos = 0; pos < newIntLen; pos++) {
|
|
476
|
+
const distFromEnd = newIntLen - 1 - pos;
|
|
477
|
+
const oldPos = oldIntLen - 1 - distFromEnd;
|
|
478
|
+
const newChar = newIntStr[pos];
|
|
479
|
+
const oldChar = oldPos >= 0 ? oldIntStr[oldPos] : undefined;
|
|
480
|
+
if (oldChar !== undefined && oldChar === newChar) {
|
|
481
|
+
unchangedIndices.add(pos);
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
addedIndices.add(pos);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (newDecIdx !== -1) {
|
|
488
|
+
if (oldDecIdx !== -1) {
|
|
489
|
+
unchangedIndices.add(newDecIdx);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
addedIndices.add(newDecIdx);
|
|
493
|
+
}
|
|
494
|
+
const oldDecStr = oldDecIdx === -1 ? "" : oldFormatted.slice(oldDecIdx + 1);
|
|
495
|
+
const newDecStr = newFormatted.slice(newDecIdx + 1);
|
|
496
|
+
for (let pos = 0; pos < newDecStr.length; pos++) {
|
|
497
|
+
const fullIdx = newDecIdx + 1 + pos;
|
|
498
|
+
const newChar = newDecStr[pos];
|
|
499
|
+
const oldChar = pos < oldDecStr.length ? oldDecStr[pos] : undefined;
|
|
500
|
+
if (oldChar !== undefined && oldChar === newChar) {
|
|
501
|
+
unchangedIndices.add(fullIdx);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
addedIndices.add(fullIdx);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { addedIndices, unchangedIndices };
|
|
509
|
+
};
|
|
510
|
+
export const getChanges = (oldValue, newValue, selectionStart, selectionEnd, newCursorPos) => {
|
|
511
|
+
const changes = {
|
|
512
|
+
addedIndices: new Set(),
|
|
513
|
+
unchangedIndices: new Set(),
|
|
514
|
+
barrelWheelIndices: new Map(),
|
|
515
|
+
};
|
|
516
|
+
if (!oldValue) {
|
|
517
|
+
for (let i = 0; i < newValue.length; i++) {
|
|
518
|
+
changes.addedIndices.add(i);
|
|
519
|
+
}
|
|
520
|
+
return changes;
|
|
521
|
+
}
|
|
522
|
+
const hadSelection = selectionStart !== selectionEnd;
|
|
523
|
+
const lengthDiff = newValue.length - oldValue.length;
|
|
524
|
+
if (hadSelection) {
|
|
525
|
+
const numReplaced = selectionEnd - selectionStart;
|
|
526
|
+
const numInserted = newCursorPos - selectionStart;
|
|
527
|
+
const insertStart = selectionStart;
|
|
528
|
+
if (numReplaced === 1 &&
|
|
529
|
+
numInserted === 1 &&
|
|
530
|
+
insertStart < oldValue.length &&
|
|
531
|
+
insertStart < newValue.length) {
|
|
532
|
+
const oldChar = oldValue[insertStart];
|
|
533
|
+
const newChar = newValue[insertStart];
|
|
534
|
+
if (oldChar && newChar && /^\d$/.test(oldChar) && /^\d$/.test(newChar)) {
|
|
535
|
+
if (oldChar !== newChar) {
|
|
536
|
+
changes.barrelWheelIndices.set(insertStart, buildBarrelWheel(oldChar, newChar));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
for (let i = 0; i < insertStart; i++) {
|
|
541
|
+
if (i < oldValue.length &&
|
|
542
|
+
i < newValue.length &&
|
|
543
|
+
oldValue[i] === newValue[i]) {
|
|
544
|
+
changes.unchangedIndices.add(i);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
for (let i = insertStart; i < newCursorPos; i++) {
|
|
548
|
+
if (!changes.barrelWheelIndices.has(i)) {
|
|
549
|
+
changes.addedIndices.add(i);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const oldAfterEnd = selectionEnd;
|
|
553
|
+
const newAfterEnd = newCursorPos;
|
|
554
|
+
const minLength = Math.min(oldValue.length - oldAfterEnd, newValue.length - newAfterEnd);
|
|
555
|
+
for (let i = 0; i < minLength; i++) {
|
|
556
|
+
const oldIdx = oldAfterEnd + i;
|
|
557
|
+
const newIdx = newAfterEnd + i;
|
|
558
|
+
if (oldIdx < oldValue.length &&
|
|
559
|
+
newIdx < newValue.length &&
|
|
560
|
+
oldValue[oldIdx] === newValue[newIdx]) {
|
|
561
|
+
changes.unchangedIndices.add(newIdx);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
else if (lengthDiff > 0) {
|
|
566
|
+
const insertPos = newCursorPos - lengthDiff;
|
|
567
|
+
for (let i = 0; i < insertPos; i++) {
|
|
568
|
+
if (i < oldValue.length &&
|
|
569
|
+
i < newValue.length &&
|
|
570
|
+
oldValue[i] === newValue[i]) {
|
|
571
|
+
changes.unchangedIndices.add(i);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
for (let i = insertPos; i < newCursorPos; i++) {
|
|
575
|
+
changes.addedIndices.add(i);
|
|
576
|
+
}
|
|
577
|
+
for (let i = newCursorPos; i < newValue.length; i++) {
|
|
578
|
+
const oldIdx = i - lengthDiff;
|
|
579
|
+
if (oldIdx >= 0 &&
|
|
580
|
+
oldIdx < oldValue.length &&
|
|
581
|
+
oldValue[oldIdx] === newValue[i]) {
|
|
582
|
+
changes.unchangedIndices.add(i);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
else if (lengthDiff < 0) {
|
|
587
|
+
const deletePos = selectionStart;
|
|
588
|
+
const numDeleted = -lengthDiff;
|
|
589
|
+
for (let i = 0; i < deletePos; i++) {
|
|
590
|
+
if (i < oldValue.length &&
|
|
591
|
+
i < newValue.length &&
|
|
592
|
+
oldValue[i] === newValue[i]) {
|
|
593
|
+
changes.unchangedIndices.add(i);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
for (let i = deletePos; i < newValue.length; i++) {
|
|
597
|
+
const oldIdx = i + numDeleted;
|
|
598
|
+
if (oldIdx < oldValue.length && oldValue[oldIdx] === newValue[i]) {
|
|
599
|
+
changes.unchangedIndices.add(i);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
for (let i = 0; i < newValue.length; i++) {
|
|
605
|
+
if (i < oldValue.length && oldValue[i] === newValue[i]) {
|
|
606
|
+
changes.unchangedIndices.add(i);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return changes;
|
|
611
|
+
};
|
|
612
|
+
/**
|
|
613
|
+
* Get the group number for a character at a given index.
|
|
614
|
+
* Groups are separated by separator characters (commas, spaces, etc.).
|
|
615
|
+
* Group 0 is before the first separator, group 1 is after, etc.
|
|
616
|
+
* @param localeDecimal - Optional locale-specific decimal separator
|
|
617
|
+
*/
|
|
618
|
+
const getGroupNumber = (str, index, localeDecimal) => {
|
|
619
|
+
let group = 0;
|
|
620
|
+
for (let i = 0; i < index && i < str.length; i++) {
|
|
621
|
+
if (isSeparator(str[i], localeDecimal)) {
|
|
622
|
+
group++;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return group;
|
|
626
|
+
};
|
|
627
|
+
/**
|
|
628
|
+
* Detects which characters should animate their x-position when the formatted
|
|
629
|
+
* string changes. This includes:
|
|
630
|
+
* - Separators that moved positions
|
|
631
|
+
* - Digits that crossed group boundaries (moved past a separator)
|
|
632
|
+
*
|
|
633
|
+
* @param localeDecimal - Optional locale-specific decimal separator
|
|
634
|
+
* @returns Array of indices in the new formatted string that should animate
|
|
635
|
+
*/
|
|
636
|
+
export const getPositionChanges = (oldFormatted, newFormatted, localeDecimal) => {
|
|
637
|
+
const changes = [];
|
|
638
|
+
if (!oldFormatted || !newFormatted) {
|
|
639
|
+
return changes;
|
|
640
|
+
}
|
|
641
|
+
// Helper to extract non-separator characters
|
|
642
|
+
const extractNonSep = (str) => {
|
|
643
|
+
let result = "";
|
|
644
|
+
for (const char of str) {
|
|
645
|
+
if (!isSeparator(char, localeDecimal)) {
|
|
646
|
+
result += char;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return result;
|
|
650
|
+
};
|
|
651
|
+
// Extract non-separator characters from both strings
|
|
652
|
+
const oldNonSep = extractNonSep(oldFormatted);
|
|
653
|
+
const newNonSep = extractNonSep(newFormatted);
|
|
654
|
+
// Map each non-separator character in the new string to its position in old string
|
|
655
|
+
// We use a simple matching algorithm: match from left to right for unchanged chars
|
|
656
|
+
// This handles insertions and deletions correctly
|
|
657
|
+
// First, find the LCS (longest common subsequence) to match characters
|
|
658
|
+
const m = oldNonSep.length;
|
|
659
|
+
const n = newNonSep.length;
|
|
660
|
+
if (m === 0 || n === 0) {
|
|
661
|
+
return changes;
|
|
662
|
+
}
|
|
663
|
+
// Build LCS table
|
|
664
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
665
|
+
for (let i = 1; i <= m; i++) {
|
|
666
|
+
const prevRow = dp[i - 1];
|
|
667
|
+
const currentRow = dp[i];
|
|
668
|
+
if (!prevRow || !currentRow) {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
for (let j = 1; j <= n; j++) {
|
|
672
|
+
if (oldNonSep[i - 1] === newNonSep[j - 1]) {
|
|
673
|
+
currentRow[j] = (prevRow[j - 1] ?? 0) + 1;
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
currentRow[j] = Math.max(prevRow[j] ?? 0, currentRow[j - 1] ?? 0);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// Backtrack to find matching pairs
|
|
681
|
+
// Maps new non-sep index to old non-sep index
|
|
682
|
+
const newToOldNonSepIndex = new Map();
|
|
683
|
+
let i = m;
|
|
684
|
+
let j = n;
|
|
685
|
+
while (i > 0 && j > 0) {
|
|
686
|
+
if (oldNonSep[i - 1] === newNonSep[j - 1]) {
|
|
687
|
+
newToOldNonSepIndex.set(j - 1, i - 1);
|
|
688
|
+
i--;
|
|
689
|
+
j--;
|
|
690
|
+
}
|
|
691
|
+
else if ((dp[i - 1]?.[j] ?? 0) > (dp[i]?.[j - 1] ?? 0)) {
|
|
692
|
+
i--;
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
j--;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// Build maps from non-sep index to formatted index
|
|
699
|
+
const oldNonSepToFormatted = new Map();
|
|
700
|
+
let nonSepIdx = 0;
|
|
701
|
+
for (let idx = 0; idx < oldFormatted.length; idx++) {
|
|
702
|
+
if (!isSeparator(oldFormatted[idx], localeDecimal)) {
|
|
703
|
+
oldNonSepToFormatted.set(nonSepIdx, idx);
|
|
704
|
+
nonSepIdx++;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const newNonSepToFormatted = new Map();
|
|
708
|
+
nonSepIdx = 0;
|
|
709
|
+
for (let idx = 0; idx < newFormatted.length; idx++) {
|
|
710
|
+
if (!isSeparator(newFormatted[idx], localeDecimal)) {
|
|
711
|
+
newNonSepToFormatted.set(nonSepIdx, idx);
|
|
712
|
+
nonSepIdx++;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
// Check each character in the new formatted string
|
|
716
|
+
for (let newIdx = 0; newIdx < newFormatted.length; newIdx++) {
|
|
717
|
+
const char = newFormatted[newIdx];
|
|
718
|
+
if (isSeparator(char, localeDecimal)) {
|
|
719
|
+
// For separators, check if there was a separator at a different position
|
|
720
|
+
// We need to find if this separator "moved" from somewhere
|
|
721
|
+
// Count separators of this type before this position in both strings
|
|
722
|
+
let newSepCountBefore = 0;
|
|
723
|
+
for (let k = 0; k < newIdx; k++) {
|
|
724
|
+
if (newFormatted[k] === char) {
|
|
725
|
+
newSepCountBefore++;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
// Find the matching separator in old string (same type, same occurrence number)
|
|
729
|
+
let oldSepCount = 0;
|
|
730
|
+
let oldIdx = -1;
|
|
731
|
+
for (let k = 0; k < oldFormatted.length; k++) {
|
|
732
|
+
if (oldFormatted[k] === char) {
|
|
733
|
+
if (oldSepCount === newSepCountBefore) {
|
|
734
|
+
oldIdx = k;
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
oldSepCount++;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// If found and positions differ, this separator moved
|
|
741
|
+
if (oldIdx >= 0 && oldIdx !== newIdx && char !== undefined) {
|
|
742
|
+
changes.push({
|
|
743
|
+
newIndex: newIdx,
|
|
744
|
+
oldIndex: oldIdx,
|
|
745
|
+
char,
|
|
746
|
+
isSeparator: true,
|
|
747
|
+
crossedGroup: false,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
// For non-separator characters, check if they crossed a group boundary
|
|
753
|
+
// Find the non-sep index for this formatted index
|
|
754
|
+
let newNonSepIdx = -1;
|
|
755
|
+
for (const [nsi, fi] of newNonSepToFormatted) {
|
|
756
|
+
if (fi === newIdx) {
|
|
757
|
+
newNonSepIdx = nsi;
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (newNonSepIdx >= 0) {
|
|
762
|
+
const oldNonSepIdx = newToOldNonSepIndex.get(newNonSepIdx);
|
|
763
|
+
if (oldNonSepIdx !== undefined) {
|
|
764
|
+
const oldFormattedIdx = oldNonSepToFormatted.get(oldNonSepIdx);
|
|
765
|
+
if (oldFormattedIdx !== undefined && char !== undefined) {
|
|
766
|
+
// LCS can be ambiguous when the same digit appears multiple times
|
|
767
|
+
// (e.g. "6,650,431" → "6,850,431": the leading "6" can be matched
|
|
768
|
+
// to either of the two old "6"s of equal LCS length). When the
|
|
769
|
+
// SAME character is at the SAME formatted position in old, the
|
|
770
|
+
// digit obviously didn't move and we must skip the animation to
|
|
771
|
+
// avoid translating it from a phantom location.
|
|
772
|
+
if (oldFormatted[newIdx] === char) {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
// Character existed before - check if it crossed a group boundary
|
|
776
|
+
const oldGroup = getGroupNumber(oldFormatted, oldFormattedIdx, localeDecimal);
|
|
777
|
+
const newGroup = getGroupNumber(newFormatted, newIdx, localeDecimal);
|
|
778
|
+
if (oldGroup !== newGroup) {
|
|
779
|
+
changes.push({
|
|
780
|
+
newIndex: newIdx,
|
|
781
|
+
oldIndex: oldFormattedIdx,
|
|
782
|
+
char,
|
|
783
|
+
isSeparator: false,
|
|
784
|
+
crossedGroup: true,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return changes;
|
|
793
|
+
};
|
|
794
|
+
//# sourceMappingURL=changes.js.map
|