@rowsncolumns/spreadsheet-state 28.0.73 → 28.0.75

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.
Files changed (167) hide show
  1. package/dist/cjs/api/advanced-filter.d.ts +72 -0
  2. package/dist/cjs/api/advanced-filter.d.ts.map +1 -0
  3. package/dist/cjs/api/advanced-filter.js +0 -0
  4. package/dist/cjs/api/datatype.d.ts.map +1 -1
  5. package/dist/cjs/api/datatype.js +8 -1
  6. package/dist/cjs/api/fill-series-spec.d.ts +35 -0
  7. package/dist/cjs/api/fill-series-spec.d.ts.map +1 -0
  8. package/dist/cjs/api/fill-series-spec.js +164 -0
  9. package/dist/cjs/api/fill.d.ts +95 -3
  10. package/dist/cjs/api/fill.d.ts.map +1 -1
  11. package/dist/cjs/api/fill.js +1186 -418
  12. package/dist/cjs/api/goal-seek.d.ts +29 -0
  13. package/dist/cjs/api/goal-seek.d.ts.map +1 -0
  14. package/dist/cjs/api/goal-seek.js +131 -0
  15. package/dist/cjs/api/immer-utils.d.ts.map +1 -1
  16. package/dist/cjs/api/immer-utils.js +21 -15
  17. package/dist/cjs/api/intl.d.ts.map +1 -1
  18. package/dist/cjs/api/paint-format.d.ts +55 -0
  19. package/dist/cjs/api/paint-format.d.ts.map +1 -0
  20. package/dist/cjs/api/paint-format.js +174 -0
  21. package/dist/cjs/api/split-text.d.ts +12 -0
  22. package/dist/cjs/api/split-text.d.ts.map +1 -1
  23. package/dist/cjs/api/split-text.js +41 -0
  24. package/dist/cjs/api/subtotal.d.ts +85 -0
  25. package/dist/cjs/api/subtotal.d.ts.map +1 -0
  26. package/dist/cjs/api/subtotal.js +250 -0
  27. package/dist/cjs/commands.d.ts +19 -1
  28. package/dist/cjs/commands.d.ts.map +1 -1
  29. package/dist/cjs/components/editors/data-validation-rule-editor.d.ts.map +1 -1
  30. package/dist/cjs/components/editors/data-validation-rule-editor.js +83 -16
  31. package/dist/cjs/hooks/use-calculation-worker.d.ts.map +1 -1
  32. package/dist/cjs/hooks/use-calculation-worker.js +1 -0
  33. package/dist/cjs/hooks/use-calculation.d.ts +1 -1
  34. package/dist/cjs/hooks/use-on-advanced-filter.d.ts +21 -0
  35. package/dist/cjs/hooks/use-on-advanced-filter.d.ts.map +1 -0
  36. package/dist/cjs/hooks/use-on-advanced-filter.js +42 -0
  37. package/dist/cjs/hooks/use-on-fill.d.ts +13 -4
  38. package/dist/cjs/hooks/use-on-fill.d.ts.map +1 -1
  39. package/dist/cjs/hooks/use-on-fill.js +28 -7
  40. package/dist/cjs/hooks/use-on-goal-seek.d.ts +30 -0
  41. package/dist/cjs/hooks/use-on-goal-seek.d.ts.map +1 -0
  42. package/dist/cjs/hooks/use-on-goal-seek.js +140 -0
  43. package/dist/cjs/hooks/use-on-import-excel.d.ts +10 -1
  44. package/dist/cjs/hooks/use-on-import-excel.d.ts.map +1 -1
  45. package/dist/cjs/hooks/use-on-import-excel.js +8 -4
  46. package/dist/cjs/hooks/use-on-merge-unmerge-cells.d.ts.map +1 -1
  47. package/dist/cjs/hooks/use-on-merge-unmerge-cells.js +53 -34
  48. package/dist/cjs/hooks/use-on-subtotal.d.ts +26 -0
  49. package/dist/cjs/hooks/use-on-subtotal.d.ts.map +1 -0
  50. package/dist/cjs/hooks/use-on-subtotal.js +53 -0
  51. package/dist/cjs/hooks/use-paint-format.d.ts +19 -3
  52. package/dist/cjs/hooks/use-paint-format.d.ts.map +1 -1
  53. package/dist/cjs/hooks/use-paint-format.js +89 -32
  54. package/dist/cjs/hooks/use-sheet-properties.d.ts +2 -0
  55. package/dist/cjs/hooks/use-sheet-properties.d.ts.map +1 -1
  56. package/dist/cjs/hooks/use-sheet-properties.js +4 -0
  57. package/dist/cjs/hooks/use-split-text-to-columns.d.ts +2 -1
  58. package/dist/cjs/hooks/use-split-text-to-columns.d.ts.map +1 -1
  59. package/dist/cjs/hooks/use-split-text-to-columns.js +3 -2
  60. package/dist/cjs/hooks/use-spreadsheet-state.d.ts +50 -36
  61. package/dist/cjs/hooks/use-spreadsheet-state.d.ts.map +1 -1
  62. package/dist/cjs/hooks/use-spreadsheet-state.js +109 -19
  63. package/dist/cjs/index.d.ts +8 -0
  64. package/dist/cjs/index.d.ts.map +1 -1
  65. package/dist/cjs/index.js +8 -0
  66. package/dist/cjs/interface/apply-fill.d.ts +3 -3
  67. package/dist/cjs/interface/apply-fill.d.ts.map +1 -1
  68. package/dist/cjs/interface/apply-fill.js +100 -53
  69. package/dist/cjs/interface/change-batch-stream.d.ts.map +1 -1
  70. package/dist/cjs/interface/change-batch-stream.js +12 -6
  71. package/dist/cjs/interface/change-batch.d.ts.map +1 -1
  72. package/dist/cjs/interface/change-batch.js +17 -9
  73. package/dist/cjs/interface/change-formatting.d.ts.map +1 -1
  74. package/dist/cjs/interface/change-formatting.js +30 -11
  75. package/dist/cjs/interface/split-text.d.ts +2 -1
  76. package/dist/cjs/interface/split-text.d.ts.map +1 -1
  77. package/dist/cjs/interface/split-text.js +7 -3
  78. package/dist/cjs/interface/spreadsheet-interface.d.ts +1 -1
  79. package/dist/cjs/interface/spreadsheet-interface.d.ts.map +1 -1
  80. package/dist/cjs/interface/spreadsheet-interface.js +2 -2
  81. package/dist/cjs/sheet-cell.d.ts.map +1 -1
  82. package/dist/cjs/sheet-cell.js +37 -6
  83. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  84. package/dist/esm/api/advanced-filter.d.ts +72 -0
  85. package/dist/esm/api/advanced-filter.d.ts.map +1 -0
  86. package/dist/esm/api/advanced-filter.js +0 -0
  87. package/dist/esm/api/datatype.d.ts.map +1 -1
  88. package/dist/esm/api/datatype.js +8 -1
  89. package/dist/esm/api/fill-series-spec.d.ts +35 -0
  90. package/dist/esm/api/fill-series-spec.d.ts.map +1 -0
  91. package/dist/esm/api/fill-series-spec.js +160 -0
  92. package/dist/esm/api/fill.d.ts +95 -3
  93. package/dist/esm/api/fill.d.ts.map +1 -1
  94. package/dist/esm/api/fill.js +1158 -418
  95. package/dist/esm/api/goal-seek.d.ts +29 -0
  96. package/dist/esm/api/goal-seek.d.ts.map +1 -0
  97. package/dist/esm/api/goal-seek.js +108 -0
  98. package/dist/esm/api/immer-utils.d.ts.map +1 -1
  99. package/dist/esm/api/immer-utils.js +21 -15
  100. package/dist/esm/api/intl.d.ts.map +1 -1
  101. package/dist/esm/api/paint-format.d.ts +55 -0
  102. package/dist/esm/api/paint-format.d.ts.map +1 -0
  103. package/dist/esm/api/paint-format.js +122 -0
  104. package/dist/esm/api/split-text.d.ts +12 -0
  105. package/dist/esm/api/split-text.d.ts.map +1 -1
  106. package/dist/esm/api/split-text.js +28 -0
  107. package/dist/esm/api/subtotal.d.ts +85 -0
  108. package/dist/esm/api/subtotal.d.ts.map +1 -0
  109. package/dist/esm/api/subtotal.js +181 -0
  110. package/dist/esm/commands.d.ts +19 -1
  111. package/dist/esm/commands.d.ts.map +1 -1
  112. package/dist/esm/components/editors/data-validation-rule-editor.d.ts.map +1 -1
  113. package/dist/esm/components/editors/data-validation-rule-editor.js +52 -4
  114. package/dist/esm/hooks/use-calculation-worker.d.ts.map +1 -1
  115. package/dist/esm/hooks/use-calculation-worker.js +1 -0
  116. package/dist/esm/hooks/use-calculation.d.ts +1 -1
  117. package/dist/esm/hooks/use-on-advanced-filter.d.ts +21 -0
  118. package/dist/esm/hooks/use-on-advanced-filter.d.ts.map +1 -0
  119. package/dist/esm/hooks/use-on-advanced-filter.js +41 -0
  120. package/dist/esm/hooks/use-on-fill.d.ts +13 -4
  121. package/dist/esm/hooks/use-on-fill.d.ts.map +1 -1
  122. package/dist/esm/hooks/use-on-fill.js +24 -4
  123. package/dist/esm/hooks/use-on-goal-seek.d.ts +30 -0
  124. package/dist/esm/hooks/use-on-goal-seek.d.ts.map +1 -0
  125. package/dist/esm/hooks/use-on-goal-seek.js +82 -0
  126. package/dist/esm/hooks/use-on-import-excel.d.ts +10 -1
  127. package/dist/esm/hooks/use-on-import-excel.d.ts.map +1 -1
  128. package/dist/esm/hooks/use-on-import-excel.js +8 -4
  129. package/dist/esm/hooks/use-on-merge-unmerge-cells.d.ts.map +1 -1
  130. package/dist/esm/hooks/use-on-merge-unmerge-cells.js +13 -13
  131. package/dist/esm/hooks/use-on-subtotal.d.ts +26 -0
  132. package/dist/esm/hooks/use-on-subtotal.d.ts.map +1 -0
  133. package/dist/esm/hooks/use-on-subtotal.js +48 -0
  134. package/dist/esm/hooks/use-paint-format.d.ts +19 -3
  135. package/dist/esm/hooks/use-paint-format.d.ts.map +1 -1
  136. package/dist/esm/hooks/use-paint-format.js +75 -30
  137. package/dist/esm/hooks/use-sheet-properties.d.ts +2 -0
  138. package/dist/esm/hooks/use-sheet-properties.d.ts.map +1 -1
  139. package/dist/esm/hooks/use-sheet-properties.js +4 -0
  140. package/dist/esm/hooks/use-split-text-to-columns.d.ts +2 -1
  141. package/dist/esm/hooks/use-split-text-to-columns.d.ts.map +1 -1
  142. package/dist/esm/hooks/use-split-text-to-columns.js +3 -2
  143. package/dist/esm/hooks/use-spreadsheet-state.d.ts +50 -36
  144. package/dist/esm/hooks/use-spreadsheet-state.d.ts.map +1 -1
  145. package/dist/esm/hooks/use-spreadsheet-state.js +100 -17
  146. package/dist/esm/index.d.ts +8 -0
  147. package/dist/esm/index.d.ts.map +1 -1
  148. package/dist/esm/index.js +8 -0
  149. package/dist/esm/interface/apply-fill.d.ts +3 -3
  150. package/dist/esm/interface/apply-fill.d.ts.map +1 -1
  151. package/dist/esm/interface/apply-fill.js +93 -48
  152. package/dist/esm/interface/change-batch-stream.d.ts.map +1 -1
  153. package/dist/esm/interface/change-batch-stream.js +12 -6
  154. package/dist/esm/interface/change-batch.d.ts.map +1 -1
  155. package/dist/esm/interface/change-batch.js +15 -9
  156. package/dist/esm/interface/change-formatting.d.ts.map +1 -1
  157. package/dist/esm/interface/change-formatting.js +28 -11
  158. package/dist/esm/interface/split-text.d.ts +2 -1
  159. package/dist/esm/interface/split-text.d.ts.map +1 -1
  160. package/dist/esm/interface/split-text.js +6 -3
  161. package/dist/esm/interface/spreadsheet-interface.d.ts +1 -1
  162. package/dist/esm/interface/spreadsheet-interface.d.ts.map +1 -1
  163. package/dist/esm/interface/spreadsheet-interface.js +2 -2
  164. package/dist/esm/sheet-cell.d.ts.map +1 -1
  165. package/dist/esm/sheet-cell.js +37 -6
  166. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  167. package/package.json +20 -20
@@ -1,6 +1,85 @@
1
1
  import { Direction } from "@rowsncolumns/common-types";
2
- import { toDate } from "@rowsncolumns/utils";
2
+ import { toDate, toSerial } from "@rowsncolumns/utils";
3
3
  import { SheetCell } from "../sheet-cell";
