@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.
- package/dist/cjs/api/advanced-filter.d.ts +72 -0
- package/dist/cjs/api/advanced-filter.d.ts.map +1 -0
- package/dist/cjs/api/advanced-filter.js +0 -0
- package/dist/cjs/api/datatype.d.ts.map +1 -1
- package/dist/cjs/api/datatype.js +8 -1
- package/dist/cjs/api/fill-series-spec.d.ts +35 -0
- package/dist/cjs/api/fill-series-spec.d.ts.map +1 -0
- package/dist/cjs/api/fill-series-spec.js +164 -0
- package/dist/cjs/api/fill.d.ts +95 -3
- package/dist/cjs/api/fill.d.ts.map +1 -1
- package/dist/cjs/api/fill.js +1186 -418
- package/dist/cjs/api/goal-seek.d.ts +29 -0
- package/dist/cjs/api/goal-seek.d.ts.map +1 -0
- package/dist/cjs/api/goal-seek.js +131 -0
- package/dist/cjs/api/immer-utils.d.ts.map +1 -1
- package/dist/cjs/api/immer-utils.js +21 -15
- package/dist/cjs/api/intl.d.ts.map +1 -1
- package/dist/cjs/api/paint-format.d.ts +55 -0
- package/dist/cjs/api/paint-format.d.ts.map +1 -0
- package/dist/cjs/api/paint-format.js +174 -0
- package/dist/cjs/api/split-text.d.ts +12 -0
- package/dist/cjs/api/split-text.d.ts.map +1 -1
- package/dist/cjs/api/split-text.js +41 -0
- package/dist/cjs/api/subtotal.d.ts +85 -0
- package/dist/cjs/api/subtotal.d.ts.map +1 -0
- package/dist/cjs/api/subtotal.js +250 -0
- package/dist/cjs/commands.d.ts +19 -1
- package/dist/cjs/commands.d.ts.map +1 -1
- package/dist/cjs/components/editors/data-validation-rule-editor.d.ts.map +1 -1
- package/dist/cjs/components/editors/data-validation-rule-editor.js +83 -16
- package/dist/cjs/hooks/use-calculation-worker.d.ts.map +1 -1
- package/dist/cjs/hooks/use-calculation-worker.js +1 -0
- package/dist/cjs/hooks/use-calculation.d.ts +1 -1
- package/dist/cjs/hooks/use-on-advanced-filter.d.ts +21 -0
- package/dist/cjs/hooks/use-on-advanced-filter.d.ts.map +1 -0
- package/dist/cjs/hooks/use-on-advanced-filter.js +42 -0
- package/dist/cjs/hooks/use-on-fill.d.ts +13 -4
- package/dist/cjs/hooks/use-on-fill.d.ts.map +1 -1
- package/dist/cjs/hooks/use-on-fill.js +28 -7
- package/dist/cjs/hooks/use-on-goal-seek.d.ts +30 -0
- package/dist/cjs/hooks/use-on-goal-seek.d.ts.map +1 -0
- package/dist/cjs/hooks/use-on-goal-seek.js +140 -0
- package/dist/cjs/hooks/use-on-import-excel.d.ts +10 -1
- package/dist/cjs/hooks/use-on-import-excel.d.ts.map +1 -1
- package/dist/cjs/hooks/use-on-import-excel.js +8 -4
- package/dist/cjs/hooks/use-on-merge-unmerge-cells.d.ts.map +1 -1
- package/dist/cjs/hooks/use-on-merge-unmerge-cells.js +53 -34
- package/dist/cjs/hooks/use-on-subtotal.d.ts +26 -0
- package/dist/cjs/hooks/use-on-subtotal.d.ts.map +1 -0
- package/dist/cjs/hooks/use-on-subtotal.js +53 -0
- package/dist/cjs/hooks/use-paint-format.d.ts +19 -3
- package/dist/cjs/hooks/use-paint-format.d.ts.map +1 -1
- package/dist/cjs/hooks/use-paint-format.js +89 -32
- package/dist/cjs/hooks/use-sheet-properties.d.ts +2 -0
- package/dist/cjs/hooks/use-sheet-properties.d.ts.map +1 -1
- package/dist/cjs/hooks/use-sheet-properties.js +4 -0
- package/dist/cjs/hooks/use-split-text-to-columns.d.ts +2 -1
- package/dist/cjs/hooks/use-split-text-to-columns.d.ts.map +1 -1
- package/dist/cjs/hooks/use-split-text-to-columns.js +3 -2
- package/dist/cjs/hooks/use-spreadsheet-state.d.ts +50 -36
- package/dist/cjs/hooks/use-spreadsheet-state.d.ts.map +1 -1
- package/dist/cjs/hooks/use-spreadsheet-state.js +109 -19
- package/dist/cjs/index.d.ts +8 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +8 -0
- package/dist/cjs/interface/apply-fill.d.ts +3 -3
- package/dist/cjs/interface/apply-fill.d.ts.map +1 -1
- package/dist/cjs/interface/apply-fill.js +100 -53
- package/dist/cjs/interface/change-batch-stream.d.ts.map +1 -1
- package/dist/cjs/interface/change-batch-stream.js +12 -6
- package/dist/cjs/interface/change-batch.d.ts.map +1 -1
- package/dist/cjs/interface/change-batch.js +17 -9
- package/dist/cjs/interface/change-formatting.d.ts.map +1 -1
- package/dist/cjs/interface/change-formatting.js +30 -11
- package/dist/cjs/interface/split-text.d.ts +2 -1
- package/dist/cjs/interface/split-text.d.ts.map +1 -1
- package/dist/cjs/interface/split-text.js +7 -3
- package/dist/cjs/interface/spreadsheet-interface.d.ts +1 -1
- package/dist/cjs/interface/spreadsheet-interface.d.ts.map +1 -1
- package/dist/cjs/interface/spreadsheet-interface.js +2 -2
- package/dist/cjs/sheet-cell.d.ts.map +1 -1
- package/dist/cjs/sheet-cell.js +37 -6
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/api/advanced-filter.d.ts +72 -0
- package/dist/esm/api/advanced-filter.d.ts.map +1 -0
- package/dist/esm/api/advanced-filter.js +0 -0
- package/dist/esm/api/datatype.d.ts.map +1 -1
- package/dist/esm/api/datatype.js +8 -1
- package/dist/esm/api/fill-series-spec.d.ts +35 -0
- package/dist/esm/api/fill-series-spec.d.ts.map +1 -0
- package/dist/esm/api/fill-series-spec.js +160 -0
- package/dist/esm/api/fill.d.ts +95 -3
- package/dist/esm/api/fill.d.ts.map +1 -1
- package/dist/esm/api/fill.js +1158 -418
- package/dist/esm/api/goal-seek.d.ts +29 -0
- package/dist/esm/api/goal-seek.d.ts.map +1 -0
- package/dist/esm/api/goal-seek.js +108 -0
- package/dist/esm/api/immer-utils.d.ts.map +1 -1
- package/dist/esm/api/immer-utils.js +21 -15
- package/dist/esm/api/intl.d.ts.map +1 -1
- package/dist/esm/api/paint-format.d.ts +55 -0
- package/dist/esm/api/paint-format.d.ts.map +1 -0
- package/dist/esm/api/paint-format.js +122 -0
- package/dist/esm/api/split-text.d.ts +12 -0
- package/dist/esm/api/split-text.d.ts.map +1 -1
- package/dist/esm/api/split-text.js +28 -0
- package/dist/esm/api/subtotal.d.ts +85 -0
- package/dist/esm/api/subtotal.d.ts.map +1 -0
- package/dist/esm/api/subtotal.js +181 -0
- package/dist/esm/commands.d.ts +19 -1
- package/dist/esm/commands.d.ts.map +1 -1
- package/dist/esm/components/editors/data-validation-rule-editor.d.ts.map +1 -1
- package/dist/esm/components/editors/data-validation-rule-editor.js +52 -4
- package/dist/esm/hooks/use-calculation-worker.d.ts.map +1 -1
- package/dist/esm/hooks/use-calculation-worker.js +1 -0
- package/dist/esm/hooks/use-calculation.d.ts +1 -1
- package/dist/esm/hooks/use-on-advanced-filter.d.ts +21 -0
- package/dist/esm/hooks/use-on-advanced-filter.d.ts.map +1 -0
- package/dist/esm/hooks/use-on-advanced-filter.js +41 -0
- package/dist/esm/hooks/use-on-fill.d.ts +13 -4
- package/dist/esm/hooks/use-on-fill.d.ts.map +1 -1
- package/dist/esm/hooks/use-on-fill.js +24 -4
- package/dist/esm/hooks/use-on-goal-seek.d.ts +30 -0
- package/dist/esm/hooks/use-on-goal-seek.d.ts.map +1 -0
- package/dist/esm/hooks/use-on-goal-seek.js +82 -0
- package/dist/esm/hooks/use-on-import-excel.d.ts +10 -1
- package/dist/esm/hooks/use-on-import-excel.d.ts.map +1 -1
- package/dist/esm/hooks/use-on-import-excel.js +8 -4
- package/dist/esm/hooks/use-on-merge-unmerge-cells.d.ts.map +1 -1
- package/dist/esm/hooks/use-on-merge-unmerge-cells.js +13 -13
- package/dist/esm/hooks/use-on-subtotal.d.ts +26 -0
- package/dist/esm/hooks/use-on-subtotal.d.ts.map +1 -0
- package/dist/esm/hooks/use-on-subtotal.js +48 -0
- package/dist/esm/hooks/use-paint-format.d.ts +19 -3
- package/dist/esm/hooks/use-paint-format.d.ts.map +1 -1
- package/dist/esm/hooks/use-paint-format.js +75 -30
- package/dist/esm/hooks/use-sheet-properties.d.ts +2 -0
- package/dist/esm/hooks/use-sheet-properties.d.ts.map +1 -1
- package/dist/esm/hooks/use-sheet-properties.js +4 -0
- package/dist/esm/hooks/use-split-text-to-columns.d.ts +2 -1
- package/dist/esm/hooks/use-split-text-to-columns.d.ts.map +1 -1
- package/dist/esm/hooks/use-split-text-to-columns.js +3 -2
- package/dist/esm/hooks/use-spreadsheet-state.d.ts +50 -36
- package/dist/esm/hooks/use-spreadsheet-state.d.ts.map +1 -1
- package/dist/esm/hooks/use-spreadsheet-state.js +100 -17
- package/dist/esm/index.d.ts +8 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +8 -0
- package/dist/esm/interface/apply-fill.d.ts +3 -3
- package/dist/esm/interface/apply-fill.d.ts.map +1 -1
- package/dist/esm/interface/apply-fill.js +93 -48
- package/dist/esm/interface/change-batch-stream.d.ts.map +1 -1
- package/dist/esm/interface/change-batch-stream.js +12 -6
- package/dist/esm/interface/change-batch.d.ts.map +1 -1
- package/dist/esm/interface/change-batch.js +15 -9
- package/dist/esm/interface/change-formatting.d.ts.map +1 -1
- package/dist/esm/interface/change-formatting.js +28 -11
- package/dist/esm/interface/split-text.d.ts +2 -1
- package/dist/esm/interface/split-text.d.ts.map +1 -1
- package/dist/esm/interface/split-text.js +6 -3
- package/dist/esm/interface/spreadsheet-interface.d.ts +1 -1
- package/dist/esm/interface/spreadsheet-interface.d.ts.map +1 -1
- package/dist/esm/interface/spreadsheet-interface.js +2 -2
- package/dist/esm/sheet-cell.d.ts.map +1 -1
- package/dist/esm/sheet-cell.js +37 -6
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +20 -20
package/dist/esm/api/fill.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
112
|
+
const [, prefix, run, suffix] = match;
|
|
22
113
|
if (!prefix && !suffix)
|
|
23
114
|
return null;
|
|
24
|
-
const parsed =
|
|
25
|
-
if (Number.
|
|
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
|
|
183
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
411
|
-
|
|
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
|
|
423
|
-
let
|
|
911
|
+
const lastSerial = dateToSerial(lastDate);
|
|
912
|
+
let step = unitStep;
|
|
424
913
|
if (previousDate) {
|
|
425
|
-
const diff =
|
|
426
|
-
if (diff
|
|
427
|
-
|
|
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
|
-
|
|
433
|
-
result.push(currentDate.toLocaleDateString(locale));
|
|
920
|
+
result.push(serialToDate(lastSerial + step * (i + 1)));
|
|
434
921
|
}
|
|
435
922
|
}
|
|
436
923
|
else {
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
currentDate.setDate(currentDate.getDate() +
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
|
645
|
-
const
|
|
646
|
-
const
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
1547
|
+
return { fillType: seriesFillType, values };
|
|
797
1548
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
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;
|