@dnax/ui 0.0.56 → 0.0.59
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 +215 -33
- package/lib/spreadsheetRows.test.ts +53 -0
- package/lib/spreadsheetRows.ts +34 -0
- package/lib/spreadsheetZones.test.ts +239 -0
- package/lib/spreadsheetZones.ts +253 -0
- package/package.json +1 -1
- package/styles/main.css +20 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Suivi des modifications : `v-model:dirty` (état) + `v-model:changes` (delta lignes / feuilles
|
|
9
9
|
// ajoutées, modifiées, supprimées), calculé en comparant le document à une **référence**
|
|
10
10
|
// (`lib/spreadsheetChanges.ts`).
|
|
11
|
+
import type { QSpreadsheetFill, QSpreadsheetLock } from "../lib/spreadsheetZones"
|
|
11
12
|
import type {
|
|
12
13
|
QSpreadsheetCellOption,
|
|
13
14
|
QSpreadsheetOptionAccessor,
|
|
@@ -17,6 +18,13 @@ import type {
|
|
|
17
18
|
QSpreadsheetValidationRule,
|
|
18
19
|
} from "../lib/spreadsheetValidation"
|
|
19
20
|
|
|
21
|
+
export type {
|
|
22
|
+
QSpreadsheetFill,
|
|
23
|
+
QSpreadsheetFillContext,
|
|
24
|
+
QSpreadsheetFillFormula,
|
|
25
|
+
QSpreadsheetLock,
|
|
26
|
+
QSpreadsheetLockPoint,
|
|
27
|
+
} from "../lib/spreadsheetZones"
|
|
20
28
|
export type {
|
|
21
29
|
QSpreadsheetCellOption,
|
|
22
30
|
QSpreadsheetOptionAccessor,
|
|
@@ -84,6 +92,14 @@ export interface QSpreadsheetColumn {
|
|
|
84
92
|
cellClass?: (val: any, row: Record<string, any>) => string | undefined
|
|
85
93
|
/** Couleur de fond de la cellule (plan) */
|
|
86
94
|
cellBackground?: (val: any, row: Record<string, any>) => string | undefined
|
|
95
|
+
/**
|
|
96
|
+
* Cellule **non modifiable** : édition, effacement, bascule booléenne, collage, remplissage et
|
|
97
|
+
* barre de formule sont refusés pour les lignes où le prédicat est vrai. Reçoit la valeur
|
|
98
|
+
* courante, la ligne et son **index** — donc verrouille aussi une cellule **vide**
|
|
99
|
+
* (`(v) => v !== null`, `(_, row) => row.locked`, `(_, __, i) => i === 0`…).
|
|
100
|
+
* Pour désigner un bloc par ses coordonnées, préférer `lockedRanges`.
|
|
101
|
+
*/
|
|
102
|
+
cellReadonly?: (val: any, row: Record<string, any>, rowIndex: number) => boolean
|
|
87
103
|
headerClass?: string
|
|
88
104
|
headerStyle?: string
|
|
89
105
|
}
|
|
@@ -92,6 +108,10 @@ export interface QSpreadsheetColumn {
|
|
|
92
108
|
// `lib/spreadsheetValidation.ts` (source unique, testée hors navigateur) et sont
|
|
93
109
|
// ré-exportées en tête de ce bloc.
|
|
94
110
|
|
|
111
|
+
// Les verrous (`QSpreadsheetLock`) et le préremplissage (`QSpreadsheetFill`) vivent dans
|
|
112
|
+
// `lib/spreadsheetZones.ts` — forme **lisible** (noms de colonnes) traduite en rectangles
|
|
113
|
+
// d'index par des fonctions pures, testées hors navigateur.
|
|
114
|
+
|
|
95
115
|
/** Validation par plage de cellules (rows/cols 0-based) */
|
|
96
116
|
export interface QSpreadsheetRangeValidator {
|
|
97
117
|
r0: number
|
|
@@ -172,6 +192,8 @@ import {
|
|
|
172
192
|
} from "../lib/spreadsheet"
|
|
173
193
|
import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
|
|
174
194
|
import { normalizeCellOptions } from "../lib/spreadsheetOptions"
|
|
195
|
+
import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
|
|
196
|
+
import { canAddRows, canRemoveRows, clampRows } from "../lib/spreadsheetRows"
|
|
175
197
|
import {
|
|
176
198
|
DEFAULT_ROW_KEY,
|
|
177
199
|
diffDocuments,
|
|
@@ -207,6 +229,63 @@ interface Props {
|
|
|
207
229
|
lang?: "en" | "fr"
|
|
208
230
|
/** Validations par plage : [{ r0,c0,r1,c1, validation }] — s'ajoutent aux colonnes */
|
|
209
231
|
validators?: QSpreadsheetRangeValidator[]
|
|
232
|
+
/**
|
|
233
|
+
* Cellules **verrouillées**, écrites avec des **noms** (jamais d'index) :
|
|
234
|
+
* - `{ row: 2, column: "total" }` → la cellule « total » de la ligne 2 ;
|
|
235
|
+
* - `{ row: 0 }` → toute la ligne 0 ;
|
|
236
|
+
* - `{ column: "total" }` → toute la colonne « total » ;
|
|
237
|
+
* - `{ from: { row, column }, to: { row, column } }` → le bloc entre les deux coins.
|
|
238
|
+
*
|
|
239
|
+
* S'applique **même à une cellule vide**. S'ajoute à `columns[].cellReadonly` et aux props
|
|
240
|
+
* globales `readonly` / `disable`.
|
|
241
|
+
*/
|
|
242
|
+
lockedRanges?: QSpreadsheetLock[]
|
|
243
|
+
/**
|
|
244
|
+
* **Préremplit** des zones, écrites avec des **noms** de colonnes (jamais d'index) —
|
|
245
|
+
* même vocabulaire que `lockedRanges`, plus une `value` :
|
|
246
|
+
* - `[{ column: "status", value: "todo" }]` → toute la colonne « status » ;
|
|
247
|
+
* - `[{ row: 0, value: "—" }]` → toute la ligne 0 ;
|
|
248
|
+
* - `[{ row: 2, column: "total", value: 0 }]` → la cellule (ligne 2, colonne « total ») ;
|
|
249
|
+
* - `[{ from: { row: 0, column: "total" }, to: { row: 2, column: "status" }, value: false }]`
|
|
250
|
+
* → le bloc entre les deux coins (bornes **incluses**).
|
|
251
|
+
*
|
|
252
|
+
* `value` est une **constante** *ou* une **fonction** `(ctx) => any` (ctx : `rowIndex`,
|
|
253
|
+
* `row`, `column`, `letter`, `rowCount`, `letterOf`) — pour une valeur **dynamique**.
|
|
254
|
+
*
|
|
255
|
+
* `fx` porte une **formule**, **prioritaire sur `value`** : une chaîne (`"SUM(B1:B2)"` — le `=`
|
|
256
|
+
* initial est ajouté s'il manque) ou une fonction `(ctx) => string`. C'est la façon explicite
|
|
257
|
+
* d'écrire une formule, p. ex. la somme d'une colonne :
|
|
258
|
+
* `{ row: 3, column: "net", fx: (c) => `SUM(${c.letterOf("effectif")}1:${c.letterOf("effectif")}${c.rowCount})` }`.
|
|
259
|
+
* (Une chaîne de `value` commençant par `"="` reste stockée comme formule, mais `fx` est
|
|
260
|
+
* préféré : il documente l'intention et n'exige pas le préfixe.)
|
|
261
|
+
*
|
|
262
|
+
* Une ligne **neuve** (bouton « + », insertion) naît déjà préremplie. Les données **déjà
|
|
263
|
+
* chargées** ne sont **jamais** modifiées automatiquement : appeler `applyPrefill()`
|
|
264
|
+
* (méthode exposée) pour combler les cellules **vides** — le dernier `prefill` gagne,
|
|
265
|
+
* les noms de colonnes inconnus sont ignorés.
|
|
266
|
+
*/
|
|
267
|
+
prefill?: QSpreadsheetFill[]
|
|
268
|
+
/**
|
|
269
|
+
* Applique `prefill` **automatiquement** aux cellules **vides** dès que le document est prêt
|
|
270
|
+
* (chargement, remplacement de `rows` / `columns`) — sans avoir à appeler `applyPrefill()`.
|
|
271
|
+
* Le résultat est **écrit dans `rows`** (`update:rows`) : utiliser `v-model:rows` (ou écouter
|
|
272
|
+
* `@update:rows`) pour le recevoir. Sans historique ni marquage « modifié ». Une ligne
|
|
273
|
+
* **neuve** naît de toute façon préremplie. `false` → application sur appel explicite
|
|
274
|
+
* uniquement. (Défaut : `true`.)
|
|
275
|
+
*/
|
|
276
|
+
autoPrefill?: boolean
|
|
277
|
+
/**
|
|
278
|
+
* Nombre **minimum** de lignes : impossible de descendre en dessous (la suppression des lignes
|
|
279
|
+
* sélectionnées est refusée si elle passerait sous le plancher). Combiné avec `maxRows` à la
|
|
280
|
+
* **même** valeur, le nombre de lignes est **figé**. (Défaut : aucune limite.)
|
|
281
|
+
*/
|
|
282
|
+
minRows?: number
|
|
283
|
+
/**
|
|
284
|
+
* Nombre **maximum** de lignes : le bouton « + », l'insertion au curseur et l'import CSV
|
|
285
|
+
* s'arrêtent à cette limite (`:max-rows="1"` → feuille à **une seule** ligne). Les chargements
|
|
286
|
+
* programmatiques (`loadDocument`) ne sont pas bornés. (Défaut : aucune limite.)
|
|
287
|
+
*/
|
|
288
|
+
maxRows?: number
|
|
210
289
|
/** Affiche le numéro de ligne (colonne de gauche) */
|
|
211
290
|
showRowNumbers?: boolean
|
|
212
291
|
/** Affiche l'en-tête de colonne (lettre + label) */
|
|
@@ -442,6 +521,7 @@ const emit = defineEmits<{
|
|
|
442
521
|
"cell-edit-end": [payload: { row: number; column: string; canceled: boolean }]
|
|
443
522
|
"selection-change": [value: QSpreadsheetSelection | null]
|
|
444
523
|
"structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
|
|
524
|
+
"prefill": [payload: { count: number; mode: "auto" | "manual" }]
|
|
445
525
|
"update:dirty": [value: boolean]
|
|
446
526
|
"update:changes": [value: QSpreadsheetChanges]
|
|
447
527
|
}>()
|
|
@@ -485,6 +565,8 @@ const ensureRowKeys = (rows: Record<string, any>[] | undefined) => {
|
|
|
485
565
|
}
|
|
486
566
|
|
|
487
567
|
// ─── Données internes (copie éditable des rows) ───
|
|
568
|
+
// Préremplissage : tant que `false`, toute application auto est ignorée (avant montage).
|
|
569
|
+
let prefillReady = false
|
|
488
570
|
const state = ref<Record<string, any>[]>([])
|
|
489
571
|
watch(
|
|
490
572
|
() => props.rows,
|
|
@@ -496,6 +578,8 @@ watch(
|
|
|
496
578
|
state.value = v.map((r) => ({ ...r }))
|
|
497
579
|
// Lignes **externes** (nouveau document) → nouvelle référence de comparaison
|
|
498
580
|
if (trackReady) acceptChanges()
|
|
581
|
+
// Nouveau document → applique les défauts sur les cellules vides.
|
|
582
|
+
autoPrefill()
|
|
499
583
|
}
|
|
500
584
|
},
|
|
501
585
|
{ immediate: true },
|
|
@@ -514,6 +598,8 @@ watch(
|
|
|
514
598
|
cols.value = v.map((c) => ({ ...c }))
|
|
515
599
|
// Colonnes **externes** (nouveau schéma) → nouvelle référence de comparaison
|
|
516
600
|
if (trackReady) acceptChanges()
|
|
601
|
+
// Les zones de `prefill` dépendent des noms de colonnes → (re)applique.
|
|
602
|
+
autoPrefill()
|
|
517
603
|
}
|
|
518
604
|
},
|
|
519
605
|
{ immediate: true },
|
|
@@ -523,6 +609,12 @@ const pushCols = (next: QSpreadsheetColumn[]) => {
|
|
|
523
609
|
emit("update:columns", next)
|
|
524
610
|
}
|
|
525
611
|
|
|
612
|
+
// ─── Limites de lignes (`minRows` / `maxRows`) ───
|
|
613
|
+
/** Peut-on encore ajouter une ligne ? (bouton « + », insertion au curseur) */
|
|
614
|
+
const canAddRow = computed(() => canAddRows(state.value.length, 1, props.maxRows))
|
|
615
|
+
/** Peut-on retirer au moins une ligne ? (plancher `minRows`) */
|
|
616
|
+
const canRemoveRow = computed(() => canRemoveRows(state.value.length, 1, props.minRows))
|
|
617
|
+
|
|
526
618
|
const colIndex = (name: string) => cols.value.findIndex((c) => c.name === name)
|
|
527
619
|
const colNameAt = (i: number) => cols.value[i]?.name ?? ""
|
|
528
620
|
const colOf = (name: string) => cols.value.find((c) => c.name === name)
|
|
@@ -791,6 +883,27 @@ const cellTitle = (row: number, column: string): string | undefined => {
|
|
|
791
883
|
return undefined
|
|
792
884
|
}
|
|
793
885
|
|
|
886
|
+
/** Verrous de `lockedRanges`, traduits en rectangles d'index (noms de colonnes résolus). */
|
|
887
|
+
const lockRectsResolved = computed(() => lockRects(props.lockedRanges, colIndex))
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* La cellule est-elle **verrouillée** ? Une seule porte pour toutes les mutations : props
|
|
891
|
+
* globales (`readonly` / `disable`), colonne `editable: false`, prédicat `cellReadonly(val, row)`
|
|
892
|
+
* et verrous `lockedRanges`. S'applique **même à une cellule vide** (il n'y a rien à effacer,
|
|
893
|
+
* mais on ne peut pas y écrire) — verrouiller n'est pas effacer.
|
|
894
|
+
*/
|
|
895
|
+
const isCellLocked = (row: number, column: string): boolean => {
|
|
896
|
+
if (props.readonly || props.disable) return true
|
|
897
|
+
const col = colOf(column)
|
|
898
|
+
if (!col) return false
|
|
899
|
+
if (col.editable === false) return true
|
|
900
|
+
const rowData = state.value[row] ?? {}
|
|
901
|
+
if (col.cellReadonly?.(rowData[column], rowData, row)) return true
|
|
902
|
+
const ci = colIndex(column)
|
|
903
|
+
if (ci === -1) return false
|
|
904
|
+
return lockRectsResolved.value.some((z) => inLockRect(z, row, ci))
|
|
905
|
+
}
|
|
906
|
+
|
|
794
907
|
const cellClasses = (row: number, column: string) => {
|
|
795
908
|
const col = colOf(column)
|
|
796
909
|
const v = state.value[row]?.[column]
|
|
@@ -806,6 +919,9 @@ const cellClasses = (row: number, column: string) => {
|
|
|
806
919
|
isActive(row, column) && "q-spreadsheet__cell--active",
|
|
807
920
|
editing.value?.row === row && editing.value?.column === column && "q-spreadsheet__cell--editing",
|
|
808
921
|
props.readonly && "q-spreadsheet__cell--readonly",
|
|
922
|
+
// Verrou **ciblé** (colonne, prédicat, plage) : affordance visuelle. Le verrou global
|
|
923
|
+
// (`readonly` / `disable`) n'ajoute rien — hacher toute la grille serait illisible.
|
|
924
|
+
isCellLocked(row, column) && !props.readonly && !props.disable && "q-spreadsheet__cell--locked",
|
|
809
925
|
)
|
|
810
926
|
}
|
|
811
927
|
|
|
@@ -880,9 +996,8 @@ const editorInputMode = computed(() => {
|
|
|
880
996
|
})
|
|
881
997
|
|
|
882
998
|
const startEdit = (row: number, column: string, initial?: string) => {
|
|
883
|
-
if (props.readonly || props.disable) return
|
|
884
999
|
const col = colOf(column)
|
|
885
|
-
if (
|
|
1000
|
+
if (isCellLocked(row, column) || col?.type === "boolean") return
|
|
886
1001
|
const raw = state.value[row]?.[column]
|
|
887
1002
|
if (col?.type === "select") {
|
|
888
1003
|
const opt = cellOptionsOf(col).find((o) => o.value === raw)
|
|
@@ -1018,17 +1133,14 @@ const setCellValue = (row: number, column: string, old: any, next: any) => {
|
|
|
1018
1133
|
}
|
|
1019
1134
|
|
|
1020
1135
|
const clearCell = (row: number, column: string) => {
|
|
1021
|
-
|
|
1022
|
-
if (col?.editable === false || props.readonly || props.disable) return
|
|
1136
|
+
if (isCellLocked(row, column)) return
|
|
1023
1137
|
const old = state.value[row]?.[column]
|
|
1024
1138
|
if (isBlankValue(old) || (Array.isArray(old) && old.length === 0)) return
|
|
1025
1139
|
setCellValue(row, column, old, null)
|
|
1026
1140
|
}
|
|
1027
1141
|
|
|
1028
1142
|
const toggleBoolean = (row: number, column: string) => {
|
|
1029
|
-
if (
|
|
1030
|
-
const col = colOf(column)
|
|
1031
|
-
if (col?.editable === false) return
|
|
1143
|
+
if (isCellLocked(row, column)) return
|
|
1032
1144
|
const old = !!state.value[row]?.[column]
|
|
1033
1145
|
setCellValue(row, column, old, !old)
|
|
1034
1146
|
}
|
|
@@ -1040,7 +1152,7 @@ const clearSelection = () => {
|
|
|
1040
1152
|
for (let r = rect.r0; r <= rect.r1; r++) {
|
|
1041
1153
|
for (let c = rect.c0; c <= rect.c1; c++) {
|
|
1042
1154
|
const col = cols.value[c]
|
|
1043
|
-
if (!col || col.
|
|
1155
|
+
if (!col || isCellLocked(r, col.name)) continue
|
|
1044
1156
|
const v = state.value[r]?.[col.name]
|
|
1045
1157
|
if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0))
|
|
1046
1158
|
changed.push({ row: r, column: col.name })
|
|
@@ -1185,19 +1297,77 @@ const redo = () => {
|
|
|
1185
1297
|
}
|
|
1186
1298
|
|
|
1187
1299
|
// ─── Lignes & colonnes ───
|
|
1188
|
-
const blankRow = (): Record<string, any> => {
|
|
1300
|
+
const blankRow = (at = state.value.length): Record<string, any> => {
|
|
1189
1301
|
// `_key` posée dès la création : la ligne est identifiable sans attendre un emit
|
|
1190
1302
|
const row: Record<string, any> = { [ROW_KEY]: newRowKey() }
|
|
1191
1303
|
for (const col of cols.value) row[col.name] = col.type === "boolean" ? false : ""
|
|
1304
|
+
// Une ligne neuve naît préremplie par les zones `prefill` qui la visent.
|
|
1305
|
+
// `rowCount` compte la ligne en cours de création (le document l'aura).
|
|
1306
|
+
Object.assign(
|
|
1307
|
+
row,
|
|
1308
|
+
prefillForRow(props.prefill, at, cols.value.map((c) => c.name), {
|
|
1309
|
+
row,
|
|
1310
|
+
rowCount: state.value.length + 1,
|
|
1311
|
+
}),
|
|
1312
|
+
)
|
|
1192
1313
|
return row
|
|
1193
1314
|
}
|
|
1194
1315
|
|
|
1316
|
+
/**
|
|
1317
|
+
* Cœur du préremplissage : ne remplit que les cellules **vides** et renvoie le nombre rempli.
|
|
1318
|
+
* `mode` distingue l'application **automatique** (silencieuse) de l'appel **explicite**
|
|
1319
|
+
* (avec historique). Émet `update:rows` (si quelque chose a changé) puis `prefill`.
|
|
1320
|
+
*/
|
|
1321
|
+
function runPrefill(mode: "auto" | "manual"): number {
|
|
1322
|
+
const fills = props.prefill
|
|
1323
|
+
if (!fills?.length || props.disable) return 0
|
|
1324
|
+
const names = cols.value.map((c) => c.name)
|
|
1325
|
+
let count = 0
|
|
1326
|
+
const next = state.value.map((row, r) => {
|
|
1327
|
+
const patch = prefillForRow(fills, r, names, { row, rowCount: state.value.length })
|
|
1328
|
+
let out = row
|
|
1329
|
+
for (const [name, value] of Object.entries(patch)) {
|
|
1330
|
+
if (!isBlankValue(row[name])) continue
|
|
1331
|
+
if (out === row) out = { ...row }
|
|
1332
|
+
out[name] = value
|
|
1333
|
+
count++
|
|
1334
|
+
}
|
|
1335
|
+
return out
|
|
1336
|
+
})
|
|
1337
|
+
if (!count) return 0
|
|
1338
|
+
if (mode === "manual") pushHistory()
|
|
1339
|
+
state.value = next
|
|
1340
|
+
emit("update:rows", next)
|
|
1341
|
+
emit("prefill", { count, mode })
|
|
1342
|
+
return count
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Comble les cellules **vides** d'après `prefill` (jamais une valeur existante) et renvoie le
|
|
1347
|
+
* nombre de cellules remplies. Appel **explicite** (avec historique) — l'application au
|
|
1348
|
+
* chargement est automatique (cf. `autoPrefill`).
|
|
1349
|
+
*/
|
|
1350
|
+
function applyPrefill(): number {
|
|
1351
|
+
return runPrefill("manual")
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Application **automatique** (document prêt, nouveau document) : silencieuse — sans
|
|
1356
|
+
* historique, pour ne pas polluer l'annulation ni marquer la grille « modifiée ». **Écrit le
|
|
1357
|
+
* résultat dans `rows`** (`update:rows`). Désactivable via `:auto-prefill="false"`.
|
|
1358
|
+
*/
|
|
1359
|
+
function autoPrefill(): void {
|
|
1360
|
+
if (!prefillReady || props.autoPrefill === false) return
|
|
1361
|
+
runPrefill("auto")
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1195
1364
|
const addRow = (at?: number) => {
|
|
1196
1365
|
if (props.readonly || props.disable) return
|
|
1366
|
+
if (!canAddRow.value) return
|
|
1197
1367
|
pushHistory()
|
|
1198
1368
|
const idx = at ?? state.value.length
|
|
1199
1369
|
const next = [...state.value]
|
|
1200
|
-
next.splice(idx, 0, blankRow())
|
|
1370
|
+
next.splice(idx, 0, blankRow(idx))
|
|
1201
1371
|
state.value = next
|
|
1202
1372
|
emit("update:rows", next)
|
|
1203
1373
|
emit("structure-change", { rows: next, columns: cols.value, reason: "add-row" })
|
|
@@ -1206,12 +1376,14 @@ const addRow = (at?: number) => {
|
|
|
1206
1376
|
|
|
1207
1377
|
const removeSelectedRows = () => {
|
|
1208
1378
|
if (props.readonly || props.disable) return
|
|
1209
|
-
pushHistory()
|
|
1210
|
-
purgeMergesAndRules()
|
|
1211
1379
|
const rect = selRect.value
|
|
1212
1380
|
if (!rect || !state.value.length) return
|
|
1213
1381
|
const indexes = new Set<number>()
|
|
1214
1382
|
for (let r = rect.r0; r <= rect.r1; r++) indexes.add(r)
|
|
1383
|
+
// Plancher `minRows` : on refuse si la suppression descendrait en dessous.
|
|
1384
|
+
if (!canRemoveRows(state.value.length, indexes.size, props.minRows)) return
|
|
1385
|
+
pushHistory()
|
|
1386
|
+
purgeMergesAndRules()
|
|
1215
1387
|
const sorted = [...indexes].sort((a, b) => b - a)
|
|
1216
1388
|
const next = [...state.value]
|
|
1217
1389
|
for (const i of sorted) next.splice(i, 1)
|
|
@@ -1363,7 +1535,7 @@ const pasteClip = async () => {
|
|
|
1363
1535
|
const c = startCol + j
|
|
1364
1536
|
if (r >= state.value.length || c >= cols.value.length) return
|
|
1365
1537
|
const col = cols.value[c]!
|
|
1366
|
-
if (col.
|
|
1538
|
+
if (isCellLocked(r, col.name)) return
|
|
1367
1539
|
const old = state.value[r]?.[col.name]
|
|
1368
1540
|
const next = coerceValue(col, text, old)
|
|
1369
1541
|
if (next !== old) setCellValue(r, col.name, old, next)
|
|
@@ -1458,10 +1630,11 @@ const fillDownKey = () => {
|
|
|
1458
1630
|
for (let c = rect.c0; c <= rect.c1; c++) {
|
|
1459
1631
|
const name = colNameAt(c)
|
|
1460
1632
|
const col = colOf(name)
|
|
1461
|
-
if (!col
|
|
1633
|
+
if (!col) continue
|
|
1462
1634
|
const src = rect.r0 - 1
|
|
1463
1635
|
const sv = state.value[src]?.[name]
|
|
1464
1636
|
for (let r = rect.r0; r <= rect.r1; r++) {
|
|
1637
|
+
if (isCellLocked(r, name)) continue
|
|
1465
1638
|
const old = state.value[r]?.[name]
|
|
1466
1639
|
if (old !== sv) setCellValue(r, name, old, sv)
|
|
1467
1640
|
}
|
|
@@ -1477,10 +1650,11 @@ const fillRightKey = () => {
|
|
|
1477
1650
|
const src = rect.c0 - 1
|
|
1478
1651
|
const sname = colNameAt(src)
|
|
1479
1652
|
const col = colOf(sname)
|
|
1480
|
-
if (!col
|
|
1653
|
+
if (!col) continue
|
|
1481
1654
|
const sv = state.value[r]?.[sname]
|
|
1482
1655
|
for (let c = rect.c0; c <= rect.c1; c++) {
|
|
1483
1656
|
const name = colNameAt(c)
|
|
1657
|
+
if (isCellLocked(r, name)) continue
|
|
1484
1658
|
const old = state.value[r]?.[name]
|
|
1485
1659
|
if (old !== sv) setCellValue(r, name, old, sv)
|
|
1486
1660
|
}
|
|
@@ -1596,7 +1770,7 @@ const onKeydown = (e: KeyboardEvent) => {
|
|
|
1596
1770
|
// Frappe directe : remplace le contenu (comportement Excel)
|
|
1597
1771
|
if (e.key.length === 1 && !mod && !e.altKey) {
|
|
1598
1772
|
const col = colOf(sel.value.column)
|
|
1599
|
-
if (col && col.
|
|
1773
|
+
if (col && col.type !== "boolean" && !isCellLocked(sel.value.row, sel.value.column)) {
|
|
1600
1774
|
e.preventDefault()
|
|
1601
1775
|
startEdit(sel.value.row, sel.value.column, e.key)
|
|
1602
1776
|
}
|
|
@@ -1714,8 +1888,7 @@ const multiSelected = (opt: QSpreadsheetCellOption) =>
|
|
|
1714
1888
|
const toggleMultiOption = (opt: QSpreadsheetCellOption) => {
|
|
1715
1889
|
if (!editing.value) return
|
|
1716
1890
|
const { row, column } = editing.value
|
|
1717
|
-
|
|
1718
|
-
if (col?.editable === false || props.readonly || props.disable) return
|
|
1891
|
+
if (isCellLocked(row, column)) return
|
|
1719
1892
|
const old = state.value[row]?.[column]
|
|
1720
1893
|
const current = multiValues(old)
|
|
1721
1894
|
const next = multiSelected(opt)
|
|
@@ -1739,11 +1912,12 @@ const syncFx = () => {
|
|
|
1739
1912
|
watch([sel, state], syncFx)
|
|
1740
1913
|
|
|
1741
1914
|
const fxCanEdit = computed(() => {
|
|
1742
|
-
if (
|
|
1743
|
-
const
|
|
1915
|
+
if (!sel.value) return false
|
|
1916
|
+
const { row, column } = sel.value
|
|
1917
|
+
if (isCellLocked(row, column)) return false
|
|
1918
|
+
const col = colOf(column)
|
|
1744
1919
|
return (
|
|
1745
1920
|
!!col &&
|
|
1746
|
-
col.editable !== false &&
|
|
1747
1921
|
col.type !== "boolean" &&
|
|
1748
1922
|
col.type !== "select" &&
|
|
1749
1923
|
col.type !== "multiselect"
|
|
@@ -2475,6 +2649,7 @@ const doSortBy = (desc = false) => {
|
|
|
2475
2649
|
// ─── Insertion au curseur (lignes / colonnes) ───
|
|
2476
2650
|
const insertRowAt = (pos: "above" | "below") => {
|
|
2477
2651
|
if (!canEdit.value) return
|
|
2652
|
+
if (!canAddRow.value) return
|
|
2478
2653
|
pushHistory()
|
|
2479
2654
|
purgeMergesAndRules()
|
|
2480
2655
|
if (!state.value.length) {
|
|
@@ -2495,7 +2670,7 @@ const insertRowAt = (pos: "above" | "below") => {
|
|
|
2495
2670
|
: state.value.length
|
|
2496
2671
|
const idx = Math.max(0, Math.min(state.value.length, at))
|
|
2497
2672
|
const next = [...state.value]
|
|
2498
|
-
next.splice(idx, 0, blankRow())
|
|
2673
|
+
next.splice(idx, 0, blankRow(idx))
|
|
2499
2674
|
state.value = next
|
|
2500
2675
|
shiftRowKeys(idx, 1)
|
|
2501
2676
|
emit("update:rows", next)
|
|
@@ -2688,7 +2863,7 @@ const applyFill = () => {
|
|
|
2688
2863
|
if (rr < 0 || rr >= state.value.length || cc < 0 || cc >= cols.value.length) continue
|
|
2689
2864
|
const name = colNameAt(cc)
|
|
2690
2865
|
const col = colOf(name)
|
|
2691
|
-
if (!col ||
|
|
2866
|
+
if (!col || isCellLocked(rr, name)) continue
|
|
2692
2867
|
|
|
2693
2868
|
let val: any
|
|
2694
2869
|
// Série numérique / dates (2 graines) vers le bas ou la droite (Ctrl = copie)
|
|
@@ -2870,6 +3045,10 @@ const onFilterSearch = (e: Event) => {
|
|
|
2870
3045
|
}
|
|
2871
3046
|
|
|
2872
3047
|
onMounted(() => {
|
|
3048
|
+
// Préremplissage automatique en premier : la référence initiale ci-dessous doit
|
|
3049
|
+
// l'inclure, sinon les défauts compteraient comme des « modifications ».
|
|
3050
|
+
prefillReady = true
|
|
3051
|
+
autoPrefill()
|
|
2873
3052
|
// Référence initiale : à partir d'ici, toute différence avec elle = « modifié »
|
|
2874
3053
|
trackReady = true
|
|
2875
3054
|
acceptChanges()
|
|
@@ -3421,8 +3600,7 @@ const findMarkClass = (r: number, ci: number) =>
|
|
|
3421
3600
|
)
|
|
3422
3601
|
const findReplaceCurrent = () => {
|
|
3423
3602
|
const m = findMatches.value[findIdx.value]
|
|
3424
|
-
if (!m ||
|
|
3425
|
-
const col = cols.value[m.ci]
|
|
3603
|
+
if (!m || isCellLocked(m.r, m.name)) return
|
|
3426
3604
|
const raw = state.value[m.r]?.[m.name]
|
|
3427
3605
|
if (typeof raw !== "string" || !findQuery.value) return
|
|
3428
3606
|
const next = findCase.value
|
|
@@ -3437,6 +3615,7 @@ const findReplaceAll = () => {
|
|
|
3437
3615
|
pushHistory()
|
|
3438
3616
|
const changed: { r: number; name: string; old: string; next: string }[] = []
|
|
3439
3617
|
for (const m of findMatches.value) {
|
|
3618
|
+
if (isCellLocked(m.r, m.name)) continue
|
|
3440
3619
|
const raw = state.value[m.r]?.[m.name]
|
|
3441
3620
|
if (typeof raw !== "string") continue
|
|
3442
3621
|
const next = findCase.value
|
|
@@ -3506,6 +3685,8 @@ const importCsv = (text: string, opts: { delimiter?: string; headers?: boolean }
|
|
|
3506
3685
|
}))
|
|
3507
3686
|
data = rows
|
|
3508
3687
|
}
|
|
3688
|
+
// Plafond `maxRows` : l'import ne crée pas plus de lignes que la limite.
|
|
3689
|
+
data = clampRows(data, props.maxRows)
|
|
3509
3690
|
const rowObjs = data.map((row) => {
|
|
3510
3691
|
const obj: Record<string, any> = { [ROW_KEY]: newRowKey() }
|
|
3511
3692
|
colsArr.forEach((c, ci) => {
|
|
@@ -3575,7 +3756,7 @@ const pasteValues = async () => {
|
|
|
3575
3756
|
const c = c0 + j
|
|
3576
3757
|
if (r >= state.value.length || c >= cols.value.length) continue
|
|
3577
3758
|
const col = cols.value[c]!
|
|
3578
|
-
if (col.
|
|
3759
|
+
if (isCellLocked(r, col.name)) continue
|
|
3579
3760
|
const old = state.value[r]?.[col.name]
|
|
3580
3761
|
const next = coerceLiteral(col, lines[i]![j] ?? "", old)
|
|
3581
3762
|
if (next !== old) setCellValue(r, col.name, old, next)
|
|
@@ -3636,7 +3817,7 @@ const pasteTransposed = async () => {
|
|
|
3636
3817
|
const c = c0 + i
|
|
3637
3818
|
if (r >= state.value.length || c >= cols.value.length) continue
|
|
3638
3819
|
const col = cols.value[c]!
|
|
3639
|
-
if (col.
|
|
3820
|
+
if (isCellLocked(r, col.name)) continue
|
|
3640
3821
|
const old = state.value[r]?.[col.name]
|
|
3641
3822
|
const next = coerceValue(col, lines[i]![j] ?? "", old)
|
|
3642
3823
|
if (next !== old) setCellValue(r, col.name, old, next)
|
|
@@ -4151,6 +4332,7 @@ defineExpose({
|
|
|
4151
4332
|
commitEdit,
|
|
4152
4333
|
cancelEdit,
|
|
4153
4334
|
addRow,
|
|
4335
|
+
applyPrefill,
|
|
4154
4336
|
insertRowAt,
|
|
4155
4337
|
removeSelectedRows,
|
|
4156
4338
|
addColumn,
|
|
@@ -4278,11 +4460,11 @@ defineExpose({
|
|
|
4278
4460
|
</span>
|
|
4279
4461
|
|
|
4280
4462
|
<span class="q-spreadsheet__tb-group">
|
|
4281
|
-
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add row" aria-label="Add row" @click="addRow()">
|
|
4463
|
+
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !canAddRow" title="Add row" aria-label="Add row" @click="addRow()">
|
|
4282
4464
|
<Icon :icon="icons.rows3" aria-hidden="true" />
|
|
4283
4465
|
<Icon :icon="icons.plus" class="q-spreadsheet__tool-badge" aria-hidden="true" />
|
|
4284
4466
|
</button>
|
|
4285
|
-
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
|
|
4467
|
+
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect || !canRemoveRow" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
|
|
4286
4468
|
<Icon :icon="icons.minus" aria-hidden="true" />
|
|
4287
4469
|
</button>
|
|
4288
4470
|
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add column" aria-label="Add column" @click="addColumn()">
|
|
@@ -4641,7 +4823,7 @@ defineExpose({
|
|
|
4641
4823
|
type="button"
|
|
4642
4824
|
class="q-spreadsheet__checkbox"
|
|
4643
4825
|
:class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
|
|
4644
|
-
:disabled="
|
|
4826
|
+
:disabled="isCellLocked(ri, col.name)"
|
|
4645
4827
|
aria-label="Toggle"
|
|
4646
4828
|
@click.stop="toggleBoolean(ri, col.name)"
|
|
4647
4829
|
>
|
|
@@ -4895,13 +5077,13 @@ defineExpose({
|
|
|
4895
5077
|
</button>
|
|
4896
5078
|
|
|
4897
5079
|
<div class="q-spreadsheet__sep" />
|
|
4898
|
-
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('above')">
|
|
5080
|
+
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !canAddRow" @click="insertRowAt('above')">
|
|
4899
5081
|
<Icon :icon="icons.arrowUp" aria-hidden="true" /> {{ t('insertRowAbove') }}
|
|
4900
5082
|
</button>
|
|
4901
|
-
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('below')">
|
|
5083
|
+
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !canAddRow" @click="insertRowAt('below')">
|
|
4902
5084
|
<Icon :icon="icons.arrowDown" aria-hidden="true" /> {{ t('insertRowBelow') }}
|
|
4903
5085
|
</button>
|
|
4904
|
-
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect" @click="removeSelectedRows">
|
|
5086
|
+
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect || !canRemoveRow" @click="removeSelectedRows">
|
|
4905
5087
|
<Icon :icon="icons.trash2" aria-hidden="true" /> {{ t('deleteRows') }}
|
|
4906
5088
|
</button>
|
|
4907
5089
|
<div class="q-spreadsheet__sep" />
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Tests unitaires des **limites de lignes** (`lib/spreadsheetRows.ts`) : `maxRows` / `minRows`.
|
|
2
|
+
import { describe, expect, it } from "bun:test"
|
|
3
|
+
import { canAddRows, canRemoveRows, clampRows, removalsAllowed, rowRoom } from "./spreadsheetRows"
|
|
4
|
+
|
|
5
|
+
describe("rowRoom / canAddRows", () => {
|
|
6
|
+
it("sans max : illimité", () => {
|
|
7
|
+
expect(rowRoom(3, undefined)).toBe(Number.POSITIVE_INFINITY)
|
|
8
|
+
expect(canAddRows(3, 99, undefined)).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("borne par le max", () => {
|
|
12
|
+
expect(rowRoom(0, 1)).toBe(1)
|
|
13
|
+
expect(rowRoom(1, 1)).toBe(0)
|
|
14
|
+
expect(rowRoom(5, 1)).toBe(0)
|
|
15
|
+
expect(canAddRows(0, 1, 1)).toBe(true)
|
|
16
|
+
expect(canAddRows(1, 1, 1)).toBe(false)
|
|
17
|
+
expect(canAddRows(0, 2, 1)).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("ignore un max non fini (Infinity / NaN)", () => {
|
|
21
|
+
expect(rowRoom(4, Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY)
|
|
22
|
+
expect(rowRoom(4, Number.NaN)).toBe(Number.POSITIVE_INFINITY)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe("removalsAllowed / canRemoveRows", () => {
|
|
27
|
+
it("sans min : on peut tout retirer", () => {
|
|
28
|
+
expect(removalsAllowed(3, undefined)).toBe(3)
|
|
29
|
+
expect(canRemoveRows(3, 3, undefined)).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("plancher par le min", () => {
|
|
33
|
+
expect(removalsAllowed(3, 1)).toBe(2)
|
|
34
|
+
expect(removalsAllowed(1, 1)).toBe(0)
|
|
35
|
+
expect(removalsAllowed(0, 1)).toBe(0)
|
|
36
|
+
expect(canRemoveRows(3, 2, 1)).toBe(true)
|
|
37
|
+
expect(canRemoveRows(3, 3, 1)).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("clampRows", () => {
|
|
42
|
+
it("tronque au-delà du max", () => {
|
|
43
|
+
expect(clampRows([1, 2, 3], 2)).toEqual([1, 2])
|
|
44
|
+
expect(clampRows([1], 5)).toEqual([1])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("renvoie la **même** référence si rien à couper (ou sans max)", () => {
|
|
48
|
+
const rows = [1, 2]
|
|
49
|
+
expect(clampRows(rows, 2)).toBe(rows)
|
|
50
|
+
expect(clampRows(rows, 5)).toBe(rows)
|
|
51
|
+
expect(clampRows(rows, undefined)).toBe(rows)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// **Limites du nombre de lignes** de QSpreadsheet (`<q-spreadsheet>`) : props `maxRows` /
|
|
2
|
+
// `minRows`. Elles bornent les opérations **utilisateur** (ajout / insertion / suppression de
|
|
3
|
+
// lignes, import CSV) — jamais les chargements programmatiques (`loadDocument`), comme
|
|
4
|
+
// `readonly` / `disable` bornent les éditions.
|
|
5
|
+
//
|
|
6
|
+
// Logique **pure**, testable hors navigateur — comme `spreadsheetZones.ts`.
|
|
7
|
+
|
|
8
|
+
/** Nombre de lignes qu'on peut encore **ajouter** avant `max` (`Infinity` si pas de limite). */
|
|
9
|
+
export function rowRoom(current: number, max?: number): number {
|
|
10
|
+
if (typeof max !== "number" || !Number.isFinite(max)) return Number.POSITIVE_INFINITY
|
|
11
|
+
return Math.max(0, max - current)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Peut-on ajouter `count` lignes à un document de `current` lignes ? */
|
|
15
|
+
export function canAddRows(current: number, count = 1, max?: number): boolean {
|
|
16
|
+
return count <= rowRoom(current, max)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Nombre de lignes qu'on peut **retirer** au maximum avant d'atteindre `min` (plancher 0). */
|
|
20
|
+
export function removalsAllowed(current: number, min?: number): number {
|
|
21
|
+
if (typeof min !== "number" || !Number.isFinite(min)) return Math.max(0, current)
|
|
22
|
+
return Math.max(0, current - Math.max(0, min))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Peut-on retirer `count` lignes d'un document de `current` lignes (il en reste ≥ `min`) ? */
|
|
26
|
+
export function canRemoveRows(current: number, count = 1, min?: number): boolean {
|
|
27
|
+
return count <= removalsAllowed(current, min)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Plafonne une liste de lignes à `max` (renvoie la **même** référence si rien à couper). */
|
|
31
|
+
export function clampRows<T>(rows: T[], max?: number): T[] {
|
|
32
|
+
if (typeof max !== "number" || !Number.isFinite(max) || rows.length <= max) return rows
|
|
33
|
+
return rows.slice(0, max)
|
|
34
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Tests unitaires des **zones** de QSpreadsheet (`lib/spreadsheetZones.ts`) : la traduction de la
|
|
2
|
+
// forme **lisible** (noms de colonnes) vers les rectangles d'index, et le préremplissage.
|
|
3
|
+
import { describe, expect, it } from "bun:test"
|
|
4
|
+
import { inLockRect, lockRect, lockRects, prefillForRow } from "./spreadsheetZones"
|
|
5
|
+
|
|
6
|
+
/** Colonnes : task(0) · total(1) · status(2) */
|
|
7
|
+
const cols = ["task", "total", "status"]
|
|
8
|
+
const colIndex = (name: string) => cols.indexOf(name)
|
|
9
|
+
|
|
10
|
+
describe("lockRect", () => {
|
|
11
|
+
it("une cellule : `{ row, column }`", () => {
|
|
12
|
+
expect(lockRect({ row: 2, column: "total" }, colIndex)).toEqual({
|
|
13
|
+
r0: 2,
|
|
14
|
+
c0: 1,
|
|
15
|
+
r1: 2,
|
|
16
|
+
c1: 1,
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("toute une ligne : `{ row }` (colonnes non bornées)", () => {
|
|
21
|
+
expect(lockRect({ row: 0 }, colIndex)).toEqual({ r0: 0, c0: null, r1: 0, c1: null })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("toute une colonne : `{ column }` (lignes non bornées)", () => {
|
|
25
|
+
expect(lockRect({ column: "total" }, colIndex)).toEqual({
|
|
26
|
+
r0: null,
|
|
27
|
+
c0: 1,
|
|
28
|
+
r1: null,
|
|
29
|
+
c1: 1,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("un bloc : `{ from, to }` (bornes incluses)", () => {
|
|
34
|
+
expect(
|
|
35
|
+
lockRect(
|
|
36
|
+
{ from: { row: 0, column: "task" }, to: { row: 2, column: "status" } },
|
|
37
|
+
colIndex,
|
|
38
|
+
),
|
|
39
|
+
).toEqual({ r0: 0, c0: 0, r1: 2, c1: 2 })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("un bloc `from`/`to` dans n'importe quel ordre", () => {
|
|
43
|
+
expect(
|
|
44
|
+
lockRect(
|
|
45
|
+
{ from: { row: 3, column: "status" }, to: { row: 1, column: "task" } },
|
|
46
|
+
colIndex,
|
|
47
|
+
),
|
|
48
|
+
).toEqual({ r0: 1, c0: 0, r1: 3, c1: 2 })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("ignore ce qui ne désigne rien (objet vide, colonne inconnue, from sans to)", () => {
|
|
52
|
+
expect(lockRect({}, colIndex)).toBeNull()
|
|
53
|
+
expect(lockRect({ row: 1, column: "nope" }, colIndex)).toBeNull()
|
|
54
|
+
expect(lockRect({ from: { row: 0, column: "task" } }, colIndex)).toBeNull()
|
|
55
|
+
expect(lockRect(undefined, colIndex)).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("lockRects", () => {
|
|
60
|
+
it("résout la liste et ignore les verrous invalides", () => {
|
|
61
|
+
expect(lockRects([{ row: 0 }, { column: "nope" }, { row: 1, column: "task" }], colIndex)).toEqual(
|
|
62
|
+
[
|
|
63
|
+
{ r0: 0, c0: null, r1: 0, c1: null },
|
|
64
|
+
{ r0: 1, c0: 0, r1: 1, c1: 0 },
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("tolère une entrée non-tableau", () => {
|
|
70
|
+
expect(lockRects(undefined, colIndex)).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe("inLockRect", () => {
|
|
75
|
+
const rect = { r0: 1, c0: 1, r1: 2, c1: 1 }
|
|
76
|
+
|
|
77
|
+
it("borne inclusivement", () => {
|
|
78
|
+
expect(inLockRect(rect, 1, 1)).toBe(true)
|
|
79
|
+
expect(inLockRect(rect, 2, 1)).toBe(true)
|
|
80
|
+
expect(inLockRect(rect, 0, 1)).toBe(false)
|
|
81
|
+
expect(inLockRect(rect, 3, 1)).toBe(false)
|
|
82
|
+
expect(inLockRect(rect, 1, 0)).toBe(false)
|
|
83
|
+
expect(inLockRect(rect, 1, 2)).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("une borne `null` est non bornée", () => {
|
|
87
|
+
expect(inLockRect({ r0: null, c0: 1, r1: null, c1: 1 }, 999, 1)).toBe(true)
|
|
88
|
+
expect(inLockRect({ r0: 0, c0: null, r1: 0, c1: null }, 0, 999)).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("prefillForRow", () => {
|
|
93
|
+
const names = ["task", "total", "status"]
|
|
94
|
+
|
|
95
|
+
it("toute la colonne : la valeur est posée sur chaque ligne", () => {
|
|
96
|
+
const fills = [{ column: "status", value: "todo" }]
|
|
97
|
+
expect(prefillForRow(fills, 0, names)).toEqual({ status: "todo" })
|
|
98
|
+
expect(prefillForRow(fills, 7, names)).toEqual({ status: "todo" })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("une cellule : seulement la ligne visée", () => {
|
|
102
|
+
const fills = [{ row: 2, column: "total", value: 0 }]
|
|
103
|
+
expect(prefillForRow(fills, 2, names)).toEqual({ total: 0 })
|
|
104
|
+
expect(prefillForRow(fills, 1, names)).toEqual({})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("toute une ligne : toutes les colonnes", () => {
|
|
108
|
+
expect(prefillForRow([{ row: 1, value: "—" }], 1, names)).toEqual({
|
|
109
|
+
task: "—",
|
|
110
|
+
total: "—",
|
|
111
|
+
status: "—",
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("un bloc : les colonnes et lignes couvertes", () => {
|
|
116
|
+
const fills = [
|
|
117
|
+
{ from: { row: 0, column: "total" }, to: { row: 1, column: "status" }, value: false },
|
|
118
|
+
]
|
|
119
|
+
expect(prefillForRow(fills, 0, names)).toEqual({ total: false, status: false })
|
|
120
|
+
expect(prefillForRow(fills, 1, names)).toEqual({ total: false, status: false })
|
|
121
|
+
expect(prefillForRow(fills, 2, names)).toEqual({})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("cumule plusieurs zones (la dernière gagne) et ignore l'inconnu", () => {
|
|
125
|
+
expect(
|
|
126
|
+
prefillForRow(
|
|
127
|
+
[
|
|
128
|
+
{ column: "status", value: "todo" },
|
|
129
|
+
{ row: 0, column: "status", value: "urgent" },
|
|
130
|
+
{ column: "nope", value: 1 },
|
|
131
|
+
],
|
|
132
|
+
0,
|
|
133
|
+
names,
|
|
134
|
+
),
|
|
135
|
+
).toEqual({ status: "urgent" })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("tolère une entrée vide ou non-tableau", () => {
|
|
139
|
+
expect(prefillForRow(undefined, 0, names)).toEqual({})
|
|
140
|
+
expect(prefillForRow([], 0, names)).toEqual({})
|
|
141
|
+
expect(prefillForRow([{ column: "task", value: 1 }], 0, [])).toEqual({})
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("prefillForRow — value fonction (valeurs dynamiques)", () => {
|
|
146
|
+
const names = ["task", "effectif", "net"]
|
|
147
|
+
|
|
148
|
+
it("appelle la fonction avec le contexte (lettre, index, taille du document)", () => {
|
|
149
|
+
const seen: any[] = []
|
|
150
|
+
const fills = [
|
|
151
|
+
{
|
|
152
|
+
row: 1,
|
|
153
|
+
column: "net",
|
|
154
|
+
value: (ctx: any) => {
|
|
155
|
+
seen.push(ctx)
|
|
156
|
+
const L = ctx.letterOf("effectif")
|
|
157
|
+
return `=SUM(${L}1:${L}${ctx.rowCount})`
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
expect(prefillForRow(fills, 1, names, { rowCount: 4 })).toEqual({ net: "=SUM(B1:B4)" })
|
|
162
|
+
expect(seen[0]).toMatchObject({
|
|
163
|
+
rowIndex: 1,
|
|
164
|
+
column: "net",
|
|
165
|
+
columnIndex: 2,
|
|
166
|
+
letter: "C",
|
|
167
|
+
rowCount: 4,
|
|
168
|
+
})
|
|
169
|
+
expect(seen[0].letterOf("effectif")).toBe("B")
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("reçoit la ligne ciblée (agrégat calculé, pas une formule)", () => {
|
|
173
|
+
const fills = [{ row: 0, column: "net", value: (ctx: any) => Number(ctx.row.effectif) * 2 }]
|
|
174
|
+
expect(prefillForRow(fills, 0, names, { row: { effectif: 21 } })).toEqual({ net: 42 })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("une chaîne de formule constante passe telle quelle", () => {
|
|
178
|
+
expect(prefillForRow([{ row: 0, column: "net", value: "=B1*2" }], 0, names)).toEqual({
|
|
179
|
+
net: "=B1*2",
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("une value **fonction** peut renvoyer une formule", () => {
|
|
184
|
+
const fills = [
|
|
185
|
+
{
|
|
186
|
+
row: 0,
|
|
187
|
+
column: "net",
|
|
188
|
+
value: (ctx: any) => `=SUM(${ctx.letterOf("effectif")}1:${ctx.letterOf("effectif")}${ctx.rowCount})`,
|
|
189
|
+
},
|
|
190
|
+
]
|
|
191
|
+
expect(prefillForRow(fills, 0, names, { rowCount: 3 })).toEqual({ net: "=SUM(B1:B3)" })
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("value reste littéral (pas de normalisation du préfixe `=`)", () => {
|
|
195
|
+
expect(prefillForRow([{ row: 0, column: "net", value: "SUM(B1:B2)" }], 0, names)).toEqual({
|
|
196
|
+
net: "SUM(B1:B2)",
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe("prefillForRow — fx (formule prioritaire)", () => {
|
|
202
|
+
const names = ["task", "effectif", "net"]
|
|
203
|
+
|
|
204
|
+
it("fx accepte une formule sans préfixe `=` (normalisée)", () => {
|
|
205
|
+
expect(prefillForRow([{ row: 0, column: "net", fx: "SUM(B1:B2)" }], 0, names)).toEqual({
|
|
206
|
+
net: "=SUM(B1:B2)",
|
|
207
|
+
})
|
|
208
|
+
expect(prefillForRow([{ row: 0, column: "net", fx: "=B1*2" }], 0, names)).toEqual({
|
|
209
|
+
net: "=B1*2",
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("fx est prioritaire sur value", () => {
|
|
214
|
+
expect(
|
|
215
|
+
prefillForRow([{ row: 0, column: "net", value: 42, fx: "B1*2" }], 0, names),
|
|
216
|
+
).toEqual({ net: "=B1*2" })
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("fx fonction reçoit le contexte et peut être dynamique", () => {
|
|
220
|
+
const fills = [
|
|
221
|
+
{
|
|
222
|
+
row: 0,
|
|
223
|
+
column: "net",
|
|
224
|
+
fx: (ctx: any) => {
|
|
225
|
+
const L = ctx.letterOf("effectif")
|
|
226
|
+
return `SUM(${L}1:${L}${ctx.rowCount - 1})`
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
]
|
|
230
|
+
expect(prefillForRow(fills, 0, names, { rowCount: 3 })).toEqual({ net: "=SUM(B1:B2)" })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("un fx vide retombe sur value ; sinon rien n'est posé", () => {
|
|
234
|
+
expect(prefillForRow([{ row: 0, column: "net", fx: " ", value: 7 }], 0, names)).toEqual({
|
|
235
|
+
net: 7,
|
|
236
|
+
})
|
|
237
|
+
expect(prefillForRow([{ row: 0, column: "net" }], 0, names)).toEqual({})
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// **Zones** de QSpreadsheet (`<q-spreadsheet>`) désignées par des **noms** — servent à deux
|
|
2
|
+
// choses : verrouiller des cellules (`lockedRanges`) et les **préremplir** (`prefill`).
|
|
3
|
+
//
|
|
4
|
+
// On désigne les cellules par **nom de colonne** (la clé dans `rows`), jamais par index :
|
|
5
|
+
//
|
|
6
|
+
// { row: 2, column: "total" } → la cellule « total » de la ligne 2
|
|
7
|
+
// { row: 0 } → toute la ligne 0
|
|
8
|
+
// { column: "total" } → toute la colonne « total »
|
|
9
|
+
// { from: {...}, to: {...} } → un bloc (bornes incluses, dans n'importe quel ordre)
|
|
10
|
+
//
|
|
11
|
+
// `lockRect()` traduit cette forme en rectangle d'**index** (une borne `null` = non bornée) ;
|
|
12
|
+
// `prefillForRow()` en déduit les valeurs à poser dans une ligne neuve. Logique **pure**,
|
|
13
|
+
// testable hors navigateur — comme `spreadsheetValidation.ts`.
|
|
14
|
+
import { colLetter } from "./spreadsheet"
|
|
15
|
+
|
|
16
|
+
/** Un coin, en clair : ligne (0-based) + **nom** de colonne. */
|
|
17
|
+
export interface QSpreadsheetLockPoint {
|
|
18
|
+
/** Ligne, 0-based (0 = première ligne de données) */
|
|
19
|
+
row: number
|
|
20
|
+
/** Nom de la colonne — la clé utilisée dans `rows` (ex. `"total"`) */
|
|
21
|
+
column: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Un verrou, écrit avec des **noms** :
|
|
26
|
+
* - `{ row: 2, column: "total" }` → une cellule ;
|
|
27
|
+
* - `{ row: 0 }` → toute la ligne 0 ;
|
|
28
|
+
* - `{ column: "total" }` → toute la colonne ;
|
|
29
|
+
* - `{ from, to }` → le bloc entre les deux coins (bornes incluses).
|
|
30
|
+
*/
|
|
31
|
+
export interface QSpreadsheetLock {
|
|
32
|
+
/** Ligne (0-based). Omise → **toutes** les lignes. */
|
|
33
|
+
row?: number
|
|
34
|
+
/** Nom de colonne. Omise → **toutes** les colonnes. */
|
|
35
|
+
column?: string
|
|
36
|
+
/** Premier coin d'un **bloc** (avec `to`) — prioritaire sur `row` / `column`. */
|
|
37
|
+
from?: QSpreadsheetLockPoint
|
|
38
|
+
/** Coin opposé du bloc (avec `from`). */
|
|
39
|
+
to?: QSpreadsheetLockPoint
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Rectangle d'index. Une borne `null` = **non bornée** (toutes les lignes / colonnes). */
|
|
43
|
+
export interface LockRect {
|
|
44
|
+
r0: number | null
|
|
45
|
+
c0: number | null
|
|
46
|
+
r1: number | null
|
|
47
|
+
c1: number | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Traduit un verrou lisible en rectangle d'index.
|
|
52
|
+
* `null` quand il ne désigne rien : objet vide, `from`/`to` incomplets, ou nom de colonne
|
|
53
|
+
* inconnu (un verrou mal orthographié est **ignoré** plutôt que de tout verrouiller).
|
|
54
|
+
*/
|
|
55
|
+
export function lockRect(
|
|
56
|
+
lock: QSpreadsheetLock | undefined,
|
|
57
|
+
columnIndex: (name: string) => number,
|
|
58
|
+
): LockRect | null {
|
|
59
|
+
if (!lock) return null
|
|
60
|
+
|
|
61
|
+
if (lock.from && lock.to) {
|
|
62
|
+
const a = columnIndex(lock.from.column)
|
|
63
|
+
const b = columnIndex(lock.to.column)
|
|
64
|
+
if (a === -1 || b === -1) return null
|
|
65
|
+
return {
|
|
66
|
+
r0: Math.min(lock.from.row, lock.to.row),
|
|
67
|
+
r1: Math.max(lock.from.row, lock.to.row),
|
|
68
|
+
c0: Math.min(a, b),
|
|
69
|
+
c1: Math.max(a, b),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const hasRow = typeof lock.row === "number"
|
|
74
|
+
const hasColumn = typeof lock.column === "string"
|
|
75
|
+
if (!hasRow && !hasColumn) return null
|
|
76
|
+
|
|
77
|
+
let c0: number | null = null
|
|
78
|
+
let c1: number | null = null
|
|
79
|
+
if (hasColumn) {
|
|
80
|
+
const ci = columnIndex(lock.column as string)
|
|
81
|
+
if (ci === -1) return null
|
|
82
|
+
c0 = ci
|
|
83
|
+
c1 = ci
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const r = hasRow ? (lock.row as number) : null
|
|
87
|
+
return { r0: r, r1: r, c0, c1 }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Traduit une liste de verrous (les entrées non résolvables sont ignorées). */
|
|
91
|
+
export function lockRects(
|
|
92
|
+
locks: QSpreadsheetLock[] | undefined,
|
|
93
|
+
columnIndex: (name: string) => number,
|
|
94
|
+
): LockRect[] {
|
|
95
|
+
if (!Array.isArray(locks)) return []
|
|
96
|
+
const out: LockRect[] = []
|
|
97
|
+
for (const lock of locks) {
|
|
98
|
+
const rect = lockRect(lock, columnIndex)
|
|
99
|
+
if (rect) out.push(rect)
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** La cellule `(row, col)` tombe-t-elle dans le rectangle ? (bornes `null` = non bornées) */
|
|
105
|
+
export const inLockRect = (rect: LockRect, row: number, col: number): boolean =>
|
|
106
|
+
(rect.r0 === null || row >= rect.r0) &&
|
|
107
|
+
(rect.r1 === null || row <= rect.r1) &&
|
|
108
|
+
(rect.c0 === null || col >= rect.c0) &&
|
|
109
|
+
(rect.c1 === null || col <= rect.c1)
|
|
110
|
+
|
|
111
|
+
// ─────────────────────────── Préremplissage ───────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Une **formule** de préremplissage : une chaîne (`"SUM(B1:B2)"` ou `"=SUM(B1:B2)"` — le `=`
|
|
115
|
+
* initial est ajouté s'il manque) ou une **fonction** du contexte qui renvoie cette chaîne.
|
|
116
|
+
*/
|
|
117
|
+
export type QSpreadsheetFillFormula = string | ((ctx: QSpreadsheetFillContext) => string)
|
|
118
|
+
|
|
119
|
+
/** Zone **préremplie** : mêmes coordonnées qu'un verrou, plus une `value` **et/ou** une `fx`. */
|
|
120
|
+
export interface QSpreadsheetFill extends QSpreadsheetLock {
|
|
121
|
+
/**
|
|
122
|
+
* Valeur posée dans les cellules **vides** de la zone. Une **constante** (`0`, `"todo"`, …)
|
|
123
|
+
* **ou** une **fonction** du contexte — pour une valeur **dynamique** (agrégat calculé…).
|
|
124
|
+
* Une chaîne commençant par `"="` est stockée comme **formule** et évaluée par le tableur.
|
|
125
|
+
*
|
|
126
|
+
* Si `fx` est **aussi** fourni, c'est `fx` qui gagne.
|
|
127
|
+
*/
|
|
128
|
+
value?: any | ((ctx: QSpreadsheetFillContext) => any)
|
|
129
|
+
/**
|
|
130
|
+
* **Formule** de la zone, **prioritaire sur `value`** : une chaîne (`"SUM(B1:B2)"` — le `=`
|
|
131
|
+
* initial est ajouté s'il manque) ou une fonction qui la renvoie. Écrit explicitement « c'est
|
|
132
|
+
* une formule », sans se soucier du préfixe :
|
|
133
|
+
*
|
|
134
|
+
* ```ts
|
|
135
|
+
* { row: 2, column: "total", fx: ({ letterOf, rowCount }) =>
|
|
136
|
+
* `SUM(${letterOf("effectif")}1:${letterOf("effectif")}${rowCount - 1})` }
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
fx?: QSpreadsheetFillFormula
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Contexte remis à une `value` **fonction**, décrit la cellule remplie et le document
|
|
144
|
+
* au moment du remplissage. Permet d'écrire des formules dont la référence dépend de la
|
|
145
|
+
* position ou de la taille des données :
|
|
146
|
+
*
|
|
147
|
+
* ```ts
|
|
148
|
+
* // somme de TOUTE la colonne « effectif » (autant de lignes qu'il y en a)
|
|
149
|
+
* { row: 3, column: "net", fx: ({ letterOf, rowCount }) =>
|
|
150
|
+
* `SUM(${letterOf("effectif")}1:${letterOf("effectif")}${rowCount})` }
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export interface QSpreadsheetFillContext {
|
|
154
|
+
/** Index (0-based) de la ligne remplie */
|
|
155
|
+
rowIndex: number
|
|
156
|
+
/** La ligne ciblée (référence live sur `rows`) */
|
|
157
|
+
row: Record<string, any>
|
|
158
|
+
/** Nom de la colonne remplie */
|
|
159
|
+
column: string
|
|
160
|
+
/** Index (0-based) de la colonne remplie */
|
|
161
|
+
columnIndex: number
|
|
162
|
+
/** Lettre A1 de la colonne remplie (ex. `"C"`) */
|
|
163
|
+
letter: string
|
|
164
|
+
/** Nombre de lignes du document au moment du remplissage */
|
|
165
|
+
rowCount: number
|
|
166
|
+
/** Lettre A1 d'une colonne par son **nom** (ex. `letterOf("effectif")` → `"C"`) */
|
|
167
|
+
letterOf: (column: string) => string
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Contexte minimal fourni par le composant (ligne ciblée + taille du document). */
|
|
171
|
+
export interface QSpreadsheetFillEnv {
|
|
172
|
+
/** La ligne ciblée (référence live sur `rows`) */
|
|
173
|
+
row?: Record<string, any>
|
|
174
|
+
/** Nombre de lignes du document au moment du remplissage */
|
|
175
|
+
rowCount?: number
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Valeurs à poser dans une ligne d'index `rowIndex`, d'après les zones `prefill` :
|
|
180
|
+
* chaque zone qui couvre cette ligne fournit sa valeur pour toutes ses colonnes — une `fx`
|
|
181
|
+
* (formule, **prioritaire**) sinon une `value` ; les deux peuvent être des fonctions appelées
|
|
182
|
+
* avec le `QSpreadsheetFillContext`. Renvoie `{}` s'il n'y a rien à poser. Pur → testable sans
|
|
183
|
+
* navigateur.
|
|
184
|
+
*/
|
|
185
|
+
export function prefillForRow(
|
|
186
|
+
fills: QSpreadsheetFill[] | undefined,
|
|
187
|
+
rowIndex: number,
|
|
188
|
+
columnNames: string[],
|
|
189
|
+
env: QSpreadsheetFillEnv = {},
|
|
190
|
+
): Record<string, any> {
|
|
191
|
+
if (!Array.isArray(fills) || !fills.length || !columnNames.length) return {}
|
|
192
|
+
|
|
193
|
+
const columnIndex = (name: string) => columnNames.indexOf(name)
|
|
194
|
+
const letterAt = (i: number) => colLetter(i)
|
|
195
|
+
const letterOf = (name: string) => letterAt(columnIndex(name))
|
|
196
|
+
const rowCount = env.rowCount ?? 0
|
|
197
|
+
const out: Record<string, any> = {}
|
|
198
|
+
|
|
199
|
+
for (const fill of fills) {
|
|
200
|
+
const rect = lockRect(fill, columnIndex)
|
|
201
|
+
if (!rect) continue
|
|
202
|
+
if (rect.r0 !== null && rowIndex < rect.r0) continue
|
|
203
|
+
if (rect.r1 !== null && rowIndex > rect.r1) continue
|
|
204
|
+
|
|
205
|
+
const from = rect.c0 ?? 0
|
|
206
|
+
const to = rect.c1 ?? columnNames.length - 1
|
|
207
|
+
for (let c = from; c <= to; c++) {
|
|
208
|
+
const name = columnNames[c]
|
|
209
|
+
if (name === undefined) continue
|
|
210
|
+
|
|
211
|
+
const ctx: QSpreadsheetFillContext = {
|
|
212
|
+
rowIndex,
|
|
213
|
+
row: env.row ?? {},
|
|
214
|
+
column: name,
|
|
215
|
+
columnIndex: c,
|
|
216
|
+
letter: letterAt(c),
|
|
217
|
+
rowCount,
|
|
218
|
+
letterOf,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// `fx` (formule) est prioritaire ; `value` ne sert que si aucune formule n'est fournie.
|
|
222
|
+
const formula = fillFormula(fill, ctx)
|
|
223
|
+
if (formula !== undefined) {
|
|
224
|
+
out[name] = formula
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (fill.value !== undefined) {
|
|
228
|
+
out[name] = typeof fill.value === "function" ? fill.value(ctx) : fill.value
|
|
229
|
+
}
|
|
230
|
+
// ni `fx` ni `value` → rien à poser
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return out
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Formule résolue d'une zone (normalisée avec un `=` initial), ou `undefined`. */
|
|
238
|
+
function fillFormula(
|
|
239
|
+
fill: QSpreadsheetFill,
|
|
240
|
+
ctx: QSpreadsheetFillContext,
|
|
241
|
+
): string | undefined {
|
|
242
|
+
if (fill.fx === undefined || fill.fx === null) return undefined
|
|
243
|
+
const raw = typeof fill.fx === "function" ? fill.fx(ctx) : fill.fx
|
|
244
|
+
return normalizeFormula(raw)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `"SUM(B1:B2)"` → `"=SUM(B1:B2)"` ; `""` / non-chaîne → `undefined`. */
|
|
248
|
+
function normalizeFormula(raw: unknown): string | undefined {
|
|
249
|
+
if (typeof raw !== "string") return undefined
|
|
250
|
+
const s = raw.trim()
|
|
251
|
+
if (!s) return undefined
|
|
252
|
+
return s.startsWith("=") ? s : "=" + s
|
|
253
|
+
}
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -9493,6 +9493,26 @@ a.q-breadcrumbs__el:hover {
|
|
|
9493
9493
|
.q-spreadsheet__cell--readonly {
|
|
9494
9494
|
cursor: default;
|
|
9495
9495
|
}
|
|
9496
|
+
|
|
9497
|
+
/* Cellule verrouillée (`cellReadonly` / `lockedRanges`) : pas d'édition + affordance discrète.
|
|
9498
|
+
Seule la `background-image` est posée — la `background-color` (formatage conditionnel,
|
|
9499
|
+
`cellBackground`) reste intacte. */
|
|
9500
|
+
.q-spreadsheet__cell--locked {
|
|
9501
|
+
cursor: default;
|
|
9502
|
+
background-image: repeating-linear-gradient(
|
|
9503
|
+
135deg,
|
|
9504
|
+
rgb(0 0 0 / 0.05) 0 2px,
|
|
9505
|
+
transparent 2px 6px
|
|
9506
|
+
);
|
|
9507
|
+
}
|
|
9508
|
+
|
|
9509
|
+
.dark .q-spreadsheet__cell--locked {
|
|
9510
|
+
background-image: repeating-linear-gradient(
|
|
9511
|
+
135deg,
|
|
9512
|
+
rgb(255 255 255 / 0.07) 0 2px,
|
|
9513
|
+
transparent 2px 6px
|
|
9514
|
+
);
|
|
9515
|
+
}
|
|
9496
9516
|
.q-spreadsheet__cell--bool {
|
|
9497
9517
|
text-align: center;
|
|
9498
9518
|
}
|