4
+ import { detectDecimalPattern } from "./decimals";
5
+ import { generateSeriesSpecValues } from "./fill-series-spec";
6
+ /** Fill types whose auto-detected outcome is a plain copy (the Ctrl toggle flips these to a series). */
7
+ const COPY_CLASS_FILL_TYPES = new Set([
8
+ "FillCopy",
9
+ "FillDefault",
10
+ ]);
11
+ /**
12
+ * Resolve the Ctrl/Cmd fill-handle toggle the way Excel does — ONE fill type
13
+ * for the whole drag, decided from what auto-detection would have done per
14
+ * fill line (each column of a vertical fill / row of a horizontal one).
15
+ * If ANY line would extend a series (2+ numbers, a date, a list name,
16
+ * "Item 1"), the toggle means Copy Cells for every line; only when every
17
+ * line would copy (lone numbers) does it mean Fill Series. Formula lines
18
+ * never vote (formulas always copy with relative-ref adjustment) and empty
19
+ * lines are ignored.
20
+ */
21
+ export const resolveToggledFillType = (detectedPerLine) => {
22
+ const votes = detectedPerLine.filter((t) => t !== undefined);
23
+ return votes.some((t) => !COPY_CLASS_FILL_TYPES.has(t))
24
+ ? "FillCopy"
25
+ : "FillSeries";
26
+ };
27
+ /**
28
+ * Excel's Auto Fill Options payload modifiers: "Fill Formatting Only" (`FillFormats`) and
29
+ * "Fill Without Formatting" (`FillWithoutFormatting`; `FillValues` is the VBA `xlFillValues`
30
+ * spelling the AI fill tool uses). Neither is a series mode — the series is detected exactly as
31
+ * for a plain drag; they only change WHAT lands in each target cell.
32
+ */
33
+ export const isFormatsOnlyFillType = (fillType) => fillType === "FillFormats";
34
+ export const isValuesOnlyFillType = (fillType) => fillType === "FillWithoutFormatting" || fillType === "FillValues";
35
+ /**
36
+ * Thrown when a fill source is touched by merges that don't tile it with identically sized
37
+ * blocks — Excel refuses the fill with this exact message and writes nothing.
38
+ */
39
+ export class MergedFillSizeError extends Error {
40
+ constructor() {
41
+ super("This operation requires the merged cells to be identically sized.");
42
+ this.name = "MergedFillSizeError";
43
+ }
44
+ }
45
+ /**
46
+ * Marks a target cell the fill leaves exactly as it is: a Series-dialog `stop` value was reached
47
+ * before the selection's end, and Excel writes nothing into the remaining selected cells.
48
+ */
49
+ export const FILL_UNTOUCHED = Symbol("fill-untouched");
50
+ /**
51
+ * The merged-block geometry of a fill source: `undefined` when no merge touches it, the block
52
+ * size when identically sized merges tile it exactly (every merge inside the source, on the block
53
+ * lattice, and together covering it), else a {@link MergedFillSizeError}.
54
+ */
55
+ export const resolveMergedFillBlock = (range, merges) => {
56
+ const inside = (merges ?? []).filter((m) => m.startRowIndex <= range.endRowIndex &&
57
+ m.endRowIndex >= range.startRowIndex &&
58
+ m.startColumnIndex <= range.endColumnIndex &&
59
+ m.endColumnIndex >= range.startColumnIndex);
60
+ const first = inside[0];
61
+ if (!first)
62
+ return undefined;
63
+ const rows = first.endRowIndex - first.startRowIndex + 1;
64
+ const cols = first.endColumnIndex - first.startColumnIndex + 1;
65
+ const srcRows = range.endRowIndex - range.startRowIndex + 1;
66
+ const srcCols = range.endColumnIndex - range.startColumnIndex + 1;
67
+ const onLattice = inside.every((m) => m.endRowIndex - m.startRowIndex + 1 === rows &&
68
+ m.endColumnIndex - m.startColumnIndex + 1 === cols &&
69
+ m.startRowIndex >= range.startRowIndex &&
70
+ m.endRowIndex <= range.endRowIndex &&
71
+ m.startColumnIndex >= range.startColumnIndex &&
72
+ m.endColumnIndex <= range.endColumnIndex &&
73
+ (m.startRowIndex - range.startRowIndex) % rows === 0 &&
74
+ (m.startColumnIndex - range.startColumnIndex) % cols === 0);
75
+ const tiled = onLattice &&
76
+ srcRows % rows === 0 &&
77
+ srcCols % cols === 0 &&
78
+ inside.length === (srcRows / rows) * (srcCols / cols);
79
+ if (!tiled)
80
+ throw new MergedFillSizeError();
81
+ return { rows, cols };
82
+ };
4
83
  // Helper functions for pattern detection and fill operations
5
84
  /**
6
85
  * Extract numeric value from a string that may contain text
@@ -9,27 +88,263 @@ const extractNumber = (value) => {
9
88
  const match = value.match(/-?\d+\.?\d*/);
10
89
  return match ? parseFloat(match[0]) : null;
11
90
  };
12
- const TEXT_NUMBER_PATTERN_REGEX = /^(.*?)(-?\d+(?:\.\d+)?)(.*)$/;
91
+ /**
92
+ * Excel's text+number rule steps the LAST digit run of the string
93
+ * ("Text1Text2" → "Text1Text3", "Q1 2024" → "Q1 2025"). The lazy prefix plus
94
+ * the `\D*$` tail pins the captured run to the trailing one.
95
+ */
96
+ const TEXT_NUMBER_PATTERN_REGEX = /^(.*?)(\d+)(\D*)$/;
13
97
  const MONTH_NAME_REGEX = /\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b/i;
14
98
  const WEEKDAY_NAME_REGEX = /\b(?:mon(?:day)?|tue(?:s(?:day)?)?|wed(?:nesday)?|thu(?:rs(?:day)?)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?)\b/i;
