@dnax/ui 0.0.66 → 0.0.67
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/components/QSpreadsheet.vue +159 -20
- package/lib/formula.test.ts +8 -0
- package/lib/formula.ts +4 -2
- package/lib/spreadsheet.test.ts +110 -0
- package/lib/spreadsheet.ts +204 -5
- package/package.json +1 -1
- package/styles/main.css +65 -0
|
@@ -186,6 +186,10 @@ import {
|
|
|
186
186
|
ISO_DATE,
|
|
187
187
|
isoAddDays,
|
|
188
188
|
shiftFormulaRefs,
|
|
189
|
+
functionQueryAt,
|
|
190
|
+
rankSuggestions,
|
|
191
|
+
functionCallAt,
|
|
192
|
+
signatureArgs,
|
|
189
193
|
csvEscape,
|
|
190
194
|
deriveCols,
|
|
191
195
|
parseCsv,
|
|
@@ -349,6 +353,9 @@ const I18N = {
|
|
|
349
353
|
sheet: "Sheet",
|
|
350
354
|
fxPlaceholder: "Value or formula (=SUM(A1:A3)…)",
|
|
351
355
|
fxNoSel: "Select a cell…",
|
|
356
|
+
fxSyntax: "Syntax error",
|
|
357
|
+
fxEmpty: "empty",
|
|
358
|
+
fxColumn: "column",
|
|
352
359
|
cut: "Cut",
|
|
353
360
|
copy: "Copy",
|
|
354
361
|
paste: "Paste",
|
|
@@ -427,6 +434,9 @@ const I18N = {
|
|
|
427
434
|
sheet: "Feuille",
|
|
428
435
|
fxPlaceholder: "Valeur ou formule (=SOMME(A1:A3)…)",
|
|
429
436
|
fxNoSel: "Sélectionnez une cellule…",
|
|
437
|
+
fxSyntax: "Erreur de syntaxe",
|
|
438
|
+
fxEmpty: "vide",
|
|
439
|
+
fxColumn: "colonne",
|
|
430
440
|
cut: "Couper",
|
|
431
441
|
copy: "Copier",
|
|
432
442
|
paste: "Coller",
|
|
@@ -1069,11 +1079,13 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
|
|
|
1069
1079
|
const raw = String(text ?? "")
|
|
1070
1080
|
const t = raw.trim()
|
|
1071
1081
|
if (t === "") return null
|
|
1072
|
-
// Formule A1 : conservée brute dans la cellule (booléen/choix exclus)
|
|
1082
|
+
// Formule A1 : conservée brute dans la cellule (booléen/choix exclus) —
|
|
1083
|
+
// `t` (déjà trimé) évite qu'un `" =…"` ne soit traité comme une formule par
|
|
1084
|
+
// `isFormulaRaw` tout en échouant au parsing du moteur.
|
|
1073
1085
|
if (t.startsWith("=")) {
|
|
1074
1086
|
if (col?.type === "boolean" || col?.type === "select" || col?.type === "multiselect")
|
|
1075
1087
|
return old
|
|
1076
|
-
return
|
|
1088
|
+
return t
|
|
1077
1089
|
}
|
|
1078
1090
|
if (col?.type === "number") {
|
|
1079
1091
|
const n = Number(raw)
|
|
@@ -1924,6 +1936,41 @@ const fxCanEdit = computed(() => {
|
|
|
1924
1936
|
)
|
|
1925
1937
|
})
|
|
1926
1938
|
|
|
1939
|
+
/**
|
|
1940
|
+
* Aperçu **live** du résultat de la formule en cours de saisie dans la barre fx.
|
|
1941
|
+
* Évaluée dans le contexte de la ligne sélectionnée (références par nom de colonne
|
|
1942
|
+
* et A1 résolues sur la grille mémorisée). `null` si la saisie n'est pas une formule.
|
|
1943
|
+
*/
|
|
1944
|
+
const fxPreview = computed<{ text: string; error: boolean; hint: string } | null>(() => {
|
|
1945
|
+
// Pendant l'édition en cellule, la grille affiche déjà le résultat.
|
|
1946
|
+
if (editing.value || !sel.value || !fxCanEdit.value) return null
|
|
1947
|
+
const source = fxDraft.value
|
|
1948
|
+
if (!isFormulaRaw(source)) return null
|
|
1949
|
+
const row = sel.value.row
|
|
1950
|
+
const value = evaluateFormula(source, {
|
|
1951
|
+
row,
|
|
1952
|
+
resolveField: (name) => {
|
|
1953
|
+
const i = colIndex(name)
|
|
1954
|
+
return i === -1 ? undefined : evaluated.value[row]?.[i]
|
|
1955
|
+
},
|
|
1956
|
+
resolveCell: (r, c) => evaluated.value[r]?.[c],
|
|
1957
|
+
})
|
|
1958
|
+
if (isError(value)) {
|
|
1959
|
+
return {
|
|
1960
|
+
text: value.code,
|
|
1961
|
+
error: true,
|
|
1962
|
+
hint: value.code === "#ERROR!" ? t("fxSyntax") : value.code,
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
if (value === null || value === undefined || value === "") {
|
|
1966
|
+
return { text: t("fxEmpty"), error: false, hint: "" }
|
|
1967
|
+
}
|
|
1968
|
+
const text = typeof value === "number"
|
|
1969
|
+
? new Intl.NumberFormat(lang.value, { maximumFractionDigits: 6 }).format(value)
|
|
1970
|
+
: String(value)
|
|
1971
|
+
return { text: text.length > 60 ? text.slice(0, 59) + "…" : text, error: false, hint: "" }
|
|
1972
|
+
})
|
|
1973
|
+
|
|
1927
1974
|
const commitFx = async () => {
|
|
1928
1975
|
if (!sel.value) return
|
|
1929
1976
|
if (!fxCanEdit.value) {
|
|
@@ -1939,7 +1986,21 @@ const commitFx = async () => {
|
|
|
1939
1986
|
}
|
|
1940
1987
|
|
|
1941
1988
|
const onFxInput = (e: Event) => {
|
|
1942
|
-
|
|
1989
|
+
const el = e.target as HTMLInputElement
|
|
1990
|
+
fxDraft.value = el.value
|
|
1991
|
+
fxCaret.value = el.selectionStart
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/** Position du curseur depuis un événement (`null` si indisponible). */
|
|
1995
|
+
const caretFrom = (e: Event): number | null => {
|
|
1996
|
+
const el = e.target as HTMLInputElement | HTMLTextAreaElement | null
|
|
1997
|
+
return el && typeof el.selectionStart === "number" ? el.selectionStart : null
|
|
1998
|
+
}
|
|
1999
|
+
const syncFxCaret = (e: Event) => {
|
|
2000
|
+
fxCaret.value = caretFrom(e)
|
|
2001
|
+
}
|
|
2002
|
+
const syncEditCaret = (e: Event) => {
|
|
2003
|
+
editCaret.value = caretFrom(e)
|
|
1943
2004
|
}
|
|
1944
2005
|
|
|
1945
2006
|
const onFxKeydown = (e: KeyboardEvent) => {
|
|
@@ -2084,16 +2145,38 @@ const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
|
2084
2145
|
// La source alimente des méthodes de chaîne (`.trim()`, `.startsWith()`) :
|
|
2085
2146
|
// la valeur brute d'une cellule peut être un nombre / booléen → on coerce.
|
|
2086
2147
|
const fxSource = computed(() => String((editing.value ? draft.value : fxDraft.value) ?? ""))
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2148
|
+
/** Position du curseur (fx bar / éditeur de cellule) — pilote les suggestions. */
|
|
2149
|
+
const fxCaret = ref<number | null>(null)
|
|
2150
|
+
const editCaret = ref<number | null>(null)
|
|
2151
|
+
const fxQuery = computed(() =>
|
|
2152
|
+
functionQueryAt(
|
|
2153
|
+
fxSource.value,
|
|
2154
|
+
(editing.value ? editCaret.value : fxCaret.value) ?? undefined,
|
|
2155
|
+
),
|
|
2156
|
+
)
|
|
2093
2157
|
const fxSuggestions = computed(() => {
|
|
2094
2158
|
const q = fxQuery.value
|
|
2095
2159
|
if (!q) return []
|
|
2096
|
-
|
|
2160
|
+
const fns = rankSuggestions(FORMULA_FNS, q, 8).map((f) => ({
|
|
2161
|
+
kind: "fn" as const,
|
|
2162
|
+
name: f.name,
|
|
2163
|
+
detail: f.sig,
|
|
2164
|
+
insert: f.name + "(",
|
|
2165
|
+
}))
|
|
2166
|
+
// Colonnes de la feuille : les formules acceptent `=price * qty`
|
|
2167
|
+
const fields = rankSuggestions(
|
|
2168
|
+
cols.value.map((col) => ({
|
|
2169
|
+
name: col.name,
|
|
2170
|
+
detail:
|
|
2171
|
+
col.label !== undefined && String(col.label) !== col.name
|
|
2172
|
+
? String(col.label)
|
|
2173
|
+
: t("fxColumn"),
|
|
2174
|
+
insert: col.name,
|
|
2175
|
+
})),
|
|
2176
|
+
q,
|
|
2177
|
+
4,
|
|
2178
|
+
).map((f) => ({ kind: "field" as const, ...f }))
|
|
2179
|
+
return [...fns, ...fields]
|
|
2097
2180
|
})
|
|
2098
2181
|
const fxHide = ref(false)
|
|
2099
2182
|
const fxActive = ref(0)
|
|
@@ -2102,6 +2185,23 @@ watch(fxQuery, () => {
|
|
|
2102
2185
|
fxActive.value = 0
|
|
2103
2186
|
fxHide.value = false
|
|
2104
2187
|
})
|
|
2188
|
+
/** Signature de la fonction au curseur (argument courant surligné). */
|
|
2189
|
+
const fxHint = computed<{ name: string; args: string[]; active: number } | null>(() => {
|
|
2190
|
+
const source = fxSource.value
|
|
2191
|
+
if (!isFormulaRaw(source)) return null
|
|
2192
|
+
const caret = Math.min(
|
|
2193
|
+
(editing.value ? editCaret.value : fxCaret.value) ?? source.length,
|
|
2194
|
+
source.length,
|
|
2195
|
+
)
|
|
2196
|
+
const call = functionCallAt(source, caret, FORMULA_FNS.map((f) => f.name))
|
|
2197
|
+
if (!call) return null
|
|
2198
|
+
const fn = FORMULA_FNS.find((f) => f.name.toLowerCase() === call.name)
|
|
2199
|
+
if (!fn) return null
|
|
2200
|
+
const args = signatureArgs(fn.sig)
|
|
2201
|
+
if (!args.length) return null
|
|
2202
|
+
return { name: fn.name, args, active: Math.min(call.index, args.length - 1) }
|
|
2203
|
+
})
|
|
2204
|
+
|
|
2105
2205
|
const fxPopStyle = ref<Record<string, string>>({})
|
|
2106
2206
|
const refreshFxPop = () => {
|
|
2107
2207
|
if (!fxOpen.value || !fxInputEl.value) return
|
|
@@ -2123,9 +2223,18 @@ const stepFx = (d: number) => {
|
|
|
2123
2223
|
const acceptFx = () => {
|
|
2124
2224
|
const f = fxSuggestions.value[fxActive.value]
|
|
2125
2225
|
if (!f) return
|
|
2126
|
-
const
|
|
2127
|
-
|
|
2128
|
-
|
|
2226
|
+
const source = editing.value ? String(draft.value ?? "") : String(fxDraft.value ?? "")
|
|
2227
|
+
const caret = Math.min(
|
|
2228
|
+
(editing.value ? editCaret.value : fxCaret.value) ?? source.length,
|
|
2229
|
+
source.length,
|
|
2230
|
+
)
|
|
2231
|
+
const query = functionQueryAt(source, caret)
|
|
2232
|
+
if (!query) return
|
|
2233
|
+
const start = caret - query.length
|
|
2234
|
+
const inserted = f.insert
|
|
2235
|
+
const next = source.slice(0, start) + inserted + source.slice(caret)
|
|
2236
|
+
if (editing.value) draft.value = next
|
|
2237
|
+
else fxDraft.value = next
|
|
2129
2238
|
fxActive.value = 0
|
|
2130
2239
|
nextTick(() => {
|
|
2131
2240
|
const el = editing.value
|
|
@@ -2133,7 +2242,12 @@ const acceptFx = () => {
|
|
|
2133
2242
|
? editAreaEl.value
|
|
2134
2243
|
: editInputEl.value
|
|
2135
2244
|
: fxInputEl.value
|
|
2136
|
-
el
|
|
2245
|
+
if (!el) return
|
|
2246
|
+
el.focus({ preventScroll: true })
|
|
2247
|
+
const pos = start + inserted.length
|
|
2248
|
+
el.setSelectionRange(pos, pos)
|
|
2249
|
+
if (editing.value) editCaret.value = pos
|
|
2250
|
+
else fxCaret.value = pos
|
|
2137
2251
|
})
|
|
2138
2252
|
}
|
|
2139
2253
|
const acceptFxAt = (i: number) => {
|
|
@@ -2960,8 +3074,8 @@ const applyFill = () => {
|
|
|
2960
3074
|
const sr = b.r0 + (((rr - b.r0) % h) + h) % h
|
|
2961
3075
|
const sc = b.c0 + (((cc - b.c0) % w) + w) % w
|
|
2962
3076
|
let raw = state.value[sr]?.[colNameAt(sc)]
|
|
2963
|
-
if (typeof raw === "string" && raw.startsWith("=")) {
|
|
2964
|
-
raw = shiftFormulaRefs(raw, rr - sr, cc - sc)
|
|
3077
|
+
if (typeof raw === "string" && raw.trim().startsWith("=")) {
|
|
3078
|
+
raw = shiftFormulaRefs(raw.trim(), rr - sr, cc - sc)
|
|
2965
3079
|
}
|
|
2966
3080
|
val = raw
|
|
2967
3081
|
}
|
|
@@ -4572,9 +4686,23 @@ defineExpose({
|
|
|
4572
4686
|
:placeholder="fxCanEdit ? t('fxPlaceholder') : t('fxNoSel')"
|
|
4573
4687
|
spellcheck="false"
|
|
4574
4688
|
@input="onFxInput"
|
|
4689
|
+
@keyup="syncFxCaret"
|
|
4690
|
+
@click="syncFxCaret"
|
|
4575
4691
|
@keydown="onFxKeydown"
|
|
4576
4692
|
@blur="commitFx"
|
|
4577
4693
|
/>
|
|
4694
|
+
<span v-if="fxHint && !fxOpen && !editing" class="q-spreadsheet__fxhint">
|
|
4695
|
+
<strong>{{ fxHint.name }}</strong>(<template v-for="(arg, i) in fxHint.args" :key="i"><span
|
|
4696
|
+
class="q-spreadsheet__fxhint-arg"
|
|
4697
|
+
:class="{ 'q-spreadsheet__fxhint-arg--on': i === fxHint.active }"
|
|
4698
|
+
>{{ arg }}</span><span v-if="i < fxHint.args.length - 1">, </span></template>)
|
|
4699
|
+
</span>
|
|
4700
|
+
<span
|
|
4701
|
+
v-if="fxPreview"
|
|
4702
|
+
class="q-spreadsheet__fx-preview"
|
|
4703
|
+
:class="{ 'q-spreadsheet__fx-preview--error': fxPreview.error }"
|
|
4704
|
+
:title="fxPreview.hint"
|
|
4705
|
+
><span class="q-spreadsheet__fx-preview-eq" aria-hidden="true">=</span>{{ fxPreview.text }}</span>
|
|
4578
4706
|
</div>
|
|
4579
4707
|
|
|
4580
4708
|
<!-- ═══════ Barre Find & Replace ═══════ -->
|
|
@@ -4954,6 +5082,9 @@ defineExpose({
|
|
|
4954
5082
|
class="q-spreadsheet__editor-input q-spreadsheet__editor-area"
|
|
4955
5083
|
:rows="draftLines"
|
|
4956
5084
|
spellcheck="false"
|
|
5085
|
+
@input="syncEditCaret"
|
|
5086
|
+
@keyup="syncEditCaret"
|
|
5087
|
+
@click="syncEditCaret"
|
|
4957
5088
|
@keydown="onEditKeydown"
|
|
4958
5089
|
@blur="commitEdit"
|
|
4959
5090
|
/>
|
|
@@ -4968,6 +5099,12 @@ defineExpose({
|
|
|
4968
5099
|
@keydown="onEditKeydown"
|
|
4969
5100
|
@blur="commitEdit"
|
|
4970
5101
|
/>
|
|
5102
|
+
<div v-if="editingIsFormula && fxHint && !fxOpen" class="q-spreadsheet__fxhint q-spreadsheet__fxhint--editor">
|
|
5103
|
+
<strong>{{ fxHint.name }}</strong>(<template v-for="(arg, i) in fxHint.args" :key="i"><span
|
|
5104
|
+
class="q-spreadsheet__fxhint-arg"
|
|
5105
|
+
:class="{ 'q-spreadsheet__fxhint-arg--on': i === fxHint.active }"
|
|
5106
|
+
>{{ arg }}</span><span v-if="i < fxHint.args.length - 1">, </span></template>)
|
|
5107
|
+
</div>
|
|
4971
5108
|
<div v-if="editingIsChoice && selectOptions.length" class="q-spreadsheet__options">
|
|
4972
5109
|
<button
|
|
4973
5110
|
v-for="opt in selectOptions"
|
|
@@ -4992,7 +5129,7 @@ defineExpose({
|
|
|
4992
5129
|
<div v-if="editingIsFormula && fxOpen" class="q-spreadsheet__fnsug">
|
|
4993
5130
|
<button
|
|
4994
5131
|
v-for="(f, fi) in fxSuggestions"
|
|
4995
|
-
:key="f.name"
|
|
5132
|
+
:key="`${f.kind}:${f.name}`"
|
|
4996
5133
|
type="button"
|
|
4997
5134
|
class="q-spreadsheet__fnsug-item"
|
|
4998
5135
|
:class="{ 'q-spreadsheet__fnsug-item--on': fxActive === fi }"
|
|
@@ -5000,7 +5137,8 @@ defineExpose({
|
|
|
5000
5137
|
@click="acceptFxAt(fi)"
|
|
5001
5138
|
>
|
|
5002
5139
|
<code>{{ f.name }}</code>
|
|
5003
|
-
<span>({{ f.
|
|
5140
|
+
<span v-if="f.kind === 'fn'">({{ f.detail }})</span>
|
|
5141
|
+
<span v-else class="q-spreadsheet__fnsug-kind">{{ f.detail }}</span>
|
|
5004
5142
|
</button>
|
|
5005
5143
|
</div>
|
|
5006
5144
|
</div>
|
|
@@ -5297,7 +5435,7 @@ defineExpose({
|
|
|
5297
5435
|
>
|
|
5298
5436
|
<button
|
|
5299
5437
|
v-for="(f, fi) in fxSuggestions"
|
|
5300
|
-
:key="f.name"
|
|
5438
|
+
:key="`${f.kind}:${f.name}`"
|
|
5301
5439
|
type="button"
|
|
5302
5440
|
class="q-spreadsheet__fnsug-item"
|
|
5303
5441
|
:class="{ 'q-spreadsheet__fnsug-item--on': fxActive === fi }"
|
|
@@ -5305,7 +5443,8 @@ defineExpose({
|
|
|
5305
5443
|
@click="acceptFxAt(fi)"
|
|
5306
5444
|
>
|
|
5307
5445
|
<code>{{ f.name }}</code>
|
|
5308
|
-
<span>({{ f.
|
|
5446
|
+
<span v-if="f.kind === 'fn'">({{ f.detail }})</span>
|
|
5447
|
+
<span v-else class="q-spreadsheet__fnsug-kind">{{ f.detail }}</span>
|
|
5309
5448
|
</button>
|
|
5310
5449
|
</div>
|
|
5311
5450
|
</Teleport>
|
package/lib/formula.test.ts
CHANGED
|
@@ -184,6 +184,14 @@ describe("fonctions dates", () => {
|
|
|
184
184
|
})
|
|
185
185
|
})
|
|
186
186
|
|
|
187
|
+
describe("espaces autour de la formule", () => {
|
|
188
|
+
const e = harness([[]])
|
|
189
|
+
it("accepte des espaces avant le `=` (isFormulaRaw les tolère)", () => {
|
|
190
|
+
expect(e(" =1+2")).toBe(3)
|
|
191
|
+
expect(e(" =SUM(1,2) ")).toBe(3)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
187
195
|
describe("math & erreurs de type", () => {
|
|
188
196
|
const e = harness([[]])
|
|
189
197
|
it("ABS / ROUND / INT / MOD / SQRT / POWER", () => {
|
package/lib/formula.ts
CHANGED
|
@@ -1349,9 +1349,11 @@ export const FORMULA_FUNCTION_NAMES: string[] = [
|
|
|
1349
1349
|
]),
|
|
1350
1350
|
].sort()
|
|
1351
1351
|
|
|
1352
|
-
/** Évalue une formule ("=…" optionnel). Retourne une valeur ou une FormulaError. */
|
|
1352
|
+
/** Évalue une formule ("=…" optionnel, espaces ignorés). Retourne une valeur ou une FormulaError. */
|
|
1353
1353
|
export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
|
|
1354
|
-
|
|
1354
|
+
// `isFormulaRaw` accepte des espaces avant le `=` : le moteur doit en faire autant.
|
|
1355
|
+
const trimmed = source.trim()
|
|
1356
|
+
const src = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed
|
|
1355
1357
|
try {
|
|
1356
1358
|
const parser = new Parser(src)
|
|
1357
1359
|
const node = parser.parseExpr()
|
package/lib/spreadsheet.test.ts
CHANGED
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
isBlankValue,
|
|
7
7
|
isoAddDays,
|
|
8
8
|
shiftFormulaRefs,
|
|
9
|
+
functionQueryAt,
|
|
10
|
+
rankSuggestions,
|
|
11
|
+
functionCallAt,
|
|
12
|
+
signatureArgs,
|
|
9
13
|
csvEscape,
|
|
10
14
|
csvSplitLine,
|
|
11
15
|
parseCsv,
|
|
@@ -44,6 +48,112 @@ describe("shiftFormulaRefs (autofill)", () => {
|
|
|
44
48
|
it("laisse les valeurs sans lettres intactes", () => {
|
|
45
49
|
expect(shiftFormulaRefs("=1+2", 1, 1)).toBe("=1+2")
|
|
46
50
|
})
|
|
51
|
+
it("ne touche pas aux littéraux de chaîne", () => {
|
|
52
|
+
expect(shiftFormulaRefs('=IF(A1>0,"A1","x")', 1, 0)).toBe('=IF(A2>0,"A1","x")')
|
|
53
|
+
expect(shiftFormulaRefs("=CONCAT('B2',A1)", 0, 1)).toBe("=CONCAT('B2',B1)")
|
|
54
|
+
})
|
|
55
|
+
it("ne confond pas un identifiant avec une référence", () => {
|
|
56
|
+
expect(shiftFormulaRefs("=LOG10(A1)", 1, 0)).toBe("=LOG10(A2)")
|
|
57
|
+
expect(shiftFormulaRefs("=price1+A1", 1, 0)).toBe("=price1+A2")
|
|
58
|
+
expect(shiftFormulaRefs("=A1:price2", 1, 0)).toBe("=A2:price2")
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe("functionQueryAt (autocomplétion fx)", () => {
|
|
63
|
+
it("rend l'identifiant en cours de frappe, en minuscules", () => {
|
|
64
|
+
expect(functionQueryAt("=SU")).toBe("su")
|
|
65
|
+
expect(functionQueryAt("=sum(")).toBe("")
|
|
66
|
+
expect(functionQueryAt("=SUM(A1)+IF")).toBe("if")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("ne suggère rien hors d'une formule ou dans une chaîne", () => {
|
|
70
|
+
expect(functionQueryAt("SUM")).toBe("")
|
|
71
|
+
expect(functionQueryAt('="hello wor')).toBe("")
|
|
72
|
+
expect(functionQueryAt("='hello wor")).toBe("")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("ne suggère pas au milieu d'une référence ou d'une plage", () => {
|
|
76
|
+
expect(functionQueryAt("=A1", 2)).toBe("")
|
|
77
|
+
expect(functionQueryAt("=A1:B")).toBe("")
|
|
78
|
+
expect(functionQueryAt("=$AB")).toBe("")
|
|
79
|
+
expect(functionQueryAt("=A1+B")).toBe("b")
|
|
80
|
+
expect(functionQueryAt("=SUM(A")).toBe("a")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("suit le curseur, pas seulement la fin du texte", () => {
|
|
84
|
+
expect(functionQueryAt("=SU+1", 3)).toBe("su")
|
|
85
|
+
expect(functionQueryAt("=SU+1", 5)).toBe("")
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("rankSuggestions (autocomplétion)", () => {
|
|
90
|
+
const items = [
|
|
91
|
+
{ name: "SUM" },
|
|
92
|
+
{ name: "SUMIF" },
|
|
93
|
+
{ name: "SUMPRODUCT" },
|
|
94
|
+
{ name: "MID" },
|
|
95
|
+
{ name: "MEDIAN" },
|
|
96
|
+
{ name: "IF" },
|
|
97
|
+
{ name: "IFS" },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
it("classe égalité > préfixe, puis nom le plus court", () => {
|
|
101
|
+
expect(rankSuggestions(items, "sum").map((i) => i.name)).toEqual([
|
|
102
|
+
"SUM",
|
|
103
|
+
"SUMIF",
|
|
104
|
+
"SUMPRODUCT",
|
|
105
|
+
])
|
|
106
|
+
expect(rankSuggestions(items, "if").map((i) => i.name)).toEqual(["IF", "IFS", "SUMIF"])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("trouve une sous-chaîne", () => {
|
|
110
|
+
expect(rankSuggestions(items, "edian").map((i) => i.name)).toEqual(["MEDIAN"])
|
|
111
|
+
expect(rankSuggestions(items, "ia").map((i) => i.name)).toEqual(["MEDIAN"])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("trouve une sous-séquence (fuzzy)", () => {
|
|
115
|
+
expect(rankSuggestions(items, "sm").map((i) => i.name)).toEqual([
|
|
116
|
+
"SUM",
|
|
117
|
+
"SUMIF",
|
|
118
|
+
"SUMPRODUCT",
|
|
119
|
+
])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("respecte la limite et ignore une requête vide", () => {
|
|
123
|
+
expect(rankSuggestions(items, "s", 1)).toHaveLength(1)
|
|
124
|
+
expect(rankSuggestions(items, "")).toEqual([])
|
|
125
|
+
expect(rankSuggestions(items, "zzz")).toEqual([])
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe("functionCallAt / signatureArgs (aide à l'argument)", () => {
|
|
130
|
+
const known = ["SUM", "IF", "MAX"]
|
|
131
|
+
|
|
132
|
+
it("détecte la fonction et l'index de l'argument courant", () => {
|
|
133
|
+
expect(functionCallAt("=SUM(", 5, known)).toEqual({ name: "sum", index: 0 })
|
|
134
|
+
expect(functionCallAt("=SUM(A1,", 8, known)).toEqual({ name: "sum", index: 1 })
|
|
135
|
+
expect(functionCallAt("=IF(A1>0,", 9, known)).toEqual({ name: "if", index: 1 })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("prend la fonction la plus interne", () => {
|
|
139
|
+
expect(functionCallAt("=SUM(IF(A1>0,", 13, known)).toEqual({ name: "if", index: 1 })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("ignore les virgules des chaînes et les appels fermés", () => {
|
|
143
|
+
expect(functionCallAt('=IF(A1,"x,y",', 7, known)).toEqual({ name: "if", index: 1 })
|
|
144
|
+
expect(functionCallAt('=IF(A1,"x,y",', 13, known)).toEqual({ name: "if", index: 2 })
|
|
145
|
+
expect(functionCallAt("=A1+B1", 6, known)).toBeNull()
|
|
146
|
+
expect(functionCallAt("=SUM(A1)", 9, known)).toBeNull()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("signatureArgs découpe les paramètres", () => {
|
|
150
|
+
expect(signatureArgs("range, criteria, [sum_range]")).toEqual([
|
|
151
|
+
"range",
|
|
152
|
+
"criteria",
|
|
153
|
+
"[sum_range]",
|
|
154
|
+
])
|
|
155
|
+
expect(signatureArgs("")).toEqual([])
|
|
156
|
+
})
|
|
47
157
|
})
|
|
48
158
|
|
|
49
159
|
describe("dates ISO", () => {
|
package/lib/spreadsheet.ts
CHANGED
|
@@ -26,14 +26,213 @@ export const isBlankValue = (v: any): boolean =>
|
|
|
26
26
|
/**
|
|
27
27
|
* Décale les références A1 non absolues d'une formule de (dr, dc) —
|
|
28
28
|
* plages incluses, `$` préservés (copie / autofill).
|
|
29
|
+
*
|
|
30
|
+
* Les **littéraux de chaîne** sont laissés intacts (`"A1"` reste `"A1"`) et les
|
|
31
|
+
* **identifiants** contenant des chiffres ne sont pas pris pour des références
|
|
32
|
+
* (`LOG10(...)`, `price1`…).
|
|
29
33
|
*/
|
|
30
34
|
export const shiftFormulaRefs = (src: string, dr: number, dc: number): string =>
|
|
31
|
-
src
|
|
35
|
+
splitFormulaSegments(src)
|
|
36
|
+
.map((segment) => (segment.code ? shiftCodeRefs(segment.text, dr, dc) : segment.text))
|
|
37
|
+
.join("")
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Requête de suggestion de **fonction** à la position du curseur.
|
|
41
|
+
*
|
|
42
|
+
* Ne suggère que si le curseur est en train de taper un identifiant de fonction :
|
|
43
|
+
* hors littéral de chaîne, commençant un token (`=`, `(`, `,`, opérateur, espace…)
|
|
44
|
+
* et **pas** au milieu d'une référence de cellule (`A` devant `1`). Retourne `""`
|
|
45
|
+
* quand il n'y a rien à suggérer (ex. `=SUM(`).
|
|
46
|
+
*/
|
|
47
|
+
export function functionQueryAt(text: string, caret: number = text.length): string {
|
|
48
|
+
if (!text.trim().startsWith("=")) return ""
|
|
49
|
+
const end = Math.max(0, Math.min(caret, text.length))
|
|
50
|
+
|
|
51
|
+
// Curseur dans une chaîne littérale → aucune suggestion
|
|
52
|
+
let quote = ""
|
|
53
|
+
for (let i = 0; i < end; i++) {
|
|
54
|
+
const ch = text[i]!
|
|
55
|
+
if (quote) {
|
|
56
|
+
if (ch === quote) {
|
|
57
|
+
if (text[i + 1] === quote) {
|
|
58
|
+
i++
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
quote = ""
|
|
62
|
+
}
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (ch === '"' || ch === "'") quote = ch
|
|
66
|
+
}
|
|
67
|
+
if (quote) return ""
|
|
68
|
+
|
|
69
|
+
let start = end
|
|
70
|
+
while (start > 0 && /[A-Za-z]/.test(text[start - 1]!)) start--
|
|
71
|
+
if (start === end) return ""
|
|
72
|
+
// « A » devant « 1 » : référence de cellule en cours de frappe, pas une fonction
|
|
73
|
+
if (/[0-9]/.test(text[end] ?? "")) return ""
|
|
74
|
+
// Doit commencer un token (évite `A1:B`, `$AB`, `sheet!SUM`…)
|
|
75
|
+
const before = start > 0 ? text[start - 1]! : ""
|
|
76
|
+
if (before && !/[=(,;\s+\-*/^%&<>]/.test(before)) return ""
|
|
77
|
+
return text.slice(start, end).toLowerCase()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Pose d'une sous-séquence : les lettres de `query` apparaissent dans l'ordre. */
|
|
81
|
+
const isSubsequence = (query: string, name: string): boolean => {
|
|
82
|
+
let i = 0
|
|
83
|
+
for (const ch of name) {
|
|
84
|
+
if (ch === query[i]) i++
|
|
85
|
+
if (i === query.length) return true
|
|
86
|
+
}
|
|
87
|
+
return query.length === 0
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Classe des suggestions par pertinence :
|
|
92
|
+
* égalité > préfixe > sous-chaîne > sous-séquence, puis nom le plus court,
|
|
93
|
+
* puis ordre d'origine. `query` vide → aucun résultat.
|
|
94
|
+
*/
|
|
95
|
+
export function rankSuggestions<T extends { name: string }>(
|
|
96
|
+
items: T[],
|
|
97
|
+
query: string,
|
|
98
|
+
limit = 8,
|
|
99
|
+
): T[] {
|
|
100
|
+
const q = query.trim().toLowerCase()
|
|
101
|
+
if (!q) return []
|
|
102
|
+
return items
|
|
103
|
+
.map((item, index) => {
|
|
104
|
+
const name = item.name.toLowerCase()
|
|
105
|
+
const score = name === q
|
|
106
|
+
? 0
|
|
107
|
+
: name.startsWith(q)
|
|
108
|
+
? 1
|
|
109
|
+
: name.includes(q)
|
|
110
|
+
? 2
|
|
111
|
+
: isSubsequence(q, name)
|
|
112
|
+
? 3
|
|
113
|
+
: -1
|
|
114
|
+
return { item, index, score, length: name.length }
|
|
115
|
+
})
|
|
116
|
+
.filter((entry) => entry.score >= 0)
|
|
117
|
+
.sort((a, b) => a.score - b.score || a.length - b.length || a.index - b.index)
|
|
118
|
+
.slice(0, limit)
|
|
119
|
+
.map((entry) => entry.item)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Arguments d'une signature (`"range, criteria, [sum_range]"` → 3 entrées). */
|
|
123
|
+
export function signatureArgs(sig: string): string[] {
|
|
124
|
+
return sig
|
|
125
|
+
.split(",")
|
|
126
|
+
.map((part) => part.trim())
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface FunctionCallAtResult {
|
|
131
|
+
/** Nom de la fonction (minuscules) */
|
|
132
|
+
name: string
|
|
133
|
+
/** Index de l'argument courant (0-based) */
|
|
134
|
+
index: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fonction (parmi `known`) dans les parenthèses de laquelle se trouve le curseur.
|
|
139
|
+
* Gère l'imbrication, les littéraux et les virgules de chaque niveau.
|
|
140
|
+
*/
|
|
141
|
+
export function functionCallAt(
|
|
142
|
+
text: string,
|
|
143
|
+
caret: number,
|
|
144
|
+
known: Iterable<string>,
|
|
145
|
+
): FunctionCallAtResult | null {
|
|
146
|
+
const names = new Set([...known].map((name) => name.toLowerCase()))
|
|
147
|
+
const end = Math.max(0, Math.min(caret, text.length))
|
|
148
|
+
const stack: { name: string; index: number }[] = []
|
|
149
|
+
let quote = ""
|
|
150
|
+
for (let i = 0; i < end; i++) {
|
|
151
|
+
const ch = text[i]!
|
|
152
|
+
if (quote) {
|
|
153
|
+
if (ch === quote) {
|
|
154
|
+
if (text[i + 1] === quote) {
|
|
155
|
+
i++
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
quote = ""
|
|
159
|
+
}
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (ch === '"' || ch === "'") {
|
|
163
|
+
quote = ch
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (ch === "(") {
|
|
167
|
+
let j = i - 1
|
|
168
|
+
while (j >= 0 && /[A-Za-z0-9_]/.test(text[j]!)) j--
|
|
169
|
+
stack.push({ name: text.slice(j + 1, i).toLowerCase(), index: 0 })
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if (ch === ")") {
|
|
173
|
+
stack.pop()
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (ch === "," && stack.length) stack[stack.length - 1]!.index++
|
|
177
|
+
}
|
|
178
|
+
for (let k = stack.length - 1; k >= 0; k--) {
|
|
179
|
+
const frame = stack[k]!
|
|
180
|
+
if (names.has(frame.name)) return { name: frame.name, index: frame.index }
|
|
181
|
+
}
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Segments d'une formule : `code: false` = littéral de chaîne (guillemets simples/doubles). */
|
|
186
|
+
const splitFormulaSegments = (src: string): { code: boolean; text: string }[] => {
|
|
187
|
+
const out: { code: boolean; text: string }[] = []
|
|
188
|
+
let code = ""
|
|
189
|
+
let i = 0
|
|
190
|
+
while (i < src.length) {
|
|
191
|
+
const ch = src[i]!
|
|
192
|
+
if (ch !== '"' && ch !== "'") {
|
|
193
|
+
code += ch
|
|
194
|
+
i++
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
if (code) {
|
|
198
|
+
out.push({ code: true, text: code })
|
|
199
|
+
code = ""
|
|
200
|
+
}
|
|
201
|
+
let literal = ch
|
|
202
|
+
i++
|
|
203
|
+
while (i < src.length) {
|
|
204
|
+
const c = src[i]!
|
|
205
|
+
literal += c
|
|
206
|
+
i++
|
|
207
|
+
if (c !== ch) continue
|
|
208
|
+
// Guillemet doublé = caractère littéral (cf. parseString du moteur)
|
|
209
|
+
if (src[i] === ch) {
|
|
210
|
+
literal += src[i]
|
|
211
|
+
i++
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
break
|
|
215
|
+
}
|
|
216
|
+
out.push({ code: false, text: literal })
|
|
217
|
+
}
|
|
218
|
+
if (code) out.push({ code: true, text: code })
|
|
219
|
+
return out
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Décalage des références dans un segment de code (jamais dans une chaîne). */
|
|
223
|
+
const shiftCodeRefs = (code: string, dr: number, dc: number): string =>
|
|
224
|
+
code.replace(
|
|
32
225
|
/(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g,
|
|
33
|
-
(
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
226
|
+
(match: string, ca: string, letters: string, ra: string, digits: string, offset: number) => {
|
|
227
|
+
// Borne gauche : la ref doit commencer un token (pas « LOG10 », « price1 »…)
|
|
228
|
+
const before = offset > 0 ? code[offset - 1]! : ""
|
|
229
|
+
if (before && /[A-Za-z0-9_.]/.test(before)) return match
|
|
230
|
+
// Borne droite : pas de suite alphanumérique, pas un appel de fonction
|
|
231
|
+
const after = code[offset + match.length] ?? ""
|
|
232
|
+
if (after && /[A-Za-z0-9_(]/.test(after)) return match
|
|
233
|
+
const col = colFromLetters(letters) + (ca === "$" ? 0 : dc)
|
|
234
|
+
const row = Number(digits) - 1 + (ra === "$" ? 0 : dr)
|
|
235
|
+
if (col < 0 || row < 0) return match
|
|
37
236
|
return ca + colLetter(col) + ra + (row + 1)
|
|
38
237
|
},
|
|
39
238
|
)
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -9934,6 +9934,67 @@ a.q-breadcrumbs__el:hover {
|
|
|
9934
9934
|
opacity: 0.55;
|
|
9935
9935
|
}
|
|
9936
9936
|
|
|
9937
|
+
/* Aperçu live du résultat (barre fx) — discret, monospace, rouge si erreur */
|
|
9938
|
+
.q-spreadsheet__fx-preview {
|
|
9939
|
+
flex: 0 1 auto;
|
|
9940
|
+
min-width: 0;
|
|
9941
|
+
max-width: 40%;
|
|
9942
|
+
overflow: hidden;
|
|
9943
|
+
text-overflow: ellipsis;
|
|
9944
|
+
white-space: nowrap;
|
|
9945
|
+
padding-left: 8px;
|
|
9946
|
+
border-left: 1px solid var(--q-spreadsheet-border);
|
|
9947
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
9948
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
9949
|
+
font-size: 12px;
|
|
9950
|
+
}
|
|
9951
|
+
.q-spreadsheet__fx-preview-eq {
|
|
9952
|
+
margin-right: 4px;
|
|
9953
|
+
opacity: 0.6;
|
|
9954
|
+
}
|
|
9955
|
+
.q-spreadsheet__fx-preview--error {
|
|
9956
|
+
color: var(--negative, #c10015);
|
|
9957
|
+
font-weight: 600;
|
|
9958
|
+
}
|
|
9959
|
+
.dark .q-spreadsheet__fx-preview,
|
|
9960
|
+
.q-spreadsheet--dark .q-spreadsheet__fx-preview {
|
|
9961
|
+
color: rgb(255 255 255 / 0.6);
|
|
9962
|
+
}
|
|
9963
|
+
|
|
9964
|
+
/* Aide à l'argument : signature de la fonction au curseur */
|
|
9965
|
+
.q-spreadsheet__fxhint {
|
|
9966
|
+
flex: 0 1 auto;
|
|
9967
|
+
min-width: 0;
|
|
9968
|
+
overflow: hidden;
|
|
9969
|
+
text-overflow: ellipsis;
|
|
9970
|
+
white-space: nowrap;
|
|
9971
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
9972
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
9973
|
+
font-size: 12px;
|
|
9974
|
+
}
|
|
9975
|
+
.q-spreadsheet__fxhint strong {
|
|
9976
|
+
color: var(--foreground, #1d1d1d);
|
|
9977
|
+
}
|
|
9978
|
+
.q-spreadsheet__fxhint-arg--on {
|
|
9979
|
+
color: var(--primary, #1976d2);
|
|
9980
|
+
font-weight: 700;
|
|
9981
|
+
}
|
|
9982
|
+
/* Dans l'éditeur de cellule : sous le textarea, séparateur discret */
|
|
9983
|
+
.q-spreadsheet__fxhint--editor {
|
|
9984
|
+
flex: 0 0 auto;
|
|
9985
|
+
padding: 3px 8px 5px;
|
|
9986
|
+
border-top: 1px dashed var(--q-spreadsheet-border);
|
|
9987
|
+
white-space: normal;
|
|
9988
|
+
}
|
|
9989
|
+
.dark .q-spreadsheet__fxhint,
|
|
9990
|
+
.q-spreadsheet--dark .q-spreadsheet__fxhint {
|
|
9991
|
+
color: rgb(255 255 255 / 0.6);
|
|
9992
|
+
}
|
|
9993
|
+
.dark .q-spreadsheet__fxhint strong,
|
|
9994
|
+
.q-spreadsheet--dark .q-spreadsheet__fxhint strong {
|
|
9995
|
+
color: #fff;
|
|
9996
|
+
}
|
|
9997
|
+
|
|
9937
9998
|
/* ─── Éditeur formule (textarea monospace) ─── */
|
|
9938
9999
|
.q-spreadsheet__editor--formula {
|
|
9939
10000
|
align-items: stretch;
|
|
@@ -10324,6 +10385,10 @@ a.q-breadcrumbs__el:hover {
|
|
|
10324
10385
|
color: #8b93a1;
|
|
10325
10386
|
font-size: 11.5px;
|
|
10326
10387
|
}
|
|
10388
|
+
.q-spreadsheet__fnsug-kind {
|
|
10389
|
+
font-style: italic;
|
|
10390
|
+
opacity: 0.85;
|
|
10391
|
+
}
|
|
10327
10392
|
.q-spreadsheet__fnsug-item:hover,
|
|
10328
10393
|
.q-spreadsheet__fnsug-item--on {
|
|
10329
10394
|
background: rgb(25 118 210 / 0.1);
|