15
99
  const ISO_DATE_TIME_REGEX = /^\d{4}-\d{2}-\d{2}(?:[tT ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
16
100
  const NUMERIC_DATE_REGEX = /^\d{1,4}[\/.-]\d{1,2}(?:[\/.-]\d{1,4})?(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?$/;
101
+ /** English ordinal suffix glued to a digit run ("1st Period", "2nd Quarter"). */
102
+ const ORDINAL_SUFFIX_REGEX = /^(st|nd|rd|th)(?![a-z])/i;
103
+ const BARE_DIGITS_REGEX = /^\d+$/;
104
+ /** Excel's built-in quarter tokens: Q1, Qtr 2, Quarter3 — whole-cell only. */
105
+ const QUARTER_TOKEN_REGEX = /^(q|qtr|quarter)(\s?)([1-4])$/i;
106
+ const QUARTER_ORDINAL_REGEX = /^([1-4])(st|nd|rd|th)(\s)(quarter|qtr)$/i;
107
+ const QUARTERS_IN_YEAR = 4;
17
108
  const extractTextNumberPattern = (value) => {
18
109
  const match = value.match(TEXT_NUMBER_PATTERN_REGEX);
19
110
  if (!match)
20
111
  return null;
21
- const [, prefix, numericPortion, suffix] = match;
112
+ const [, prefix, run, suffix] = match;
22
113
  if (!prefix && !suffix)
23
114
  return null;
24
- const parsed = parseFloat(numericPortion);
25
- if (Number.isNaN(parsed))
115
+ const parsed = Number(run);
116
+ if (!Number.isFinite(parsed))
26
117
  return null;
27
118
  return {
28
119
  prefix,
29
120
  suffix,
30
121
  number: parsed,
122
+ width: run.length,
123
+ };
124
+ };
125
+ const mod = (value, len) => ((value % len) + len) % len;
126
+ /**
127
+ * Re-apply the seed's letter case to a canonical list entry: an all-caps seed
128
+ * continues in caps ("MONDAY" → "TUESDAY"), an all-lowercase one in lowercase
129
+ * ("jan" → "feb"); anything else keeps the list's own casing.
130
+ */
131
+ const applySeedCase = (seed, canonical) => {
132
+ // A seed without cased letters (`upper === lower`) keeps the list's casing.
133
+ const upper = seed.toUpperCase();
134
+ const lower = seed.toLowerCase();
135
+ if (seed === upper && upper !== lower)
136
+ return canonical.toUpperCase();
137
+ if (seed === lower && upper !== lower)
138
+ return canonical.toLowerCase();
139
+ return canonical;
140
+ };
141
+ const ordinalSuffix = (n) => {
142
+ const abs = Math.abs(Math.trunc(n));
143
+ const mod100 = abs % 100;
144
+ if (mod100 >= 11 && mod100 <= 13)
145
+ return "th";
146
+ switch (abs % 10) {
147
+ case 1:
148
+ return "st";
149
+ case 2:
150
+ return "nd";
151
+ case 3:
152
+ return "rd";
153
+ default:
154
+ return "th";
155
+ }
156
+ };
157
+ /** Render an integer digit run zero-padded to the seed's width (sign outside the padding). */
158
+ const formatDigitRun = (n, width) => {
159
+ const rounded = Math.round(n);
160
+ const digits = String(Math.abs(rounded)).padStart(width, "0");
161
+ return rounded < 0 ? `-${digits}` : digits;
162
+ };
163
+ const tokenizeDigitRuns = (value) => {
164
+ const parts = [];
165
+ const runs = [];
166
+ const re = /\d+/g;
167
+ let last = 0;
168
+ let match;
169
+ while ((match = re.exec(value)) !== null) {
170
+ parts.push(value.slice(last, match.index));
171
+ runs.push(match[0]);
172
+ last = match.index + match[0].length;
173
+ }
174
+ parts.push(value.slice(last));
175
+ return { parts, runs };
176
+ };
177
+ /**
178
+ * Align text seeds on their digit runs the way Excel does: every seed shares
179
+ * the same non-digit skeleton, exactly one run varies between seeds (the LAST
180
+ * run when nothing varies — the single-seed case) and it steps linearly.
181
+ * "Q1 2024","Q2 2024" → step the quarter; "Q1 2024" alone → step the year.
182
+ */
183
+ const matchTextNumberSeries = (values) => {
184
+ if (values.length === 0)
185
+ return null;
186
+ const tokens = values.map(tokenizeDigitRuns);
187
+ const first = tokens[0];
188
+ if (first.runs.length === 0)
189
+ return null;
190
+ const sameSkeleton = tokens.every((t) => t.runs.length === first.runs.length &&
191
+ t.parts.every((part, i) => part === first.parts[i]));
192
+ if (!sameSkeleton)
193
+ return null;
194
+ const varying = [];
195
+ for (let r = 0; r < first.runs.length; r++) {
196
+ if (tokens.some((t) => Number(t.runs[r]) !== Number(first.runs[r]))) {
197
+ varying.push(r);
198
+ }
199
+ }
200
+ if (varying.length > 1)
201
+ return null;
202
+ const runIndex = varying.length === 1 ? varying[0] : first.runs.length - 1;
203
+ const numbers = tokens.map((t) => Number(t.runs[runIndex]));
204
+ if (numbers.some((n) => !Number.isFinite(n)))
205
+ return null;
206
+ const step = numbers.length >= 2 ? numbers[1] - numbers[0] : 1;
207
+ const linear = numbers.every((n, i) => i === 0 || n - numbers[i - 1] === step);
208
+ if (!linear)
209
+ return null;
210
+ const lastTokens = tokens[tokens.length - 1];
211
+ return {
212
+ parts: first.parts,
213
+ runs: lastTokens.runs,
214
+ runIndex,
215
+ step,
216
+ last: numbers[numbers.length - 1],
217
+ width: lastTokens.runs[runIndex].length,
31
218
  };
32
219
  };
220
+ const renderTextNumber = (series, n) => {
221
+ const { parts, runs, runIndex, width } = series;
222
+ let out = parts[0];
223
+ for (let r = 0; r < runs.length; r++) {
224
+ out += r === runIndex ? formatDigitRun(n, width) : runs[r];
225
+ const after = parts[r + 1];
226
+ const ordinal = r === runIndex ? after.match(ORDINAL_SUFFIX_REGEX) : null;
227
+ out += ordinal
228
+ ? `${applySeedCase(ordinal[1], ordinalSuffix(n))}${after.slice(2)}`
229
+ : after;
230
+ }
231
+ return out;
232
+ };
233
+ /**
234
+ * Continue a text+number series ("Item 1" → "Item 2"; "001" → "002" keeps the
235
+ * zero padding; "1st Period" → "2nd Period" re-derives the ordinal). One seed
236
+ * steps by ±1 (by fill direction); two or more take the seeds' difference.
237
+ */
238
+ export const generateTextNumberSeries = (values, count, descending = false) => {
239
+ const series = matchTextNumberSeries(values);
240
+ if (!series)
241
+ return null;
242
+ const step = values.length >= 2 ? series.step : descending ? -1 : 1;
243
+ return Array.from({ length: count }, (_, i) => renderTextNumber(series, series.last + step * (i + 1)));
244
+ };
245
+ /**
246
+ * Excel's built-in quarter list: `Q1`/`Qtr 1`/`Quarter 1` and the ordinal form
247
+ * `1st Quarter`, spelling and casing preserved, cycling mod 4. Whole-cell
248
+ * tokens only — with a year attached ("Q1 2024") the text+number rule applies.
249
+ */
250
+ const parseQuarterToken = (value) => {
251
+ const token = value.trim();
252
+ const plain = token.match(QUARTER_TOKEN_REGEX);
253
+ if (plain) {
254
+ const [, word, space, digit] = plain;
255
+ return {
256
+ index: Number(digit) - 1,
257
+ render: (quarter) => `${word}${space}${quarter}`,
258
+ };
259
+ }
260
+ const ordinal = token.match(QUARTER_ORDINAL_REGEX);
261
+ if (ordinal) {
262
+ const [, digit, suffix, space, word] = ordinal;
263
+ return {
264
+ index: Number(digit) - 1,
265
+ render: (quarter) => `${quarter}${applySeedCase(suffix, ordinalSuffix(quarter))}${space}${word}`,
266
+ };
267
+ }
268
+ return null;
269
+ };
270
+ const isQuarterToken = (value) => typeof value === "string" && parseQuarterToken(value) !== null;
271
+ export const generateQuarterSeries = (values, count, descending = false) => {
272
+ const tokens = values.map(parseQuarterToken);
273
+ if (tokens.length === 0 || tokens.some((t) => t === null))
274
+ return null;
275
+ const quarters = tokens;
276
+ const indices = quarters.map((t) => t.index);
277
+ let step;
278
+ if (indices.length >= 2) {
279
+ step = mod(indices[1] - indices[0], QUARTERS_IN_YEAR);
280
+ const consistent = indices.every((idx, i) => i === 0 || mod(idx - indices[i - 1], QUARTERS_IN_YEAR) === step);
281
+ if (!consistent)
282
+ return null;
283
+ }
284
+ else {
285
+ step = descending ? -1 : 1;
286
+ }
287
+ const last = quarters[quarters.length - 1];
288
+ return Array.from({ length: count }, (_, i) => last.render(mod(last.index + step * (i + 1), QUARTERS_IN_YEAR) + 1));
289
+ };
290
+ /**
291
+ * Locate a weekday/month seed in its locale list, case-insensitively: exact
292
+ * full name, exact 3-letter abbreviation, then the loose prefix match the
293
+ * detector accepts.
294
+ */
295
+ const resolveNameListPosition = (value, names) => {
296
+ const lower = value.trim().toLowerCase();
297
+ if (!lower)
298
+ return null;
299
+ const fullIndex = names.findIndex((n) => n.toLowerCase() === lower);
300
+ if (fullIndex !== -1)
301
+ return { index: fullIndex, abbreviated: false };
302
+ const abbrIndex = names.findIndex((n) => n.toLowerCase().substring(0, 3) === lower);
303
+ if (abbrIndex !== -1)
304
+ return { index: abbrIndex, abbreviated: true };
305
+ if (lower.length < 3)
306
+ return null;
307
+ const looseIndex = names.findIndex((n) => n.toLowerCase().startsWith(lower) ||
308
+ lower.startsWith(n.toLowerCase().substring(0, 3)));
309
+ if (looseIndex !== -1) {
310
+ return { index: looseIndex, abbreviated: value.trim().length <= 3 };
311
+ }
312
+ return null;
313
+ };
314
+ /**
315
+ * Continue a weekday/month NAME series (cyclic). Two or more seeds fix the
316
+ * step from their list-index gap (Mon, Wed → Fri, Sun, Tue); a single seed
317
+ * steps ±1 by fill direction. Output keeps the seed's abbreviation and casing.
318
+ * Returns `[]` when the last seed is not a list member.
319
+ */
320
+ const generateNameListSeries = (sourceValues, names, count, descending = false) => {
321
+ const seed = sourceValues[sourceValues.length - 1];
322
+ if (typeof seed !== "string")
323
+ return [];
324
+ const positions = sourceValues.map((v) => typeof v === "string" ? resolveNameListPosition(v, names) : null);
325
+ const last = positions[positions.length - 1];
326
+ if (!last)
327
+ return [];
328
+ const len = names.length;
329
+ let step;
330
+ if (positions.length >= 2 && positions.every((p) => p !== null)) {
331
+ const indices = positions.map((p) => p.index);
332
+ step = mod(indices[1] - indices[0], len);
333
+ const consistent = indices.every((idx, i) => i === 0 || mod(idx - indices[i - 1], len) === step);
334
+ if (!consistent)
335
+ return [];
336
+ }
337
+ else {
338
+ step = descending ? -1 : 1;
339
+ }
340
+ const result = [];
341
+ for (let i = 0; i < count; i++) {
342
+ const canonical = names[mod(last.index + step * (i + 1), len)];
343
+ const form = last.abbreviated ? canonical.substring(0, 3) : canonical;
344
+ result.push(applySeedCase(seed, form));
345
+ }
346
+ return result;
347
+ };
33
348
  const isLikelyDateString = (value) => {
34
349
  const trimmed = value.trim();
35
350
  if (!trimmed || /^\d+$/.test(trimmed)) {
@@ -66,6 +381,62 @@ const parseDate = (value) => {
66
381
  const parsed = Date.parse(value);
67
382
  return isNaN(parsed) ? null : new Date(parsed);
68
383
  };
384
+ /**
385
+ * Calendar arithmetic for date fills runs in Excel-serial space (days since
386
+ * the 1900 epoch, wall-clock — no DST). `toDate`/`toSerial` round-trip on the
387
+ * 1900 epoch regardless of the workbook's date system, so the epoch offset
388
+ * cancels and only the caller that writes the cell needs `use1904`.
389
+ */
390
+ const dateToSerial = (date) => toSerial(date, false);
391
+ const serialToDate = (serial) => toDate(serial, false);
392
+ /**
393
+ * A time-of-day value (Excel serial in [0, 1) — `9:00` = 0.375) travels through
394
+ * the fill as a Date on Excel's day zero, 1899-12-31. No real date lives there
395
+ * (Excel starts at 1900-01-01), so "before 1900" ⇔ "time-only".
396
+ */
397
+ const isTimeOnlyDate = (date) => date.getFullYear() < 1900;
398
+ const isDateLikeFormatType = (type) => type === "DATE" || type === "DATE_TIME" || type === "TIME";
399
+ const HOURS_PER_DAY = 24;
400
+ const SERIAL_EPSILON = 1e-9;
401
+ const isWholeDayStep = (serialStep) => Math.abs(serialStep - Math.round(serialStep)) < SERIAL_EPSILON;
402
+ const daysInMonth = (year, monthIndex) => new Date(year, monthIndex + 1, 0).getDate();
403
+ const isLastDayOfMonth = (date) => date.getDate() === daysInMonth(date.getFullYear(), date.getMonth());
404
+ /**
405
+ * Whole months between two dates (ignores day-of-month).
406
+ */
407
+ const monthsBetween = (from, to) => (to.getFullYear() - from.getFullYear()) * 12 +
408
+ (to.getMonth() - from.getMonth());
409
+ /**
410
+ * `anchor` + `months`, Excel style: the anchor's day-of-month is kept and
411
+ * clamped to the target month's length (Jan 31 + 1 → Feb 29 in 2024, + 2 →
412
+ * Mar 31 — never JS `setMonth`'s overflow into the next month). `endOfMonth`
413
+ * forces the target month's last day (a month-end series: Jan 31, Feb 29 →
414
+ * Mar 31, Apr 30). Time-of-day rides along unchanged.
415
+ */
416
+ const addMonthsClamped = (anchor, months, endOfMonth = false) => {
417
+ const total = anchor.getFullYear() * 12 + anchor.getMonth() + months;
418
+ const year = Math.floor(total / 12);
419
+ const monthIndex = total - year * 12;
420
+ const lastDay = daysInMonth(year, monthIndex);
421
+ const day = endOfMonth ? lastDay : Math.min(anchor.getDate(), lastDay);
422
+ return new Date(year, monthIndex, day, anchor.getHours(), anchor.getMinutes(), anchor.getSeconds(), anchor.getMilliseconds());
423
+ };
424
+ const isWeekend = (date) => date.getDay() === 0 || date.getDay() === 6;
425
+ /**
426
+ * Signed count of weekdays (Mon–Fri) stepped from `from` to reach `to`.
427
+ * Weekend days are not counted, so Fri → next Fri is 5.
428
+ */
429
+ const weekdaysBetween = (from, to) => {
430
+ const dir = to.getTime() >= from.getTime() ? 1 : -1;
431
+ const cursor = new Date(from);
432
+ let steps = 0;
433
+ while (dir > 0 ? cursor.getTime() < to.getTime() : cursor.getTime() > to.getTime()) {
434
+ cursor.setDate(cursor.getDate() + dir);
435
+ if (!isWeekend(cursor))
436
+ steps += dir;
437
+ }
438
+ return steps;
439
+ };
69
440
  /**
70
441
  * Get day names array
71
442
  */
@@ -179,8 +550,58 @@ const isSimpleDecimalSeriesNumberPattern = (pattern) => {
179
550
  const normalizedPattern = pattern?.trim() ?? "General";
180
551
  return SIMPLE_DECIMAL_SERIES_NUMBER_PATTERN_REGEX.test(normalizedPattern);
181
552
  };
182
- const shouldNormalizeSeriesNumberFormat = (fillType, numberFormat, value) => {
183
- if (fillType !== "FillSeries") {
553
+ const TREND_FILL_TYPES = new Set([
554
+ "LinearTrend",
555
+ "GrowthTrend",
556
+ ]);
557
+ const SIMPLE_SERIES_NUMBER_PATTERN_SHAPE_REGEX = /^(#,##0|#|0)(?:\.(0+))?$/i;
558
+ /**
559
+ * The shape of a simple NUMBER pattern — how many forced decimals it has
560
+ * and whether it groups thousands — ignoring the "#" vs "0" integer
561
+ * placeholder (the auto-stamp picks "0" only for |value| < 1).
562
+ */
563
+ const getSimpleNumberPatternShape = (pattern) => {
564
+ const match = SIMPLE_SERIES_NUMBER_PATTERN_SHAPE_REGEX.exec(pattern?.trim() ?? "");
565
+ if (!match) {
566
+ return null;
567
+ }
568
+ return { decimals: match[2]?.length ?? 0, grouped: match[1].includes(",") };
569
+ };
570
+ /**
571
+ * Whether a seed's NUMBER pattern is the one the editor stamps itself when
572
+ * the value is typed (`SheetCell.setNumberValue` → `detectDecimalPattern`:
573
+ * typing 1.1 stamps one decimal, typing 1 stamps none). A pattern whose
574
+ * shape differs from what the seed's own value produces ("0.00" on the value
575
+ * 1) can only have been applied by the user or imported, and Excel carries a
576
+ * user-applied format onto the filled cells untouched.
577
+ *
578
+ * Type inference stamps a pattern only for TEXT entry — a JS number written
579
+ * by a fill (or a program) stays General (`detectValueTypeAndPattern`), so a
580
+ * generated value never picks up a decimal pattern of its own. This shape
581
+ * check is how fill tells the seed's auto-stamped pattern from an explicit
582
+ * one when deciding whether to carry it onto the target.
583
+ */
584
+ const isAutoDetectedNumberPattern = (pattern, seedValue, locale) => {
585
+ if (typeof seedValue !== "number" || !Number.isFinite(seedValue)) {
586
+ return false;
587
+ }
588
+ const shape = getSimpleNumberPatternShape(pattern);
589
+ const autoShape = getSimpleNumberPatternShape(detectDecimalPattern(seedValue, undefined, locale));
590
+ return (shape !== null &&
591
+ autoShape !== null &&
592
+ shape.decimals === autoShape.decimals &&
593
+ shape.grouped === autoShape.grouped);
594
+ };
595
+ /**
596
+ * Decide whether a generated series value should drop the pattern cloned
597
+ * from its seed so it renders under its own inferred pattern instead:
598
+ * an integer pattern on a fractional result (1, 2, 4 → 5.333… would show
599
+ * "5") or a decimal pattern on an integer result (1.1, 1.2 → 2 would show
600
+ * "2.0"). Only auto-stamped patterns are dropped — an explicit "0.00" stays
601
+ * "0.00" (3.00, 4.00), as in Excel.
602
+ */
603
+ const shouldNormalizeSeriesNumberFormat = (fillType, numberFormat, value, seedValue, locale) => {
604
+ if (fillType !== "FillSeries" && !TREND_FILL_TYPES.has(fillType)) {
184
605
  return false;
185
606
  }
186
607
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -190,13 +611,12 @@ const shouldNormalizeSeriesNumberFormat = (fillType, numberFormat, value) => {
190
611
  return false;
191
612
  }
192
613
  const pattern = numberFormat.pattern;
193
- if (isSimpleIntegerSeriesNumberPattern(pattern) && !isNearlyInteger(value)) {
194
- return true;
195
- }
196
- if (isSimpleDecimalSeriesNumberPattern(pattern) && isNearlyInteger(value)) {
197
- return true;
614
+ const integerPatternOnFraction = isSimpleIntegerSeriesNumberPattern(pattern) && !isNearlyInteger(value);
615
+ const decimalPatternOnInteger = isSimpleDecimalSeriesNumberPattern(pattern) && isNearlyInteger(value);
616
+ if (!integerPatternOnFraction && !decimalPatternOnInteger) {
617
+ return false;
198
618
  }
199
- return false;
619
+ return isAutoDetectedNumberPattern(pattern, seedValue, locale);
200
620
  };
201
621
  const getCellNumberFormat = (sheetCell) => {
202
622
  const effectiveNumberFormat = sheetCell.getNumberFormat();
@@ -211,9 +631,11 @@ const getCellNumberFormat = (sheetCell) => {
211
631
  }
212
632
  return undefined;
213
633
  };
214
- const shouldNormalizeSeriesNumberFormatForCell = (sheetCell, fillType, value) => {
634
+ const shouldNormalizeSeriesNumberFormatForCell = (sheetCell, fillType, value, locale) => {
215
635
  const numberFormat = getCellNumberFormat(sheetCell);
216
- return shouldNormalizeSeriesNumberFormat(fillType, numberFormat, value);
636
+ // `sheetCell` still holds the (wrapped) seed the target is cloned from.
637
+ const seedValue = sheetCell.getEffectiveValue();
638
+ return shouldNormalizeSeriesNumberFormat(fillType, numberFormat, value, seedValue, locale);
217
639
  };
218
640
  const clearCellNumberFormatForSeries = (sheetCell) => {
219
641
  if (sheetCell.userEnteredFormat) {
@@ -240,13 +662,19 @@ export const detectFillType = (selectionData, locale = "en", customAutofillLists
240
662
  if (matchCustomList(values, customAutofillLists)) {
241
663
  return "FillCustomList";
242
664
  }
665
+ // Excel's built-in quarter list (Q1 / Qtr 1 / Quarter 1 / 1st Quarter):
666
+ // a cyclic series of four, never "Q5".
667
+ if (values.every(isQuarterToken)) {
668
+ return "FillSeries";
669
+ }
243
670
  // Helper to check for day/month names
244
671
  const dayNames = getDayNames(locale);
245
672
  const monthNames = getMonthNames(locale);
246
673
  if (values.length === 1) {
247
674
  const singleValue = values[0];
248
675
  if (singleValue instanceof Date) {
249
- return "FillDays";
676
+ // A lone time (9:00) steps by the hour; a lone date by the day (Excel).
677
+ return isTimeOnlyDate(singleValue) ? "FillHours" : "FillDays";
250
678
  }
251
679
  // Single numbers → FillCopy (Excel standard: need 2+ cells for series)
252
680
  if (typeof singleValue === "number") {
@@ -292,22 +720,29 @@ export const detectFillType = (selectionData, locale = "en", customAutofillLists
292
720
  if (dates.length >= 2) {
293
721
  const firstDate = dates[0];
294
722
  const secondDate = dates[1];
295
- const timeDiff = secondDate.getTime() - firstDate.getTime();
296
- const dayDiff = timeDiff / (1000 * 60 * 60 * 24);
297
- if (Math.abs(dayDiff - 1) < 0.1)
723
+ // Time-of-day seeds (9:00, 9:30) continue at their own step (30 min).
724
+ if (dates.every(isTimeOnlyDate))
725
+ return "FillHours";
726
+ const serialStep = dateToSerial(secondDate) - dateToSerial(firstDate);
727
+ // A sub-day step (09:00, 15:00 → +6h) is linear on the serial — Excel only
728
+ // infers a calendar unit from whole-day differences.
729
+ if (!isWholeDayStep(serialStep))
298
730
  return "FillDays";
299
- if (Math.abs(dayDiff - 7) < 0.1)
300
- return "FillWeekdays";
301
731
  const sameDayOfMonth = firstDate.getDate() === secondDate.getDate();
302
- const sameMonth = firstDate.getMonth() === secondDate.getMonth();
303
- const monthDiff = (secondDate.getFullYear() - firstDate.getFullYear()) * 12 +
304
- (secondDate.getMonth() - firstDate.getMonth());
305
- if (sameDayOfMonth && monthDiff === 1)
732
+ const monthDiff = monthsBetween(firstDate, secondDate);
733
+ // Same day-of-month N months apart → a month series with step N (15-Jan,
734
+ // 15-Apr → 15-Jul, 15-Oct); whole years → a year series. Two month-ends
735
+ // (Jan 31, Feb 29) are a month series too, staying on the month-end.
736
+ // NOTE: Excel never infers "weekdays" — a 7-day gap is a 7-day step; Fill
737
+ // Weekdays is only ever an explicit choice.
738
+ if (sameDayOfMonth && monthDiff !== 0) {
739
+ return monthDiff % 12 === 0 ? "FillYears" : "FillMonths";
740
+ }
741
+ if (monthDiff !== 0 &&
742
+ isLastDayOfMonth(firstDate) &&
743
+ isLastDayOfMonth(secondDate)) {
306
744
  return "FillMonths";
307
- if (sameDayOfMonth &&
308
- sameMonth &&
309
- secondDate.getFullYear() - firstDate.getFullYear() === 1)
310
- return "FillYears";
745
+ }
311
746
  return "FillDays";
312
747
  }
313
748
  // Check for numeric series (after dates to avoid Date objects being treated as timestamps)
@@ -324,6 +759,26 @@ export const detectFillType = (selectionData, locale = "en", customAutofillLists
324
759
  if (isLinearSeries && diff !== 0) {
325
760
  return "FillSeries";
326
761
  }
762
+ // Excel fits a least-squares line through 3+ bare numbers whose step is
763
+ // not constant (1, 2, 4 → 5.333…, 6.833…) instead of repeating them.
764
+ // Only bare numbers qualify: "Item 1", "Item 2", "Item 4" also extract
765
+ // numbers but stay a text pattern.
766
+ if (!isLinearSeries &&
767
+ numbers.length >= 3 &&
768
+ nonDateValues.every((v) => typeof v === "number")) {
769
+ return "LinearTrend";
770
+ }
771
+ }
772
+ // Text+number seeds aligned on one varying digit run ("Text1Text2",
773
+ // "Text1Text3" → the trailing run steps) are a series even when the
774
+ // first number in each string does not move.
775
+ if (values.every((v) => typeof v === "string") &&
776
+ !values.every((v) => BARE_DIGITS_REGEX.test(v)) &&
777
+ !values.some((v) => isLikelyDateString(v))) {
778
+ const textSeries = matchTextNumberSeries(values);
779
+ if (textSeries && textSeries.step !== 0) {
780
+ return "FillSeries";
781
+ }
327
782
  }
328
783
  // Check for text patterns (days/months)
329
784
  const textValues = values.map((v) => String(v).toLowerCase());
@@ -341,8 +796,15 @@ export const detectFillType = (selectionData, locale = "en", customAutofillLists
341
796
  /**
342
797
  * Generate fill values based on type and pattern
343
798
  */
344
- export const generateFillValues = (sourceValues, fillType, count, locale, customAutofillLists) => {
799
+ export const generateFillValues = (sourceValues, fillType, count, locale, customAutofillLists,
800
+ /**
801
+ * The fill walks away from the selection's start (fill up / fill left), so a
802
+ * single seed continues backwards: "Item 5" → "Item 4", Wednesday → Tuesday.
803
+ * Multi-seed lines already encode the direction in their reversed order.
804
+ */
805
+ descending = false) => {
345
806
  const result = [];
807
+ const unitStep = descending ? -1 : 1;
346
808
  switch (fillType) {
347
809
  case "FillCustomList": {
348
810
  const match = matchCustomList(sourceValues, customAutofillLists);
@@ -354,16 +816,20 @@ export const generateFillValues = (sourceValues, fillType, count, locale, custom
354
816
  }
355
817
  break;
356
818
  }
357
- const { list, startIndex, ascending } = match;
819
+ const { list, startIndex } = match;
820
+ // A single seed has no order of its own — the fill direction decides.
821
+ const ascending = sourceValues.length >= 2 ? match.ascending : !descending;
358
822
  const len = list.length;
359
- // Continue from where the source slice ended.
823
+ // Continue from where the source slice ended; the output follows the
824
+ // seed's casing pattern like the built-in lists ("low" → "medium").
825
+ const seed = String(sourceValues[sourceValues.length - 1]);
360
826
  const nextOffset = ascending
361
827
  ? startIndex + sourceValues.length
362
828
  : startIndex - sourceValues.length;
363
829
  for (let i = 0; i < count; i++) {
364
830
  const step = ascending ? i : -i;
365
831
  const idx = (((nextOffset + step) % len) + len) % len;
366
- result.push(list[idx]);
832
+ result.push(applySeedCase(seed, list[idx]));
367
833
  }
368
834
  break;
369
835
  }
@@ -374,23 +840,23 @@ export const generateFillValues = (sourceValues, fillType, count, locale, custom
374
840
  break;
375
841
  }
376
842
  case "FillSeries": {
377
- const textPatterns = sourceValues
378
- .map((value) => typeof value === "string" ? extractTextNumberPattern(value) : null)
379
- .filter((pattern) => pattern !== null);
380
- if (textPatterns.length === sourceValues.length &&
381
- textPatterns.length > 0) {
382
- const [firstPattern, ...restPatterns] = textPatterns;
383
- const hasConsistentAffixes = restPatterns.every(({ prefix, suffix }) => prefix === firstPattern.prefix && suffix === firstPattern.suffix);
384
- if (hasConsistentAffixes) {
385
- const numbers = textPatterns.map((pattern) => pattern.number);
386
- const diff = numbers.length >= 2
387
- ? normalizeSeriesNumber(numbers[1] - numbers[0])
388
- : 1;
389
- const lastValue = normalizeSeriesNumber(numbers[numbers.length - 1]);
390
- for (let i = 0; i < count; i++) {
391
- const nextNumber = normalizeSeriesNumber(lastValue + diff * (i + 1));
392
- result.push(`${firstPattern.prefix}${String(nextNumber)}${firstPattern.suffix}`);
393
- }
843
+ const allStrings = sourceValues.every((value) => typeof value === "string");
844
+ // Built-in quarter list first: Q4 wraps to Q1 instead of stepping to Q5.
845
+ if (allStrings) {
846
+ const quarters = generateQuarterSeries(sourceValues, count, descending);
847
+ if (quarters) {
848
+ result.push(...quarters);
849
+ break;
850
+ }
851
+ }
852
+ // Text+number series ("Item 1" → "Item 2"). Bare digit strings stay on
853
+ // the numeric path below so "5" → 6, 7, 8 (numbers) as before.
854
+ if (allStrings &&
855
+ sourceValues.length > 0 &&
856
+ !sourceValues.every((value) => BARE_DIGITS_REGEX.test(value))) {
857
+ const textSeries = generateTextNumberSeries(sourceValues, count, descending);
858
+ if (textSeries) {
859
+ result.push(...textSeries);
394
860
  break;
395
861
  }
396
862
  }
@@ -398,18 +864,38 @@ export const generateFillValues = (sourceValues, fillType, count, locale, custom
398
864
  .map((v) => (typeof v === "number" ? v : extractNumber(String(v))))
399
865
  .filter((n) => n !== null);
400
866
  if (numbers.length >= 1) {
401
- // Default increment is 1 for single values, or calculated diff for 2+ values
867
+ // Default increment is 1 for single values (−1 when filling up /
868
+ // left), or calculated diff for 2+ values
402
869
  const diff = numbers.length >= 2
403
870
  ? normalizeSeriesNumber(numbers[1] - numbers[0])
404
- : 1;
871
+ : descending
872
+ ? -1
873
+ : 1;
405
874
  const lastValue = normalizeSeriesNumber(numbers[numbers.length - 1]);
406
875
  for (let i = 0; i < count; i++) {
407
876
  result.push(normalizeSeriesNumber(lastValue + diff * (i + 1)));
408
877
  }
878
+ break;
409
879
  }
410
- else {
411
- result.push(...Array(count).fill(sourceValues[sourceValues.length - 1]));
880
+ // No numeric run at all: an explicit "Fill Series" on a list item walks
881
+ // the list (Monday → Tuesday…), like Excel's AutoFill Options menu.
882
+ if (allStrings) {
883
+ const days = generateNameListSeries(sourceValues, getDayNames(locale), count, descending);
884
+ if (days.length) {
885
+ result.push(...days);
886
+ break;
887
+ }
888
+ const months = generateNameListSeries(sourceValues, getMonthNames(locale), count, descending);
889
+ if (months.length) {
890
+ result.push(...months);
891
+ break;
892
+ }
893
+ if (matchCustomList(sourceValues, customAutofillLists)) {
894
+ result.push(...generateFillValues(sourceValues, "FillCustomList", count, locale, customAutofillLists, descending));
895
+ break;
896
+ }
412
897
  }
898
+ result.push(...Array(count).fill(sourceValues[sourceValues.length - 1]));
413
899
  break;
414
900
  }
415
901
  case "FillDays": {
@@ -417,56 +903,52 @@ export const generateFillValues = (sourceValues, fillType, count, locale, custom
417
903
  .map((value) => parseDate(value))
418
904
  .filter((date) => date !== null);
419
905
  if (parsedDates.length) {
906
+ // Step in serial space from the LAST two seeds so a sub-day step
907
+ // (09:00, 15:00 → 21:00, 03:00) and the time-of-day both survive;
908
+ // a lone date steps one day (backwards when filling up / left).
420
909
  const lastDate = parsedDates[parsedDates.length - 1];
421
910
  const previousDate = parsedDates.length >= 2 ? parsedDates[parsedDates.length - 2] : null;
422
- const dayMs = 1000 * 60 * 60 * 24;
423
- let dayIncrement = 1;
911
+ const lastSerial = dateToSerial(lastDate);
912
+ let step = unitStep;
424
913
  if (previousDate) {
425
- const diff = Math.round((lastDate.getTime() - previousDate.getTime()) / dayMs);
426
- if (diff !== 0) {
427
- dayIncrement = diff;
914
+ const diff = lastSerial - dateToSerial(previousDate);
915
+ if (Math.abs(diff) > SERIAL_EPSILON) {
916
+ step = diff;
428
917
  }
429
918
  }
430
- const currentDate = new Date(lastDate);
431
919
  for (let i = 0; i < count; i++) {
432
- currentDate.setDate(currentDate.getDate() + dayIncrement);
433
- result.push(currentDate.toLocaleDateString(locale));
920
+ result.push(serialToDate(lastSerial + step * (i + 1)));
434
921
  }
435
922
  }
436
923
  else {
437
- const lastDayValue = sourceValues[sourceValues.length - 1];
438
- if (typeof lastDayValue === "string") {
439
- // Handle day names (like "Mon", "Tue", "Wed")
440
- const allDayNames = getDayNames(locale);
441
- const allDayAbbrevs = allDayNames.map((d) => d.substring(0, 3));
442
- // Find which day we're at
443
- const lastDayLower = lastDayValue.toLowerCase();
444
- const dayIndex = allDayNames.findIndex((d) => d.toLowerCase().includes(lastDayLower) ||
445
- lastDayLower.includes(d.toLowerCase()));
446
- if (dayIndex !== -1) {
447
- // Determine if we're using full names or abbreviations
448
- const isAbbreviated = lastDayValue.length <= 3;
449
- for (let i = 0; i < count; i++) {
450
- const nextDayIndex = (dayIndex + i + 1) % 7;
451
- const dayName = isAbbreviated
452
- ? allDayAbbrevs[nextDayIndex]
453
- : allDayNames[nextDayIndex];
454
- result.push(dayName);
455
- }
456
- }
457
- }
924
+ // Day names ("Mon", "Tue", "Wed"): seed gap as the step, seed casing kept.
925
+ result.push(...generateNameListSeries(sourceValues, getDayNames(locale), count, descending));
458
926
  }
459
927
  break;
460
928
  }
461
929
  case "FillWeekdays": {
462
- const lastWeekday = parseDate(sourceValues[sourceValues.length - 1]);
930
+ // Explicit Fill Weekdays: step Mon–Fri only, from a weekend seed too
931
+ // (Sat 1/6 → Mon 1/8). Two seeds set the step in weekdays (Fri, Fri →
932
+ // 5); a lone seed steps one weekday (backwards when filling up / left).
933
+ const weekdayDates = sourceValues
934
+ .map((value) => parseDate(value))
935
+ .filter((date) => date !== null);
936
+ const lastWeekday = weekdayDates[weekdayDates.length - 1];
463
937
  if (lastWeekday) {
938
+ const previous = weekdayDates.length >= 2
939
+ ? weekdayDates[weekdayDates.length - 2]
940
+ : null;
941
+ const seedStep = previous ? weekdaysBetween(previous, lastWeekday) : 0;
942
+ const step = seedStep === 0 ? unitStep : seedStep;
943
+ const dir = step > 0 ? 1 : -1;
464
944
  const currentDate = new Date(lastWeekday);
465
945
  for (let i = 0; i < count; i++) {
466
- do {
467
- currentDate.setDate(currentDate.getDate() + 1);
468
- } while (currentDate.getDay() === 0 || currentDate.getDay() === 6); // Skip weekends
469
- result.push(new Date(currentDate).toLocaleDateString(locale));
946
+ for (let taken = 0; taken < Math.abs(step);) {
947
+ currentDate.setDate(currentDate.getDate() + dir);
948
+ if (!isWeekend(currentDate))
949
+ taken += 1;
950
+ }
951
+ result.push(new Date(currentDate));
470
952
  }
471
953
  }
472
954
  break;
@@ -476,42 +958,71 @@ export const generateFillValues = (sourceValues, fillType, count, locale, custom
476
958
  const lastValue = sourceValues[sourceValues.length - 1];
477
959
  const lastMonth = parseDate(lastValue);
478
960
  if (lastMonth) {
479
- // Handle actual dates
961
+ // Anchor on the last seed and add whole months, clamping the day to
962
+ // the target month (Jan 31 → Feb 29, Mar 31, Apr 30). Two seeds set
963
+ // the step (15-Jan, 15-Apr → 3 months); two month-ends (Jan 31, Feb
964
+ // 29) keep the series on the month-end. A lone seed steps one month
965
+ // (backwards when filling up / left).
966
+ const monthDates = sourceValues
967
+ .map((value) => parseDate(value))
968
+ .filter((date) => date !== null);
969
+ const previous = monthDates.length >= 2 ? monthDates[monthDates.length - 2] : null;
970
+ const seedStep = previous ? monthsBetween(previous, lastMonth) : 0;
971
+ const step = seedStep === 0 ? unitStep : seedStep;
972
+ const endOfMonth = monthDates.length >= 2 && monthDates.every(isLastDayOfMonth);
480
973
  for (let i = 0; i < count; i++) {
481
- const newDate = new Date(lastMonth);
482
- newDate.setMonth(lastMonth.getMonth() + i + 1);
483
- result.push(newDate.toLocaleDateString(locale));
974
+ result.push(addMonthsClamped(lastMonth, step * (i + 1), endOfMonth));
484
975
  }
485
976
  }
486
977
  else if (typeof lastValue === "string") {
487
- // Handle month names (like "Jan", "Feb", "Mar")
488
- const allMonthNames = getMonthNames(locale);
489
- const allMonthAbbrevs = allMonthNames.map((m) => m.substring(0, 3));
490
- // Find which month we're at
491
- const lastValueLower = lastValue.toLowerCase();
492
- const monthIndex = allMonthNames.findIndex((m) => m.toLowerCase().includes(lastValueLower) ||
493
- lastValueLower.includes(m.toLowerCase()));
494
- if (monthIndex !== -1) {
495
- // Determine if we're using full names or abbreviations
496
- const isAbbreviated = lastValue.length <= 3;
497
- for (let i = 0; i < count; i++) {
498
- const nextMonthIndex = (monthIndex + i + 1) % 12;
499
- const monthName = isAbbreviated
500
- ? allMonthAbbrevs[nextMonthIndex]
501
- : allMonthNames[nextMonthIndex];
502
- result.push(monthName);
503
- }
504
- }
978
+ // Month names ("Jan", "Feb", "Mar"): seed gap as the step, seed casing kept.
979
+ result.push(...generateNameListSeries(sourceValues, getMonthNames(locale), count, descending));
505
980
  }
506
981
  break;
507
982
  }
508
983
  case "FillYears": {
509
- const lastYear = parseDate(sourceValues[sourceValues.length - 1]);
984
+ // Anchor on the last seed and add whole years, clamping Feb 29 to Feb 28
985
+ // in non-leap years (2/29/2024 → 2/28/2025 … 2/29/2028 — the day comes
986
+ // back, it is never lost). Two seeds set the step; a lone seed steps one
987
+ // year (backwards when filling up / left).
988
+ const yearDates = sourceValues
989
+ .map((value) => parseDate(value))
990
+ .filter((date) => date !== null);
991
+ const lastYear = yearDates[yearDates.length - 1];
510
992
  if (lastYear) {
993
+ const previous = yearDates.length >= 2 ? yearDates[yearDates.length - 2] : null;
994
+ const seedStep = previous
995
+ ? lastYear.getFullYear() - previous.getFullYear()
996
+ : 0;
997
+ const step = seedStep === 0 ? unitStep : seedStep;
511
998
  for (let i = 0; i < count; i++) {
512
- const newDate = new Date(lastYear);
513
- newDate.setFullYear(lastYear.getFullYear() + i + 1);
514
- result.push(newDate.toLocaleDateString(locale));
999
+ result.push(addMonthsClamped(lastYear, 12 * step * (i + 1)));
1000
+ }
1001
+ }
1002
+ break;
1003
+ }
1004
+ case "FillHours": {
1005
+ // Time-of-day series on the raw serial: a lone time steps one hour
1006
+ // (9:00 → 10:00, 11:00; backwards when filling up / left), two set the
1007
+ // step (9:00, 9:30 → 10:00, 10:30). Emits serials, not Dates, so the
1008
+ // series can pass midnight (22:00 → 23:00, 0:00 = serial 1.0, 1:00)
1009
+ // while the cell keeps the seed's time format.
1010
+ const timeSerials = sourceValues
1011
+ .map((value) => value instanceof Date
1012
+ ? dateToSerial(value)
1013
+ : typeof value === "number"
1014
+ ? value
1015
+ : null)
1016
+ .filter((serial) => serial !== null);
1017
+ if (timeSerials.length) {
1018
+ const lastSerial = timeSerials[timeSerials.length - 1];
1019
+ const previous = timeSerials.length >= 2 ? timeSerials[timeSerials.length - 2] : null;
1020
+ const seedStep = previous === null ? 0 : lastSerial - previous;
1021
+ const step = Math.abs(seedStep) > SERIAL_EPSILON
1022
+ ? seedStep
1023
+ : unitStep / HOURS_PER_DAY;
1024
+ for (let i = 0; i < count; i++) {
1025
+ result.push(normalizeSeriesNumber(lastSerial + step * (i + 1)));
515
1026
  }
516
1027
  }
517
1028
  break;
@@ -531,7 +1042,7 @@ export const generateFillValues = (sourceValues, fillType, count, locale, custom
531
1042
  const intercept = (sumY - slope * sumX) / n;
532
1043
  for (let i = 0; i < count; i++) {
533
1044
  const x = numValues.length + i;
534
- result.push(slope * x + intercept);
1045
+ result.push(normalizeSeriesNumber(slope * x + intercept));
535
1046
  }
536
1047
  }
537
1048
  break;
@@ -555,25 +1066,12 @@ export const generateFillValues = (sourceValues, fillType, count, locale, custom
555
1066
  }
556
1067
  break;
557
1068
  }
558
- case "FillValues": {
559
- // Similar to FillSeries but preserves text formatting
560
- for (let i = 0; i < count; i++) {
561
- const sourceIndex = i % sourceValues.length;
562
- let value = sourceValues[sourceIndex];
563
- if (typeof value === "string") {
564
- const num = extractNumber(value);
565
- if (num !== null && sourceValues.length >= 2) {
566
- const nextNum = extractNumber(String(sourceValues[(sourceIndex + 1) % sourceValues.length]));
567
- if (nextNum !== null) {
568
- const diff = nextNum - num;
569
- const cycles = Math.floor(i / sourceValues.length);
570
- const newNum = num + diff * (cycles + 1);
571
- value = value.replace(String(num), String(newNum));
572
- }
573
- }
574
- }
575
- result.push(value);
576
- }
1069
+ case "FillValues":
1070
+ case "FillWithoutFormatting": {
1071
+ // Values-only is a payload modifier, not a series mode: the values extrapolate exactly as a
1072
+ // plain drag would (1,2 → 3,4,5,6); only the formatting is withheld by the caller.
1073
+ const detected = detectFillType([sourceValues], locale, customAutofillLists);
1074
+ result.push(...generateFillValues(sourceValues, detected, count, locale, customAutofillLists, descending));
577
1075
  break;
578
1076
  }
579
1077
  case "FillFormats": {
@@ -594,36 +1092,162 @@ export const generateFillValues = (sourceValues, fillType, count, locale, custom
594
1092
  }
595
1093
  return result;
596
1094
  };
1095
+ const hasExcelTextPrefix = (value) => typeof value === "string" && value.startsWith("'") && value.length > 1;
1096
+ /**
1097
+ * Classify one seed the way Excel partitions a mixed selection: by type, and
1098
+ * for text+number by its non-digit skeleton, so "Item 1" and "Box 1" run as
1099
+ * two independent series.
1100
+ */
1101
+ const classifyFillValue = (value, dayNames, monthNames, customAutofillLists) => {
1102
+ if (value == null || value === "")
1103
+ return { kind: "blank", key: "blank" };
1104
+ if (value instanceof Date)
1105
+ return { kind: "date", key: "date" };
1106
+ if (typeof value === "number")
1107
+ return { kind: "number", key: "number" };
1108
+ if (typeof value !== "string")
1109
+ return { kind: "text", key: "text" };
1110
+ if (matchCustomList([value], customAutofillLists)) {
1111
+ return { kind: "custom", key: "custom" };
1112
+ }
1113
+ if (parseQuarterToken(value))
1114
+ return { kind: "quarter", key: "quarter" };
1115
+ if (isLikelyDateString(value) && parseDate(value)) {
1116
+ return { kind: "date", key: "date" };
1117
+ }
1118
+ if (/\d/.test(value)) {
1119
+ const { parts } = tokenizeDigitRuns(value);
1120
+ return { kind: "textnum", key: `textnum:${parts.join(" ")}` };
1121
+ }
1122
+ if (resolveNameListPosition(value, dayNames)) {
1123
+ return { kind: "day", key: "day" };
1124
+ }
1125
+ if (resolveNameListPosition(value, monthNames)) {
1126
+ return { kind: "month", key: "month" };
1127
+ }
1128
+ return { kind: "text", key: "text" };
1129
+ };
1130
+ const repeatValues = (values, count) => Array.from({ length: count }, (_, i) => values[i % values.length]);
1131
+ /**
1132
+ * Extend one class group. `mixedLine` is true when the group shares its line
1133
+ * with other classes or blanks — there a lone number steps (1, x → 2, x, 3, x)
1134
+ * where a lone number on its own copies (Excel needs two cells for a series).
1135
+ */
1136
+ const generateGroupValues = (cls, values, count, mixedLine, { locale, customAutofillLists, descending }) => {
1137
+ const fallback = () => repeatValues(values, count);
1138
+ switch (cls.kind) {
1139
+ case "blank":
1140
+ return Array(count).fill(null);
1141
+ case "text":
1142
+ return fallback();
1143
+ case "number": {
1144
+ if (values.length === 1) {
1145
+ if (!mixedLine)
1146
+ return fallback();
1147
+ const step = descending ? -1 : 1;
1148
+ const last = normalizeSeriesNumber(values[0]);
1149
+ return Array.from({ length: count }, (_, i) => normalizeSeriesNumber(last + step * (i + 1)));
1150
+ }
1151
+ const type = detectFillType([values], locale, customAutofillLists);
1152
+ const out = generateFillValues(values, type, count, locale, customAutofillLists, descending);
1153
+ return out.length === count ? out : fallback();
1154
+ }
1155
+ case "date": {
1156
+ // `detectFillType` picks the calendar unit (a lone time → FillHours,
1157
+ // a lone date → FillDays, month-ends → FillMonths, …).
1158
+ const type = detectFillType([values], locale, customAutofillLists);
1159
+ const out = generateFillValues(values, type, count, locale, customAutofillLists, descending);
1160
+ return out.length === count ? out : fallback();
1161
+ }
1162
+ case "quarter":
1163
+ return generateQuarterSeries(values, count, descending) ?? fallback();
1164
+ case "textnum":
1165
+ return generateTextNumberSeries(values, count, descending) ?? fallback();
1166
+ case "day": {
1167
+ const out = generateNameListSeries(values, getDayNames(locale), count, descending);
1168
+ return out.length === count ? out : fallback();
1169
+ }
1170
+ case "month": {
1171
+ const out = generateNameListSeries(values, getMonthNames(locale), count, descending);
1172
+ return out.length === count ? out : fallback();
1173
+ }
1174
+ case "custom": {
1175
+ const out = generateFillValues(values, "FillCustomList", count, locale, customAutofillLists, descending);
1176
+ return out.length === count ? out : fallback();
1177
+ }
1178
+ }
1179
+ };
1180
+ /**
1181
+ * Compose a fill line from its seeds. A homogeneous line is one series; a
1182
+ * mixed line (text1, textA / 1, blank, 2, blank / Monday, 1) is partitioned by
1183
+ * class, each group extended on its own, and the results interleaved back
1184
+ * onto the source pattern so blanks stay blank and every slot continues its
1185
+ * own sub-series — Excel's "text1, textA → text2, textA, text3, textA".
1186
+ */
1187
+ const generateLineSeries = (sourceValues, count, options) => {
1188
+ const { locale, customAutofillLists } = options;
1189
+ const len = sourceValues.length;
1190
+ if (len === 0 || count <= 0)
1191
+ return { fillType: "FillDefault", values: [] };
1192
+ const dayNames = getDayNames(locale);
1193
+ const monthNames = getMonthNames(locale);
1194
+ const classes = sourceValues.map((v) => classifyFillValue(v, dayNames, monthNames, customAutofillLists));
1195
+ const groups = new Map();
1196
+ classes.forEach((cls, position) => {
1197
+ const group = groups.get(cls.key) ?? {
1198
+ cls,
1199
+ values: [],
1200
+ generated: [],
1201
+ cursor: 0,
1202
+ };
1203
+ group.values.push(sourceValues[position]);
1204
+ groups.set(cls.key, group);
1205
+ });
1206
+ const nonBlankGroups = [...groups.values()].filter((g) => g.cls.kind !== "blank");
1207
+ if (nonBlankGroups.length === 0) {
1208
+ return { fillType: "FillDefault", values: Array(count).fill(null) };
1209
+ }
1210
+ const mixedLine = groups.size > 1;
1211
+ const needed = new Map();
1212
+ for (let i = 0; i < count; i++) {
1213
+ const key = classes[i % len].key;
1214
+ needed.set(key, (needed.get(key) ?? 0) + 1);
1215
+ }
1216
+ for (const [key, group] of groups) {
1217
+ group.generated = generateGroupValues(group.cls, group.values, needed.get(key) ?? 0, mixedLine, options);
1218
+ }
1219
+ const values = [];
1220
+ for (let i = 0; i < count; i++) {
1221
+ const group = groups.get(classes[i % len].key);
1222
+ values.push(group.generated[group.cursor++]);
1223
+ }
1224
+ // The number group's own detection drives number-format normalisation.
1225
+ const numberGroup = groups.get("number");
1226
+ let lineFillType;
1227
+ if (numberGroup) {
1228
+ lineFillType =
1229
+ numberGroup.values.length >= 2
1230
+ ? detectFillType([numberGroup.values], locale, customAutofillLists)
1231
+ : mixedLine
1232
+ ? "FillSeries"
1233
+ : "FillCopy";
1234
+ }
1235
+ else if (mixedLine || nonBlankGroups.some((g) => g.cls.kind !== "text")) {
1236
+ lineFillType = "FillSeries";
1237
+ }
1238
+ else {
1239
+ lineFillType = "FillDefault";
1240
+ }
1241
+ return { fillType: lineFillType, values };
1242
+ };
597
1243
  /**
598
1244
  * Get auto fill values
599
1245
  * @returns T[][]
600
1246
  */
601
- export const getAutoFillValues = async ({ sheetId, direction, selection, fillBounds, getCellData, getEffectiveValue, getFormattedValue, getSheetName, getDataValidationById, cellXfsRegistry, locale, fillType, customAutofillLists, }) => {
1247
+ export const getAutoFillValues = async ({ sheetId, direction, selection, fillBounds, getCellData, getEffectiveValue, getFormattedValue, getSheetName, getDataValidationById, cellXfsRegistry, locale, fillType: requestedFillType, modifier, series, customAutofillLists, merges, }) => {
602
1248
  // Create a cell helper
603
1249
  const sheetCell = new SheetCell();
604
1250
  const activeSheetName = getSheetName(sheetId);
605
- const textCell = new SheetCell();
606
- const getTextInfo = (cells) => {
607
- for (const { rowIndex, columnIndex } of cells) {
608
- const cellData = getCellData(sheetId, rowIndex, columnIndex);
609
- textCell.assign(sheetId, { rowIndex, columnIndex }, cellData, {
610
- locale,
611
- cellXfsRegistry,
612
- getDataValidationById,
613
- });
614
- if (textCell.getNumberFormat()?.type === "TEXT") {
615
- const fallback = getFillSourceValue(sheetId, rowIndex, columnIndex);
616
- return {
617
- isText: true,
618
- textValue: textCell.getUserEnteredValue() ??
619
- textCell.getEffectiveValue() ??
620
- String(fallback ?? ""),
621
- pattern: textCell.getNumberFormat()?.pattern ?? "General",
622
- };
623
- }
624
- }
625
- return { isText: false, textValue: "", pattern: "General" };
626
- };
627
1251
  const buildTextCell = (sourceCellData, value, pattern) => {
628
1252
  const newCell = new SheetCell();
629
1253
  newCell.assign(sheetId, { rowIndex: selection.range.startRowIndex, columnIndex: 0 }, sourceCellData, { locale, cellXfsRegistry, getDataValidationById });
@@ -634,285 +1258,401 @@ export const getAutoFillValues = async ({ sheetId, direction, selection, fillBou
634
1258
  newCell.setUserEnteredValue(value);
635
1259
  return newCell.getCellData();
636
1260
  };
637
- const getFillSourceValue = (sheetId, rowIndex, columnIndex) => {
1261
+ /**
1262
+ * Read one seed: its series value plus how it is typed. TEXT-formatted and
1263
+ * `'`-prefixed cells yield their text (so "001" stays a zero-padded text
1264
+ * number rather than the number 1), DATE cells yield a Date.
1265
+ */
1266
+ const readFillSource = (rowIndex, columnIndex) => {
638
1267
  const cellData = getCellData(sheetId, rowIndex, columnIndex);
639
1268
  sheetCell.assign(sheetId, { rowIndex, columnIndex }, cellData, {
640
1269
  locale,
641
1270
  cellXfsRegistry,
642
1271
  getDataValidationById,
643
1272
  });
644
- const numFmtType = sheetCell.getNumberFormat()?.type;
645
- const isDateTime = numFmtType === "DATE" || numFmtType === "DATE_TIME";
646
- const isText = numFmtType === "TEXT";
1273
+ const numberFormat = sheetCell.getNumberFormat();
1274
+ const numFmtType = numberFormat?.type;
1275
+ const isDateTime = isDateLikeFormatType(numFmtType);
1276
+ const textFormat = numFmtType === "TEXT";
1277
+ const userEntered = sheetCell.getUserEnteredValue();
1278
+ const quotePrefixed = !sheetCell.isFormula() && hasExcelTextPrefix(userEntered);
1279
+ let value;
647
1280
  if (isDateTime) {
648
1281
  const effectiveValue = getEffectiveValue?.(sheetId, rowIndex, columnIndex);
649
- if (typeof effectiveValue === "number") {
650
- return toDate(effectiveValue, sheetCell.use1904);
651
- }
652
- return getFormattedValue?.(sheetId, rowIndex, columnIndex);
1282
+ // A time-of-day (serial in [0, 1) — a typed `9:00` is DATE_TIME
1283
+ // 0.375) is epoch-independent: carry it on the 1900 epoch's day zero
1284
+ // so `detectFillType` can tell a time from a date.
1285
+ value =
1286
+ typeof effectiveValue === "number"
1287
+ ? effectiveValue >= 0 && effectiveValue < 1
1288
+ ? serialToDate(effectiveValue)
1289
+ : toDate(effectiveValue, sheetCell.use1904)
1290
+ : getFormattedValue?.(sheetId, rowIndex, columnIndex);
1291
+ }
1292
+ else if (textFormat) {
1293
+ value = userEntered ?? sheetCell.getEffectiveValue();
653
1294
  }
654
- const cellValue = isText
655
- ? sheetCell.getUserEnteredValue()
656
- : getEffectiveValue?.(sheetId, rowIndex, columnIndex);
657
- return cellValue;
1295
+ else {
1296
+ value = getEffectiveValue?.(sheetId, rowIndex, columnIndex);
1297
+ }
1298
+ if (quotePrefixed && hasExcelTextPrefix(value)) {
1299
+ value = value.slice(1);
1300
+ }
1301
+ const textTyped = textFormat ||
1302
+ quotePrefixed ||
1303
+ (!sheetCell.isFormula() &&
1304
+ typeof userEntered === "string" &&
1305
+ typeof value === "string");
1306
+ return {
1307
+ value,
1308
+ meta: {
1309
+ textFormat,
1310
+ textPattern: numberFormat?.pattern ?? "General",
1311
+ quotePrefixed,
1312
+ textTyped,
1313
+ formula: sheetCell.isFormula(),
1314
+ },
1315
+ };
1316
+ };
1317
+ /**
1318
+ * A generated Date is written into a date/time-formatted cell as its serial
1319
+ * so the seed's number format (DATE d/m/yy, DATE_TIME hh:mm, …) survives
1320
+ * verbatim — a Date instance would be re-classified on entry (midnight →
1321
+ * DATE) and could flip the pattern. Time-of-day Dates sit on the 1900
1322
+ * epoch's day zero and convert on that epoch whatever the workbook's date
1323
+ * system. `sheetCell` must already be assigned to the source cell.
1324
+ */
1325
+ const toCellValue = (value) => {
1326
+ if (!(value instanceof Date))
1327
+ return value;
1328
+ if (!isDateLikeFormatType(sheetCell.getNumberFormat()?.type))
1329
+ return value;
1330
+ return isTimeOnlyDate(value)
1331
+ ? dateToSerial(value)
1332
+ : toSerial(value, sheetCell.use1904);
1333
+ };
1334
+ /**
1335
+ * Write one generated value onto `sheetCell` (already assigned the wrapped
1336
+ * seed cell). Formulas shift; text-typed seeds stay text — a TEXT format
1337
+ * is re-applied, and a value that type inference would turn into a number,
1338
+ * date or boolean is written with Excel's `'` escape.
1339
+ */
1340
+ const writeFillValue = (value, meta, sourceCellData, from, to, lineFillType) => {
1341
+ if (sheetCell.isFormula()) {
1342
+ sheetCell.moveFormula(from, to, activeSheetName, {
1343
+ preserveAbsolutes: true,
1344
+ });
1345
+ // Remove effective value for formulas
1346
+ sheetCell.clearEffectiveValueForFormula();
1347
+ return sheetCell.getCellData();
1348
+ }
1349
+ if (meta.textFormat) {
1350
+ return buildTextCell(sourceCellData, value == null ? "" : String(value), meta.textPattern);
1351
+ }
1352
+ if (meta.quotePrefixed && typeof value === "string" && value !== "") {
1353
+ sheetCell.setUserEnteredValue(`'${value}`);
1354
+ return sheetCell.getCellData();
1355
+ }
1356
+ if (shouldNormalizeSeriesNumberFormatForCell(sheetCell, lineFillType, value, locale)) {
1357
+ clearCellNumberFormatForSeries(sheetCell);
1358
+ }
1359
+ sheetCell.setUserEnteredValue(toCellValue(value));
1360
+ if (meta.textTyped &&
1361
+ typeof value === "string" &&
1362
+ value !== "" &&
1363
+ (sheetCell.isFormula() ||
1364
+ typeof sheetCell.getUserEnteredValue() !== "string")) {
1365
+ sheetCell.setUserEnteredValue(`'${value}`);
1366
+ }
1367
+ return sheetCell.getCellData();
658
1368
  };
659
- // Get selection data for pattern analysis
660
- const selectionData = [];
661
- for (let rowIndex = selection.range.startRowIndex; rowIndex <= selection.range.endRowIndex; rowIndex++) {
662
- const rowData = [];
663
- for (let columnIndex = selection.range.startColumnIndex; columnIndex <= selection.range.endColumnIndex; columnIndex++) {
664
- const cellData = getFillSourceValue(sheetId, rowIndex, columnIndex);
665
- rowData.push(cellData || null);
666
- }
667
- selectionData.push(rowData);
668
- }
669
- // Calculate fill dimensions
670
- const selectionRows = selection.range.endRowIndex - selection.range.startRowIndex + 1;
671
- const selectionCols = selection.range.endColumnIndex - selection.range.startColumnIndex + 1;
672
1369
  const fillValues = [];
673
- switch (direction) {
674
- case Direction.Down: {
675
- const fillCount = fillBounds.endRowIndex - selection.range.endRowIndex;
676
- for (let columnIndex = selection.range.startColumnIndex; columnIndex <= selection.range.endColumnIndex; columnIndex++) {
677
- // Get source values for this column
678
- const sourceValues = [];
679
- const sourceCells = [];
680
- for (let rowIndex = selection.range.startRowIndex; rowIndex <= selection.range.endRowIndex; rowIndex++) {
681
- // Check data type
682
- const cellValue = getFillSourceValue(sheetId, rowIndex, columnIndex);
683
- sourceValues.push(cellValue);
684
- sourceCells.push({ rowIndex, columnIndex });
685
- }
686
- const textInfo = getTextInfo(sourceCells);
687
- // Detect fill type for this specific column
688
- const columnData = [sourceValues]; // Convert to 2D array for detectFillType
689
- const actualFillType = fillType || detectFillType(columnData, locale, customAutofillLists);
690
- // Generate fill values for this column
691
- let newValues = textInfo.isText
692
- ? Array(fillCount).fill(textInfo.textValue)
693
- : generateFillValues(sourceValues, actualFillType, fillCount, locale, customAutofillLists);
694
- // Fallback to FillDefault if no values were generated
695
- if (newValues.length === 0 && actualFillType !== "FillDefault") {
696
- newValues = generateFillValues(sourceValues, "FillDefault", fillCount, locale);
697
- }
698
- // Apply the values
699
- for (let i = 0; i < fillCount; i++) {
700
- const targetRowIndex = selection.range.endRowIndex + 1 + i;
701
- const targetColumnIndex = columnIndex;
702
- if (!fillValues[i])
703
- fillValues[i] = [];
704
- if (actualFillType === "FillFormats") {
705
- // Copy format but not value
706
- fillValues[i][targetColumnIndex - fillBounds.startColumnIndex] =
707
- "";
708
- }
709
- else {
710
- // Get a source cell for formatting/properties
711
- const sourceRowIndex = selection.range.startRowIndex + (i % selectionRows);
712
- const sourceCellData = getCellData?.(sheetId, sourceRowIndex, columnIndex);
713
- sheetCell.assign(sheetId, { rowIndex: sourceRowIndex, columnIndex }, sourceCellData, { locale, cellXfsRegistry, getDataValidationById });
714
- if (sheetCell.isFormula()) {
715
- sheetCell.moveFormula({ rowIndex: sourceRowIndex, columnIndex }, { rowIndex: targetRowIndex, columnIndex: targetColumnIndex }, activeSheetName, { preserveAbsolutes: true });
716
- // Remove effective value for formulas
717
- sheetCell.clearEffectiveValueForFormula();
718
- }
719
- else if (textInfo.isText) {
720
- fillValues[i][targetColumnIndex - fillBounds.startColumnIndex] =
721
- buildTextCell(sourceCellData, String(newValues[i] ?? ""), textInfo.pattern);
722
- continue;
723
- }
724
- else {
725
- // Use the generated fill value instead of source cell value
726
- const shouldNormalizeSeriesNumberFormat = shouldNormalizeSeriesNumberFormatForCell(sheetCell, actualFillType, newValues[i]);
727
- if (shouldNormalizeSeriesNumberFormat) {
728
- clearCellNumberFormatForSeries(sheetCell);
729
- }
730
- sheetCell.setUserEnteredValue(newValues[i]);
731
- }
732
- fillValues[i][targetColumnIndex - fillBounds.startColumnIndex] =
733
- sheetCell.getCellData();
734
- }
735
- }
1370
+ const vertical = direction === Direction.Down || direction === Direction.Up;
1371
+ const descending = direction === Direction.Up || direction === Direction.Left;
1372
+ const { range } = selection;
1373
+ // A merged seed fills by whole blocks (Excel: the drag advances in block multiples and every
1374
+ // landed block is merged like the seed). Along the fill the seeds and targets step by the block
1375
+ // size (`stride`); across it one fill line runs per block column/row (`lineStride`). Only the
1376
+ // ANCHOR cells feed detection — covered cells never count as blanks in the series — and every
1377
+ // landed block is reported for `applyFill` to merge. A plain fill is the 1×1 case.
1378
+ const block = resolveMergedFillBlock(range, merges);
1379
+ const stride = block ? (vertical ? block.rows : block.cols) : 1;
1380
+ const lineStride = block ? (vertical ? block.cols : block.rows) : 1;
1381
+ // Fill lines run along the drag: one per column for a vertical fill, one per
1382
+ // row for a horizontal one. Seeds are read in fill order (reversed for
1383
+ // up/left) so the writer's `i % seedCount` wrap picks the matching seed.
1384
+ const lineCount = (vertical
1385
+ ? range.endColumnIndex - range.startColumnIndex + 1
1386
+ : range.endRowIndex - range.startRowIndex + 1) / lineStride;
1387
+ const seedCount = (vertical
1388
+ ? range.endRowIndex - range.startRowIndex + 1
1389
+ : range.endColumnIndex - range.startColumnIndex + 1) / stride;
1390
+ const span = (() => {
1391
+ switch (direction) {
1392
+ case Direction.Down:
1393
+ return fillBounds.endRowIndex - range.endRowIndex;
1394
+ case Direction.Up:
1395
+ return range.startRowIndex - fillBounds.startRowIndex;
1396
+ case Direction.Right:
1397
+ return fillBounds.endColumnIndex - range.endColumnIndex;
1398
+ case Direction.Left:
1399
+ return range.startColumnIndex - fillBounds.startColumnIndex;
1400
+ default:
1401
+ return 0;
1402
+ }
1403
+ })();
1404
+ // Number of target cells (plain) or target blocks (merged seed) per line; a merged extent snaps
1405
+ // to whole blocks, truncating toward the source.
1406
+ const fillCount = Math.floor(Math.max(span, 0) / stride);
1407
+ const snapped = { ...fillBounds };
1408
+ if (block) {
1409
+ if (direction === Direction.Down) {
1410
+ snapped.endRowIndex = range.endRowIndex + fillCount * stride;
1411
+ }
1412
+ else if (direction === Direction.Up) {
1413
+ snapped.startRowIndex = range.startRowIndex - fillCount * stride;
1414
+ }
1415
+ else if (direction === Direction.Right) {
1416
+ snapped.endColumnIndex = range.endColumnIndex + fillCount * stride;
1417
+ }
1418
+ else {
1419
+ snapped.startColumnIndex = range.startColumnIndex - fillCount * stride;
1420
+ }
1421
+ fillValues.snappedFillBounds = snapped;
1422
+ fillValues.fillMerges = [];
1423
+ }
1424
+ const seedCoord = (line, k) => {
1425
+ if (vertical) {
1426
+ const columnIndex = range.startColumnIndex + line * lineStride;
1427
+ const rowIndex = descending
1428
+ ? range.endRowIndex - (stride - 1) - k * stride
1429
+ : range.startRowIndex + k * stride;
1430
+ return { rowIndex, columnIndex };
1431
+ }
1432
+ const rowIndex = range.startRowIndex + line * lineStride;
1433
+ const columnIndex = descending
1434
+ ? range.endColumnIndex - (stride - 1) - k * stride
1435
+ : range.startColumnIndex + k * stride;
1436
+ return { rowIndex, columnIndex };
1437
+ };
1438
+ const targetCoord = (line, i) => {
1439
+ if (vertical) {
1440
+ const columnIndex = range.startColumnIndex + line * lineStride;
1441
+ const rowIndex = descending
1442
+ ? range.startRowIndex - (i + 1) * stride
1443
+ : range.endRowIndex + 1 + i * stride;
1444
+ return { rowIndex, columnIndex };
1445
+ }
1446
+ const rowIndex = range.startRowIndex + line * lineStride;
1447
+ const columnIndex = descending
1448
+ ? range.startColumnIndex - (i + 1) * stride
1449
+ : range.endColumnIndex + 1 + i * stride;
1450
+ return { rowIndex, columnIndex };
1451
+ };
1452
+ // The result grid starts at the first filled cell: below/right of the
1453
+ // selection for down/right fills, at the (snapped) fill bounds' origin for up/left.
1454
+ const originRow = direction === Direction.Down ? range.endRowIndex + 1 : snapped.startRowIndex;
1455
+ const originColumn = direction === Direction.Right
1456
+ ? range.endColumnIndex + 1
1457
+ : snapped.startColumnIndex;
1458
+ const place = (target, cell) => {
1459
+ const r = target.rowIndex - originRow;
1460
+ const c = target.columnIndex - originColumn;
1461
+ if (!fillValues[r])
1462
+ fillValues[r] = [];
1463
+ fillValues[r][c] = cell;
1464
+ };
1465
+ const lines = [];
1466
+ for (let line = 0; line < lineCount; line++) {
1467
+ const sourceValues = [];
1468
+ const sourceMeta = [];
1469
+ for (let k = 0; k < seedCount; k++) {
1470
+ const { rowIndex, columnIndex } = seedCoord(line, k);
1471
+ const { value, meta } = readFillSource(rowIndex, columnIndex);
1472
+ sourceValues.push(value);
1473
+ sourceMeta.push(meta);
1474
+ }
1475
+ lines.push({ sourceValues, sourceMeta });
1476
+ }
1477
+ /**
1478
+ * Resolve the fill type once for the whole drag. An explicit type wins; the
1479
+ * Ctrl toggle is decided from what every line would auto-detect (columns
1480
+ * for a vertical fill, rows for a horizontal one) — formula and empty lines
1481
+ * don't vote.
1482
+ */
1483
+ const fillType = (() => {
1484
+ if (requestedFillType)
1485
+ return requestedFillType;
1486
+ if (modifier !== "toggle")
1487
+ return undefined;
1488
+ const detectedPerLine = lines.map(({ sourceValues, sourceMeta }) => {
1489
+ if (sourceMeta.every((m) => m.formula))
1490
+ return undefined;
1491
+ if (sourceValues.every((v) => v == null || v === ""))
1492
+ return undefined;
1493
+ return generateLineSeries(sourceValues, 1, {
1494
+ locale,
1495
+ customAutofillLists,
1496
+ descending,
1497
+ }).fillType;
1498
+ });
1499
+ return resolveToggledFillType(detectedPerLine);
1500
+ })();
1501
+ // Excel's Auto Fill Options: formats-only leaves the targets' values alone, values-only leaves
1502
+ // their formatting alone. Neither is a series mode — detect the series as for a plain drag.
1503
+ const isFormatsOnly = isFormatsOnlyFillType(fillType);
1504
+ const isValuesOnly = isValuesOnlyFillType(fillType);
1505
+ const seriesFillType = isFormatsOnly || isValuesOnly ? undefined : fillType;
1506
+ /**
1507
+ * "Fill Formatting Only": the target keeps its value and takes the source's user-entered format
1508
+ * (a style reference is copied as-is — same registry). Equivalent to Paste Special > Formats.
1509
+ */
1510
+ const formatsOnlyCell = (source, target) => {
1511
+ const sourceUf = getCellData(sheetId, source.rowIndex, source.columnIndex)?.uf;
1512
+ const { uf: _targetUf, ...rest } = (getCellData(sheetId, target.rowIndex, target.columnIndex) ?? {});
1513
+ return (sourceUf === undefined ? rest : { ...rest, uf: sourceUf });
1514
+ };
1515
+ /**
1516
+ * "Fill Without Formatting": the generated cell keeps the TARGET's own user-entered format
1517
+ * instead of the source's. A plain fill returns the generated cell untouched.
1518
+ */
1519
+ const finalizeCell = (cell, target) => {
1520
+ if (!isValuesOnly)
1521
+ return cell;
1522
+ const targetUf = getCellData(sheetId, target.rowIndex, target.columnIndex)?.uf;
1523
+ const { uf: _sourceUf, ...rest } = cell;
1524
+ return (targetUf === undefined ? rest : { ...rest, uf: targetUf });
1525
+ };
1526
+ /**
1527
+ * One fill line's generated values, in fill order: the Series dialog's spec
1528
+ * first (explicit intent), then the requested fill type, else auto-detect.
1529
+ */
1530
+ const computeLine = (sourceValues, count) => {
1531
+ if (series) {
1532
+ const specLine = generateSeriesSpecValues(sourceValues, series, count, locale);
1533
+ if (specLine) {
1534
+ return {
1535
+ fillType: seriesFillType ?? "FillSeries",
1536
+ values: specLine.values,
1537
+ seeds: specLine.seeds,
1538
+ untouchedTail: true,
1539
+ };
736
1540
  }
737
- break;
738
1541
  }
739
- case Direction.Up: {
740
- const fillCount = selection.range.startRowIndex - fillBounds.startRowIndex;
741
- for (let columnIndex = selection.range.startColumnIndex; columnIndex <= selection.range.endColumnIndex; columnIndex++) {
742
- // Get source values for this column (in reverse order for up direction)
743
- const sourceValues = [];
744
- const sourceCells = [];
745
- for (let rowIndex = selection.range.endRowIndex; rowIndex >= selection.range.startRowIndex; rowIndex--) {
746
- const cellValue = getFillSourceValue(sheetId, rowIndex, columnIndex);
747
- sourceValues.push(cellValue);
748
- sourceCells.push({ rowIndex, columnIndex });
749
- }
750
- const textInfo = getTextInfo(sourceCells);
751
- // Detect fill type for this specific column
752
- const columnData = [sourceValues]; // Convert to 2D array for detectFillType
753
- const actualFillType = fillType || detectFillType(columnData, locale, customAutofillLists);
754
- // Generate fill values for this column
755
- let newValues = textInfo.isText
756
- ? Array(fillCount).fill(textInfo.textValue)
757
- : generateFillValues(sourceValues, actualFillType, fillCount, locale, customAutofillLists);
758
- // Fallback to FillDefault if no values were generated
759
- if (newValues.length === 0 && actualFillType !== "FillDefault") {
760
- newValues = generateFillValues(sourceValues, "FillDefault", fillCount, locale);
761
- }
762
- // Apply the values (in reverse order)
763
- for (let i = 0; i < fillCount; i++) {
764
- const targetRowIndex = selection.range.startRowIndex - 1 - i;
765
- const targetColumnIndex = columnIndex;
766
- if (!fillValues[fillCount - 1 - i])
767
- fillValues[fillCount - 1 - i] = [];
768
- if (actualFillType === "FillFormats") {
769
- fillValues[fillCount - 1 - i][targetColumnIndex - fillBounds.startColumnIndex] = "";
770
- }
771
- else {
772
- const sourceRowIndex = selection.range.endRowIndex - (i % selectionRows);
773
- const sourceCellData = getCellData?.(sheetId, sourceRowIndex, columnIndex);
774
- sheetCell.assign(sheetId, { rowIndex: sourceRowIndex, columnIndex }, sourceCellData, { locale, cellXfsRegistry, getDataValidationById });
775
- if (sheetCell.isFormula()) {
776
- sheetCell.moveFormula({ rowIndex: sourceRowIndex, columnIndex }, { rowIndex: targetRowIndex, columnIndex: targetColumnIndex }, activeSheetName, { preserveAbsolutes: true });
777
- // Remove effective value for formulas
778
- sheetCell.clearEffectiveValueForFormula();
779
- }
780
- else if (textInfo.isText) {
781
- fillValues[fillCount - 1 - i][targetColumnIndex - fillBounds.startColumnIndex] = buildTextCell(sourceCellData, String(newValues[i] ?? ""), textInfo.pattern);
782
- continue;
783
- }
784
- else {
785
- // Use the generated fill value instead of source cell value
786
- const shouldNormalizeSeriesNumberFormat = shouldNormalizeSeriesNumberFormatForCell(sheetCell, actualFillType, newValues[i]);
787
- if (shouldNormalizeSeriesNumberFormat) {
788
- clearCellNumberFormatForSeries(sheetCell);
789
- }
790
- sheetCell.setUserEnteredValue(newValues[i]);
791
- }
792
- fillValues[fillCount - 1 - i][targetColumnIndex - fillBounds.startColumnIndex] = sheetCell.getCellData();
793
- }
794
- }
1542
+ if (seriesFillType) {
1543
+ let values = generateFillValues(sourceValues, seriesFillType, count, locale, customAutofillLists, descending);
1544
+ if (values.length === 0 && seriesFillType !== "FillDefault") {
1545
+ values = generateFillValues(sourceValues, "FillDefault", count, locale);
795
1546
  }
796
- break;
1547
+ return { fillType: seriesFillType, values };
797
1548
  }
798
- case Direction.Right: {
799
- const fillCount = fillBounds.endColumnIndex - selection.range.endColumnIndex;
800
- for (let rowIndex = selection.range.startRowIndex; rowIndex <= selection.range.endRowIndex; rowIndex++) {
801
- // Get source values for this row
802
- const sourceValues = [];
803
- const sourceCells = [];
804
- const textSourceCellData = getCellData?.(sheetId, rowIndex, selection.range.startColumnIndex);
805
- for (let columnIndex = selection.range.startColumnIndex; columnIndex <= selection.range.endColumnIndex; columnIndex++) {
806
- const cellValue = getFillSourceValue(sheetId, rowIndex, columnIndex);
807
- sourceValues.push(cellValue);
808
- sourceCells.push({ rowIndex, columnIndex });
809
- }
810
- const textInfo = getTextInfo(sourceCells);
811
- // Detect fill type for this specific row
812
- const rowData = [sourceValues]; // Convert to 2D array for detectFillType
813
- const actualFillType = fillType || detectFillType(rowData, locale);
814
- // Generate fill values for this row
815
- let newValues = textInfo.isText
816
- ? Array(fillCount).fill(textInfo.textValue)
817
- : generateFillValues(sourceValues, actualFillType, fillCount, locale, customAutofillLists);
818
- // Fallback to FillDefault if no values were generated
819
- if (newValues.length === 0 && actualFillType !== "FillDefault") {
820
- newValues = generateFillValues(sourceValues, "FillDefault", fillCount, locale);
821
- }
822
- // Apply the values
823
- const targetRowIndex = rowIndex - fillBounds.startRowIndex;
824
- if (!fillValues[targetRowIndex])
825
- fillValues[targetRowIndex] = [];
826
- for (let i = 0; i < fillCount; i++) {
827
- const targetColumnIndex = selection.range.endColumnIndex + 1 + i;
828
- if (actualFillType === "FillFormats") {
829
- fillValues[targetRowIndex][i] = "";
830
- }
831
- else if (textInfo.isText) {
832
- fillValues[targetRowIndex][i] = buildTextCell(textSourceCellData, String(newValues[i] ?? ""), textInfo.pattern);
833
- }
834
- else {
835
- const sourceColumnIndex = selection.range.startColumnIndex + (i % selectionCols);
836
- const sourceCellData = getCellData?.(sheetId, rowIndex, sourceColumnIndex);
837
- sheetCell.assign(sheetId, { rowIndex, columnIndex: sourceColumnIndex }, sourceCellData, { locale, cellXfsRegistry, getDataValidationById });
838
- if (sheetCell.isFormula()) {
839
- sheetCell.moveFormula({ rowIndex, columnIndex: sourceColumnIndex }, { rowIndex, columnIndex: targetColumnIndex }, activeSheetName, { preserveAbsolutes: true });
840
- // Remove effective value for formulas
841
- sheetCell.clearEffectiveValueForFormula();
842
- }
843
- else {
844
- // Use the generated fill value instead of source cell value
845
- const shouldNormalizeSeriesNumberFormat = shouldNormalizeSeriesNumberFormatForCell(sheetCell, actualFillType, newValues[i]);
846
- if (shouldNormalizeSeriesNumberFormat) {
847
- clearCellNumberFormatForSeries(sheetCell);
848
- }
849
- sheetCell.setUserEnteredValue(newValues[i]);
850
- }
851
- fillValues[targetRowIndex][i] = sheetCell.getCellData();
852
- }
853
- }
1549
+ return generateLineSeries(sourceValues, count, {
1550
+ locale,
1551
+ customAutofillLists,
1552
+ descending,
1553
+ });
1554
+ };
1555
+ /**
1556
+ * What a landed block's covered cell becomes. Excel copies the whole block, so the covered cell
1557
+ * takes the format of the source block's matching covered cell (a border on the block's far edge
1558
+ * lives on a covered cell) and no value — the merge owns the block. Formats-only keeps the
1559
+ * target's own payload under the source format; values-only clears the value and keeps the
1560
+ * target's own format.
1561
+ */
1562
+ const coveredCell = (target, sourceCovered) => {
1563
+ const targetCell = getCellData(sheetId, target.rowIndex, target.columnIndex);
1564
+ const sourceUf = getCellData(sheetId, sourceCovered.rowIndex, sourceCovered.columnIndex)?.uf;
1565
+ if (isFormatsOnly) {
1566
+ return sourceUf === undefined
1567
+ ? (targetCell ?? undefined)
1568
+ : { ...(targetCell ?? {}), uf: sourceUf };
1569
+ }
1570
+ const uf = isValuesOnly ? targetCell?.uf : sourceUf;
1571
+ return uf === undefined ? undefined : { uf };
1572
+ };
1573
+ /**
1574
+ * Land one block at `target` (its anchor), repeating the source block anchored at `from`: the
1575
+ * anchor takes `cell`, the covered cells take the source block's covered-cell formats (see
1576
+ * `coveredCell`) and the block is reported as a merge. A plain 1×1 fill just places the cell.
1577
+ */
1578
+ const placeBlock = (target, cell, from) => {
1579
+ place(target, cell);
1580
+ if (!block)
1581
+ return;
1582
+ const landed = {
1583
+ startRowIndex: target.rowIndex,
1584
+ endRowIndex: target.rowIndex + block.rows - 1,
1585
+ startColumnIndex: target.columnIndex,
1586
+ endColumnIndex: target.columnIndex + block.cols - 1,
1587
+ };
1588
+ for (let r = landed.startRowIndex; r <= landed.endRowIndex; r++) {
1589
+ for (let c = landed.startColumnIndex; c <= landed.endColumnIndex; c++) {
1590
+ if (r === target.rowIndex && c === target.columnIndex)
1591
+ continue;
1592
+ place({ rowIndex: r, columnIndex: c }, coveredCell({ rowIndex: r, columnIndex: c }, {
1593
+ rowIndex: from.rowIndex + (r - target.rowIndex),
1594
+ columnIndex: from.columnIndex + (c - target.columnIndex),
1595
+ }));
854
1596
  }
855
- break;
856
1597
  }
857
- case Direction.Left: {
858
- const fillCount = selection.range.startColumnIndex - fillBounds.startColumnIndex;
859
- for (let rowIndex = selection.range.startRowIndex; rowIndex <= selection.range.endRowIndex; rowIndex++) {
860
- // Get source values for this row (in reverse order for left direction)
861
- const sourceValues = [];
862
- const sourceCells = [];
863
- const textSourceCellData = getCellData?.(sheetId, rowIndex, selection.range.endColumnIndex);
864
- for (let columnIndex = selection.range.endColumnIndex; columnIndex >= selection.range.startColumnIndex; columnIndex--) {
865
- const cellValue = getFillSourceValue(sheetId, rowIndex, columnIndex);
866
- sourceValues.push(cellValue);
867
- sourceCells.push({ rowIndex, columnIndex });
868
- }
869
- const textInfo = getTextInfo(sourceCells);
870
- // Detect fill type for this specific row
871
- const rowData = [sourceValues]; // Convert to 2D array for detectFillType
872
- const actualFillType = fillType || detectFillType(rowData, locale);
873
- // Generate fill values for this row
874
- let newValues = textInfo.isText
875
- ? Array(fillCount).fill(textInfo.textValue)
876
- : generateFillValues(sourceValues, actualFillType, fillCount, locale, customAutofillLists);
877
- // Fallback to FillDefault if no values were generated
878
- if (newValues.length === 0 && actualFillType !== "FillDefault") {
879
- newValues = generateFillValues(sourceValues, "FillDefault", fillCount, locale);
880
- }
881
- // Apply the values (in reverse order)
882
- const targetRowIndex = rowIndex - fillBounds.startRowIndex;
883
- if (!fillValues[targetRowIndex])
884
- fillValues[targetRowIndex] = [];
885
- for (let i = 0; i < fillCount; i++) {
886
- const targetColumnIndex = selection.range.startColumnIndex - 1 - i;
887
- if (actualFillType === "FillFormats") {
888
- fillValues[targetRowIndex][fillCount - 1 - i] = "";
889
- }
890
- else if (textInfo.isText) {
891
- fillValues[targetRowIndex][fillCount - 1 - i] = buildTextCell(textSourceCellData, String(newValues[i] ?? ""), textInfo.pattern);
892
- }
893
- else {
894
- const sourceColumnIndex = selection.range.endColumnIndex - (i % selectionCols);
895
- const sourceCellData = getCellData?.(sheetId, rowIndex, sourceColumnIndex);
896
- sheetCell.assign(sheetId, { rowIndex, columnIndex: sourceColumnIndex }, sourceCellData, { locale, cellXfsRegistry, getDataValidationById });
897
- if (sheetCell.isFormula()) {
898
- sheetCell.moveFormula({ rowIndex, columnIndex: sourceColumnIndex }, { rowIndex, columnIndex: targetColumnIndex }, activeSheetName, { preserveAbsolutes: true });
899
- // Remove effective value for formulas
900
- sheetCell.clearEffectiveValueForFormula();
901
- }
902
- else {
903
- // Use the generated fill value instead of source cell value
904
- const shouldNormalizeSeriesNumberFormat = shouldNormalizeSeriesNumberFormatForCell(sheetCell, actualFillType, newValues[i]);
905
- if (shouldNormalizeSeriesNumberFormat) {
906
- clearCellNumberFormatForSeries(sheetCell);
907
- }
908
- sheetCell.setUserEnteredValue(newValues[i]);
909
- }
910
- fillValues[targetRowIndex][fillCount - 1 - i] =
911
- sheetCell.getCellData();
912
- }
913
- }
1598
+ fillValues.fillMerges?.push(landed);
1599
+ };
1600
+ /**
1601
+ * A target block a Series-dialog `stop` never reached: every cell of it (anchor and covered) is
1602
+ * marked untouched so `applyFill` leaves it exactly as it is — no value, no format, no merge.
1603
+ */
1604
+ const markUntouched = (target) => {
1605
+ const rows = block?.rows ?? 1;
1606
+ const cols = block?.cols ?? 1;
1607
+ for (let r = 0; r < rows; r++) {
1608
+ for (let c = 0; c < cols; c++) {
1609
+ place({ rowIndex: target.rowIndex + r, columnIndex: target.columnIndex + c }, FILL_UNTOUCHED);
914
1610
  }
915
- break;
1611
+ }
1612
+ };
1613
+ for (let line = 0; line < lineCount; line++) {
1614
+ const { sourceValues, sourceMeta } = lines[line];
1615
+ const { fillType: lineFillType, values: newValues, seeds, untouchedTail, } = computeLine(sourceValues, fillCount);
1616
+ // Series-dialog Trend: Excel REPLACES the starting values with the fitted trend — each numeric
1617
+ // seed cell is rewritten in place (formula shifting is moot: `from` = `to`) and keeps its format.
1618
+ if (seeds?.length && !isFormatsOnly) {
1619
+ fillValues.seedCells ??= [];
1620
+ for (const seed of seeds) {
1621
+ const from = seedCoord(line, seed.index);
1622
+ const sourceCellData = getCellData?.(sheetId, from.rowIndex, from.columnIndex);
1623
+ sheetCell.assign(sheetId, from, sourceCellData, {
1624
+ locale,
1625
+ cellXfsRegistry,
1626
+ getDataValidationById,
1627
+ });
1628
+ fillValues.seedCells.push({
1629
+ rowIndex: from.rowIndex,
1630
+ columnIndex: from.columnIndex,
1631
+ cell: finalizeCell(writeFillValue(seed.value, sourceMeta[seed.index], sourceCellData, from, from, lineFillType), from),
1632
+ });
1633
+ }
1634
+ }
1635
+ for (let i = 0; i < fillCount; i++) {
1636
+ const target = targetCoord(line, i);
1637
+ const k = i % seedCount;
1638
+ const from = seedCoord(line, k);
1639
+ if (untouchedTail && i >= newValues.length) {
1640
+ // The Series dialog's `stop` ended this line: Excel leaves the rest of the selection alone.
1641
+ markUntouched(target);
1642
+ continue;
1643
+ }
1644
+ if (isFormatsOnly) {
1645
+ // Copy format but not value
1646
+ placeBlock(target, formatsOnlyCell(from, target), from);
1647
+ continue;
1648
+ }
1649
+ const sourceCellData = getCellData?.(sheetId, from.rowIndex, from.columnIndex);
1650
+ sheetCell.assign(sheetId, from, sourceCellData, {
1651
+ locale,
1652
+ cellXfsRegistry,
1653
+ getDataValidationById,
1654
+ });
1655
+ placeBlock(target, finalizeCell(writeFillValue(newValues[i], sourceMeta[k], sourceCellData, from, target, lineFillType), target), from);
916
1656
  }
917
1657
  }
918
1658
  return fillValues;