@dnax/ui 0.0.28 → 0.0.29

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.
@@ -0,0 +1,4662 @@
1
+ <script lang="ts">
2
+ // QSpreadsheet — tableur type Excel (grille éditable) :
3
+ // sélection de cellule + plage (clic, maj+clic, glisser, clavier),
4
+ // édition en place (Entrée / F2 / double-clic / frappe directe),
5
+ // lignes & colonnes (ajouter / insérer / supprimer),
6
+ // tri, copier/coller, undo/redo, et cellules typées
7
+ // (texte, nombre, entier, booléen, date, datetime, select avec badges colorés).
8
+ export type QSpreadsheetCellType =
9
+ | "string"
10
+ | "text"
11
+ | "number"
12
+ | "integer"
13
+ | "email"
14
+ | "url"
15
+ | "boolean"
16
+ | "date"
17
+ | "datetime"
18
+ | "select"
19
+
20
+ export interface QSpreadsheetCellOption {
21
+ /** Valeur stockée dans la cellule */
22
+ value: any
23
+ /** Libellé affiché */
24
+ label: string
25
+ /** Couleur du badge (token ou hex, ex. "positive", "#22c55e") */
26
+ color?: string
27
+ }
28
+
29
+ export interface QSpreadsheetColumn {
30
+ /** Clé de la propriété dans la ligne */
31
+ name: string
32
+ label?: string
33
+ /** Largeur de colonne (nombre px ou chaîne CSS) — défaut : defaultColWidth */
34
+ width?: number | string
35
+ /** Largeur minimale en px (redimensionnement) */
36
+ minWidth?: number
37
+ /** Largeur maximale en px */
38
+ maxWidth?: number
39
+ align?: "left" | "center" | "right"
40
+ type?: QSpreadsheetCellType
41
+ /** La cellule est éditable (Entrée / double-clic / frappe) */
42
+ editable?: boolean
43
+ /** Pour type "select" : options (badges colorés) */
44
+ options?: QSpreadsheetCellOption[]
45
+ /** Pour type "select" : rend l'option active en badge coloré (sinon texte) */
46
+ chip?: boolean
47
+ /** Validation à la saisie : { min, max, integer?, pattern?, message? } */
48
+ validation?: QSpreadsheetValidation
49
+ /** Formatteur de la valeur affichée */
50
+ format?: (val: any, row: Record<string, any>) => any
51
+ /** Classe CSS de la cellule (défaut : ) */
52
+ cellClass?: (val: any, row: Record<string, any>) => string | undefined
53
+ /** Couleur de fond de la cellule (plan) */
54
+ cellBackground?: (val: any, row: Record<string, any>) => string | undefined
55
+ headerClass?: string
56
+ headerStyle?: string
57
+ }
58
+
59
+ export interface QSpreadsheetValidation {
60
+ /** Valeur minimale (types numériques / entiers) */
61
+ min?: number
62
+ /** Valeur maximale (types numériques / entiers) */
63
+ max?: number
64
+ /** Exige un entier (si présent, valide les entiers) */
65
+ integer?: boolean
66
+ /** Expression régulière validée contre le texte */
67
+ pattern?: string
68
+ /** Champ obligatoire (vide refusé) */
69
+ required?: boolean
70
+ /** Valeurs autorisées (liste) */
71
+ list?: any[]
72
+ /** Message d'erreur affiché (sinon message générique) */
73
+ message?: string
74
+ /** Schéma ArkType (string) — ex. "number.integer & >= 0 & <= 100" (optionnel) */
75
+ schema?: string
76
+ }
77
+
78
+ /** Validation par plage de cellules (rows/cols 0-based) */
79
+ export interface QSpreadsheetRangeValidator {
80
+ r0: number
81
+ c0: number
82
+ r1: number
83
+ c1: number
84
+ validation: QSpreadsheetValidation
85
+ }
86
+
87
+ export interface QSpreadsheetSelection {
88
+ /** Ligne de départ (ancre) */
89
+ row: number
90
+ /** Colonne de départ (ancre) */
91
+ column: string
92
+ /** Ligne de fin (plage ; = row si cellule seule) */
93
+ endRow: number
94
+ /** Colonne de fin (plage ; = column si cellule seule) */
95
+ endColumn: string
96
+ }
97
+
98
+ /** Une feuille d'un classeur multi-onglets (rows = objets par column.name) */
99
+ export interface QSpreadsheetSheet {
100
+ /** Clé stable de la feuille (optionnelle — générée sinon) */
101
+ key?: string
102
+ /** Nom affiché sur l'onglet (défaut "Sheet N") */
103
+ name?: string
104
+ /** Colonnes ({ name, label, type, width, … }) — sinon déduites des rows */
105
+ columns?: QSpreadsheetColumn[]
106
+ /** Lignes de la feuille */
107
+ rows?: Record<string, any>[]
108
+ }
109
+
110
+ /** État complet exportable d'un classeur (JSON / sérialisation) */
111
+ export interface QSpreadsheetDocument {
112
+ version: 1
113
+ active: string
114
+ sheets: (QSpreadsheetSheet & {
115
+ formats?: Record<string, { bold?: boolean; italic?: boolean; bg?: string; color?: string }>
116
+ widths?: Record<string, number | string>
117
+ rowHeights?: Record<number, number>
118
+ filters?: Record<string, string[] | null>
119
+ })[]
120
+ }
121
+ </script>
122
+
123
+ <script setup lang="ts">
124
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
125
+ import { Icon } from "@iconify/vue"
126
+ import { cn } from "../lib/utils"
127
+ import { icons } from "../lib/icons"
128
+ import { colorValue, foregroundFor } from "../lib/colors"
129
+ import { radiusStyle, useRadius, useConfigLang } from "../lib/useComponentProps"
130
+ import type { RadiusProp } from "../lib/useComponentProps"
131
+ import { evaluateFormula, isError, FormulaError } from "../lib/formula"
132
+ import type { FormulaValue } from "../lib/formula"
133
+ import {
134
+ colLetter,
135
+ colFromLetters,
136
+ isBlankValue,
137
+ ISO_DATE,
138
+ isoAddDays,
139
+ shiftFormulaRefs,
140
+ csvEscape,
141
+ deriveCols,
142
+ parseCsv,
143
+ } from "../lib/spreadsheet"
144
+
145
+ interface Props {
146
+ /** Lignes (v-model:rows) — un objet par ligne, clés = noms de colonnes */
147
+ rows?: Record<string, any>[]
148
+ /** Colonnes ({ name, label, width, type, editable, options, chip, format, align, … }) */
149
+ columns?: QSpreadsheetColumn[]
150
+ /** Hauteur du conteneur scrollable (nombre px ou chaîne CSS, défaut "360px") */
151
+ height?: string | number
152
+ /** Hauteur de ligne en px (défaut 32) */
153
+ rowHeight?: number
154
+ /** Largeur de colonne par défaut en px (défaut 160) */
155
+ defaultColWidth?: number
156
+ /** Affiche la barre d'outils */
157
+ showToolbar?: boolean
158
+ /** Affiche la barre de formule (fx) au-dessus de la grille */
159
+ showFormulaBar?: boolean
160
+ /** Nombre de lignes du haut gelées (freeze panes) */
161
+ frozenRows?: number
162
+ /** Nombre de colonnes de gauche gelées (freeze panes) */
163
+ frozenCols?: number
164
+ /** Virtualise les lignes (défaut true — actif au-delà de ~150 lignes) */
165
+ virtualScroll?: boolean
166
+ /** Classeur multi-feuilles (v-model:sheets) — remplace rows/columns */
167
+ sheets?: QSpreadsheetSheet[]
168
+ /** Position des onglets de feuilles : "top" (défaut) ou "bottom" (Excel) */
169
+ sheetsPosition?: "top" | "bottom"
170
+ /** Langue de l'interface : "en" (défaut) ou "fr" */
171
+ lang?: "en" | "fr"
172
+ /** Validations par plage : [{ r0,c0,r1,c1, validation }] — s'ajoutent aux colonnes */
173
+ validators?: QSpreadsheetRangeValidator[]
174
+ /** Affiche le numéro de ligne (colonne de gauche) */
175
+ showRowNumbers?: boolean
176
+ /** Affiche l'en-tête de colonne (lettre + label) */
177
+ showColumnHeaders?: boolean
178
+ /** Sélection (v-model:selected) — { row, column, endRow, endColumn } */
179
+ selected?: QSpreadsheetSelection | null
180
+ /** Hauteur réduite */
181
+ dense?: boolean
182
+ /** Sans ombre */
183
+ flat?: boolean
184
+ /** Bordure */
185
+ bordered?: boolean
186
+ /** Couleur des en-têtes (icônes grisées) */
187
+ dark?: boolean
188
+ /** Désactive l'édition (lecture seule) */
189
+ readonly?: boolean
190
+ /** Désactive tout (lecture seule + grisée) */
191
+ disable?: boolean
192
+ /** Coins arrondis (échelle xs|sm|md|lg ou none) — défaut : composantProps */
193
+ radius?: RadiusProp
194
+ }
195
+
196
+ const props = withDefaults(defineProps<Props>(), {
197
+ rows: () => [],
198
+ columns: () => [],
199
+ height: "360px",
200
+ rowHeight: 32,
201
+ defaultColWidth: 160,
202
+ showToolbar: true,
203
+ showFormulaBar: true,
204
+ frozenRows: 0,
205
+ frozenCols: 0,
206
+ virtualScroll: true,
207
+ sheets: undefined,
208
+ sheetsPosition: "top",
209
+ lang: "en",
210
+ validators: undefined,
211
+ showRowNumbers: true,
212
+ showColumnHeaders: true,
213
+ selected: null,
214
+ dense: false,
215
+ flat: false,
216
+ bordered: false,
217
+ dark: false,
218
+ readonly: false,
219
+ disable: false,
220
+ })
221
+
222
+ // ─── i18n (en | fr) ───
223
+ const I18N = {
224
+ en: {
225
+ sheet: "Sheet",
226
+ fxPlaceholder: "Value or formula (=SUM(A1:A3)…)",
227
+ fxNoSel: "Select a cell…",
228
+ cut: "Cut",
229
+ copy: "Copy",
230
+ paste: "Paste",
231
+ clearContents: "Clear contents",
232
+ insertRowAbove: "Insert row above",
233
+ insertRowBelow: "Insert row below",
234
+ deleteRows: "Delete selected rows",
235
+ insertColLeft: "Insert column left",
236
+ insertColRight: "Insert column right",
237
+ deleteCols: "Delete selected columns",
238
+ sortAsc: "Sort A → Z",
239
+ sortDesc: "Sort Z → A",
240
+ bold: "Bold",
241
+ italic: "Italic",
242
+ wrapText: "Wrap text",
243
+ mergeCells: "Merge cells",
244
+ unmerge: "Unmerge selection",
245
+ hideRows: "Hide selected rows",
246
+ hideCol: "Hide column",
247
+ showHidden: "Show all hidden",
248
+ showCol: "Show column “{name}”",
249
+ condFormat: "Conditional formatting…",
250
+ fillColor: "Fill color",
251
+ textColor: "Text color",
252
+ clearFormat: "Clear formatting",
253
+ rowLbl: "Row",
254
+ colLbl: "Column",
255
+ cellLbl: "Cell",
256
+ findPh: "Find…",
257
+ replacePh: "Replace…",
258
+ replace: "Replace",
259
+ replaceAll: "Replace all",
260
+ close: "Close",
261
+ prev: "Previous",
262
+ next: "Next",
263
+ filterTitle: "Filter column",
264
+ selectAll: "Select all",
265
+ noValues: "{{ t('noValues') }}",
266
+ clearFilter: "Clear filter",
267
+ searchValues: "Search values…",
268
+ blanks: "(Blanks)",
269
+ noRowsFilter: "No rows match the filter.",
270
+ noRowsYet: "No rows yet — add one with the toolbar or right-click.",
271
+ noColsYet: "{{ t('noColsYet') }}",
272
+ condGreater: "greater than",
273
+ condGe: "≥",
274
+ condLess: "less than",
275
+ condLe: "≤",
276
+ condEq: "equal",
277
+ condContains: "contains",
278
+ condBlank: "is blank",
279
+ condNotBlank: "not blank",
280
+ condFormula: "formula (A1)",
281
+ condAdd: "Add rule",
282
+ condUpdate: "Update",
283
+ condColumn: "Column",
284
+ condValue: "Value",
285
+ condClear: "Clear all rules",
286
+ condSelection: "Selection",
287
+ statusRowsShown: "{total} rows · {shown} shown",
288
+ statusRows: "{total} rows",
289
+ statusSheets: "{n} sheets",
290
+ addSheet: "Add sheet",
291
+ removeSheet: "Remove sheet",
292
+ empty: "…",
293
+ },
294
+ fr: {
295
+ sheet: "Feuille",
296
+ fxPlaceholder: "Valeur ou formule (=SOMME(A1:A3)…)",
297
+ fxNoSel: "Sélectionnez une cellule…",
298
+ cut: "Couper",
299
+ copy: "Copier",
300
+ paste: "Coller",
301
+ clearContents: "Effacer le contenu",
302
+ insertRowAbove: "Insérer une ligne au-dessus",
303
+ insertRowBelow: "Insérer une ligne en dessous",
304
+ deleteRows: "Supprimer les lignes sélectionnées",
305
+ insertColLeft: "Insérer une colonne à gauche",
306
+ insertColRight: "Insérer une colonne à droite",
307
+ deleteCols: "Supprimer les colonnes sélectionnées",
308
+ sortAsc: "Trier A → Z",
309
+ sortDesc: "Trier Z → A",
310
+ bold: "Gras",
311
+ italic: "Italique",
312
+ wrapText: "Retour à la ligne",
313
+ mergeCells: "Fusionner les cellules",
314
+ unmerge: "Défusionner la sélection",
315
+ hideRows: "Masquer les lignes sélectionnées",
316
+ hideCol: "Masquer la colonne",
317
+ showHidden: "Tout afficher",
318
+ showCol: "Afficher la colonne «{name}»",
319
+ condFormat: "Mise en forme conditionnelle…",
320
+ fillColor: "Couleur de fond",
321
+ textColor: "Couleur du texte",
322
+ clearFormat: "Effacer la mise en forme",
323
+ rowLbl: "Ligne",
324
+ colLbl: "Colonne",
325
+ cellLbl: "Cellule",
326
+ findPh: "Rechercher…",
327
+ replacePh: "Remplacer…",
328
+ replace: "Remplacer",
329
+ replaceAll: "Tout remplacer",
330
+ close: "Fermer",
331
+ prev: "Précédent",
332
+ next: "Suivant",
333
+ filterTitle: "Filtrer la colonne",
334
+ selectAll: "Tout sélectionner",
335
+ noValues: "Aucune valeur",
336
+ clearFilter: "Effacer le filtre",
337
+ searchValues: "Rechercher des valeurs…",
338
+ blanks: "(Vides)",
339
+ noRowsFilter: "Aucune ligne ne correspond au filtre.",
340
+ noRowsYet: "Aucune ligne — ajoutez-en via la barre d'outils ou un clic droit.",
341
+ noColsYet: "Ajoutez une colonne via la barre d'outils (+) ou un clic droit.",
342
+ condGreater: "supérieur à",
343
+ condGe: "≥",
344
+ condLess: "inférieur à",
345
+ condLe: "≤",
346
+ condEq: "égal à",
347
+ condContains: "contient",
348
+ condBlank: "est vide",
349
+ condNotBlank: "non vide",
350
+ condFormula: "formule (A1)",
351
+ condAdd: "Ajouter la règle",
352
+ condUpdate: "Mettre à jour",
353
+ condColumn: "Colonne",
354
+ condValue: "Valeur",
355
+ condClear: "Tout effacer",
356
+ condSelection: "Sélection",
357
+ statusRowsShown: "{total} lignes · {shown} affichées",
358
+ statusRows: "{total} lignes",
359
+ statusSheets: "{n} feuilles",
360
+ addSheet: "Ajouter une feuille",
361
+ removeSheet: "Supprimer la feuille",
362
+ empty: "…",
363
+ },
364
+ } as const
365
+ type UiKey = keyof (typeof I18N)["en"]
366
+ const configLang = useConfigLang()
367
+ const lang = computed(() => props.lang ?? configLang.value)
368
+ const t = (key: UiKey): string => I18N[lang.value][key] ?? I18N.en[key]
369
+ const fmt = (key: UiKey, params: Record<string, string | number>) =>
370
+ String(t(key)).replace(/\{([a-z]+)\}/g, (_m, k: string) => String(params[k] ?? ""))
371
+ const sheetName = (n: number) => fmt("sheet", {}) + " " + n
372
+
373
+ const emit = defineEmits<{
374
+ "update:rows": [value: Record<string, any>[]]
375
+ "update:selected": [value: QSpreadsheetSelection | null]
376
+ "update:columns": [value: QSpreadsheetColumn[]]
377
+ "update:sheets": [value: QSpreadsheetSheet[]]
378
+ "cell-click": [payload: { row: number; column: string; value: any }]
379
+ "cell-change": [payload: { row: number; column: string; oldValue: any; newValue: any; rows: Record<string, any>[] }]
380
+ "cell-edit-start": [payload: { row: number; column: string }]
381
+ "cell-edit-end": [payload: { row: number; column: string; canceled: boolean }]
382
+ "selection-change": [value: QSpreadsheetSelection | null]
383
+ "structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
384
+ }>()
385
+
386
+ // ─── Données internes (copie éditable des rows) ───
387
+ const state = ref<Record<string, any>[]>([])
388
+ watch(
389
+ () => props.rows,
390
+ (v) => {
391
+ if (v && v !== state.value) state.value = v.map((r) => ({ ...r }))
392
+ },
393
+ { immediate: true },
394
+ )
395
+ const pushRows = (next: Record<string, any>[]) => {
396
+ state.value = next
397
+ emit("update:rows", next)
398
+ }
399
+
400
+ // ─── Colonnes internes ───
401
+ const cols = ref<QSpreadsheetColumn[]>([])
402
+ watch(
403
+ () => props.columns,
404
+ (v) => {
405
+ if (v && v !== cols.value) cols.value = v.map((c) => ({ ...c }))
406
+ },
407
+ { immediate: true },
408
+ )
409
+ const pushCols = (next: QSpreadsheetColumn[]) => {
410
+ cols.value = next
411
+ emit("update:columns", next)
412
+ }
413
+
414
+ const colIndex = (name: string) => cols.value.findIndex((c) => c.name === name)
415
+ const colNameAt = (i: number) => cols.value[i]?.name ?? ""
416
+ const colOf = (name: string) => cols.value.find((c) => c.name === name)
417
+
418
+ // ─── Sélection (ancre + plage) ───
419
+ const sel = ref<QSpreadsheetSelection | null>(null)
420
+ watch(
421
+ () => props.selected,
422
+ (v) => {
423
+ if (v !== sel.value) sel.value = v ? { ...v } : null
424
+ },
425
+ { deep: true },
426
+ )
427
+ const setSel = (s: QSpreadsheetSelection | null) => {
428
+ sel.value = s
429
+ emit("update:selected", s)
430
+ emit("selection-change", s)
431
+ }
432
+
433
+ const inSelection = (row: number, column: string) => {
434
+ const s = sel.value
435
+ if (!s) return false
436
+ const r0 = Math.min(s.row, s.endRow)
437
+ const r1 = Math.max(s.row, s.endRow)
438
+ const ci = colIndex(column)
439
+ const c0 = Math.min(colIndex(s.column), colIndex(s.endColumn))
440
+ const c1 = Math.max(colIndex(s.column), colIndex(s.endColumn))
441
+ return row >= r0 && row <= r1 && ci >= c0 && ci <= c1
442
+ }
443
+ const isActive = (row: number, column: string) => sel.value?.row === row && sel.value?.column === column
444
+
445
+ const selRect = computed(() => {
446
+ const s = sel.value
447
+ if (!s) return null
448
+ const c0 = colIndex(s.column)
449
+ const c1 = colIndex(s.endColumn)
450
+ if (c0 === -1 || c1 === -1) return null
451
+ return {
452
+ r0: Math.min(s.row, s.endRow),
453
+ r1: Math.max(s.row, s.endRow),
454
+ c0: Math.min(c0, c1),
455
+ c1: Math.max(c0, c1),
456
+ }
457
+ })
458
+
459
+ const select = (row: number, column: string, extend = false) => {
460
+ if (!cols.value.length) return
461
+ row = Math.max(0, Math.min(state.value.length - 1, row))
462
+ ensureRowVisible(row)
463
+ const s = sel.value
464
+ if (extend && s) {
465
+ setSel({ ...s, endRow: row, endColumn: column })
466
+ } else {
467
+ setSel({ row, column, endRow: row, endColumn: column })
468
+ }
469
+ if (pendingFocus.value) pendingFocus.value.col = column
470
+ nextTick(() => {
471
+ focusCell(row, column)
472
+ positionEditor()
473
+ })
474
+ }
475
+
476
+ const moveSelection = (dr: number, dc: number, extend = false) => {
477
+ if (!sel.value || !cols.value.length) return
478
+ const { row, column } = sel.value
479
+ const ci = colIndex(column)
480
+ let nr = Math.max(0, Math.min(state.value.length - 1, row + dr))
481
+ // Filtre actif : saute les lignes masquées lors des déplacements verticaux
482
+ if (dr !== 0 && hasActiveFilters.value) {
483
+ const dir = dr > 0 ? 1 : -1
484
+ while (nr >= 0 && nr < state.value.length && !isRowVisible(nr)) nr += dir
485
+ nr = Math.max(0, Math.min(state.value.length - 1, nr))
486
+ }
487
+ let nc = Math.max(0, Math.min(cols.value.length - 1, ci + dc))
488
+ // Colonnes masquées : sautées en horizontal
489
+ if (dc !== 0) {
490
+ const dir = dc > 0 ? 1 : -1
491
+ while (nc >= 0 && nc < cols.value.length && isColHiddenName(cols.value[nc]!.name))
492
+ nc += dir
493
+ nc = Math.max(0, Math.min(cols.value.length - 1, nc))
494
+ }
495
+ select(nr, colNameAt(nc), extend)
496
+ }
497
+
498
+ const escAttr = (s: string) => String(s).replace(/"/g, '\\"')
499
+ const scrollEl = ref<HTMLElement | null>(null)
500
+ const rootEl = ref<HTMLElement | null>(null)
501
+ const setGridRef = (el: unknown) => {
502
+ rootEl.value = (el as HTMLElement) ?? null
503
+ scrollEl.value = (el as HTMLElement) ?? null
504
+ }
505
+ const focusCell = (row: number, column: string) => {
506
+ const td = scrollEl.value?.querySelector<HTMLElement>(
507
+ `td[data-row="${row}"][data-col="${escAttr(column)}"]`,
508
+ )
509
+ if (!td) {
510
+ rootEl.value?.focus({ preventScroll: true })
511
+ return
512
+ }
513
+ // Révèle la cellule DANS le viewport uniquement (jamais la page) :
514
+ // on défile le conteneur interne si besoin, puis on focus sans scroll natif.
515
+ const sc = scrollEl.value!
516
+ const sr = sc.getBoundingClientRect()
517
+ const tr = td.getBoundingClientRect()
518
+ const topOffset = headH.value ?? 0 // barre d'en-tête sticky
519
+ const innerTop = tr.top - sr.top + sc.scrollTop
520
+ const innerBottom = innerTop + tr.height
521
+ let target: number | null = null
522
+ if (innerTop < sc.scrollTop + topOffset) target = innerTop - topOffset
523
+ else if (innerBottom > sc.scrollTop + sc.clientHeight) {
524
+ target = innerBottom - sc.clientHeight
525
+ }
526
+ if (target !== null) {
527
+ sc.scrollTop = Math.max(0, target)
528
+ updateRange()
529
+ }
530
+ td.focus({ preventScroll: true })
531
+ }
532
+
533
+ // ─── Formules A1 (évaluation mémorisée, cycle-safe) ───
534
+ // Une valeur de cellule qui commence par "=" est une formule :
535
+ // =B2*C2, =SUM(D2:D5), =IF(A2>10,"big","small"), $B$2 (absolue)
536
+ const cellValue = (row: number, column: string) => state.value[row]?.[column]
537
+
538
+ const isFormulaRaw = (v: any) => typeof v === "string" && v.trim().startsWith("=")
539
+
540
+ const evaluated = computed<FormulaValue[][]>(() => {
541
+ const rows = state.value
542
+ const memo = new Map<string, FormulaValue>()
543
+ const visiting = new Set<string>()
544
+
545
+ const get = (r: number, c: number): FormulaValue => {
546
+ const key = r + ":" + c
547
+ if (memo.has(key)) return memo.get(key)!
548
+ if (visiting.has(key)) return new FormulaError("#CYCLE!")
549
+ const row = rows[r]
550
+ const col = cols.value[c]
551
+ if (!row || !col) return new FormulaError("#REF!")
552
+ const raw = row[col.name]
553
+ if (raw === null || raw === undefined || raw === "") {
554
+ memo.set(key, null)
555
+ return null
556
+ }
557
+ let val: FormulaValue = raw as FormulaValue
558
+ if (isFormulaRaw(raw)) {
559
+ visiting.add(key)
560
+ val = evaluateFormula(raw, {
561
+ row: r,
562
+ resolveField: (name) => {
563
+ const i = colIndex(name)
564
+ return i === -1 ? undefined : get(r, i)
565
+ },
566
+ resolveCell: (rr, cc) => get(rr, cc),
567
+ })
568
+ visiting.delete(key)
569
+ }
570
+ memo.set(key, val)
571
+ return val
572
+ }
573
+
574
+ const out: FormulaValue[][] = []
575
+ for (let r = 0; r < rows.length; r++) {
576
+ const line: FormulaValue[] = []
577
+ for (let c = 0; c < cols.value.length; c++) line.push(get(r, c))
578
+ out.push(line)
579
+ }
580
+ return out
581
+ })
582
+
583
+ /** Valeur affichable d'une cellule (formule évaluée, sinon valeur brute) */
584
+ const evalAt = (row: number, column: string): FormulaValue | undefined => {
585
+ const ci = colIndex(column)
586
+ if (ci === -1) return undefined
587
+ return evaluated.value[row]?.[ci]
588
+ }
589
+
590
+ const isErrorCell = (row: number, column: string): boolean =>
591
+ isError(evalAt(row, column))
592
+
593
+ const cellText = (row: number, column: string): string => {
594
+ const col = colOf(column)
595
+ const ev = evalAt(row, column)
596
+ if (isError(ev)) return ev.toString()
597
+ if (col?.type === "select") return "" // rendu via badge / label
598
+ let out: any = ev === null || ev === undefined ? "" : ev
599
+ // Apostrophe littérale (paste values) : affichée sans le préfixe
600
+ if (typeof out === "string" && out.startsWith("'")) out = out.slice(1)
601
+ if (col?.format && out !== "") out = col.format(out, state.value[row] ?? {})
602
+ if (out === null || out === undefined || out === "") return ""
603
+ return String(out)
604
+ }
605
+
606
+ const cellTitle = (row: number, column: string): string | undefined => {
607
+ const col = colOf(column)
608
+ const v = state.value[row]?.[column]
609
+ if (isFormulaRaw(v)) return String(v)
610
+ if (col?.type === "select") {
611
+ const opt = col.options?.find((o) => o.value === v)
612
+ return opt?.label ?? (v === null || v === undefined ? undefined : String(v))
613
+ }
614
+ return undefined
615
+ }
616
+
617
+ const cellClasses = (row: number, column: string) => {
618
+ const col = colOf(column)
619
+ const v = state.value[row]?.[column]
620
+ return cn(
621
+ "q-spreadsheet__cell",
622
+ col?.align === "center" && "q-spreadsheet__cell--center",
623
+ col?.align === "right" && "q-spreadsheet__cell--right",
624
+ col?.type === "number" && "q-spreadsheet__cell--number",
625
+ col?.type === "integer" && "q-spreadsheet__cell--number",
626
+ col?.cellClass?.(v, state.value[row] ?? {}),
627
+ isErrorCell(row, column) && "q-spreadsheet__cell--error",
628
+ inSelection(row, column) && !isActive(row, column) && "q-spreadsheet__cell--in-range",
629
+ isActive(row, column) && "q-spreadsheet__cell--active",
630
+ editing.value?.row === row && editing.value?.column === column && "q-spreadsheet__cell--editing",
631
+ props.readonly && "q-spreadsheet__cell--readonly",
632
+ )
633
+ }
634
+
635
+ const cellStyle = (row: number, column: string) => {
636
+ const col = colOf(column)
637
+ const v = state.value[row]?.[column]
638
+ const bg = col?.cellBackground?.(v, state.value[row] ?? {})
639
+ return bg ? { backgroundColor: bg } : undefined
640
+ }
641
+
642
+ // ─── Édition ───
643
+ const editing = ref<{ row: number; column: string } | null>(null)
644
+ const draft = ref("")
645
+ const editorEl = ref<HTMLElement | null>(null)
646
+ const editInputEl = ref<HTMLInputElement | null>(null)
647
+ const editAreaEl = ref<HTMLTextAreaElement | null>(null)
648
+ const editorStyle = ref<Record<string, string>>({})
649
+
650
+ const columnOf = (name: string) => colOf(name)
651
+
652
+ /** L'éditeur bascule en textarea monospace dès que le draft est une formule (=…) */
653
+ const editingIsFormula = computed(() => {
654
+ if (!editing.value) return false
655
+ const col = colOf(editing.value.column)
656
+ if (!col || col.type === "boolean" || col.type === "select") return false
657
+ return draft.value.trim().startsWith("=")
658
+ })
659
+ const editingIsSelect = computed(() => {
660
+ const col = editing.value ? colOf(editing.value.column) : undefined
661
+ return col?.type === "select"
662
+ })
663
+ const draftLines = computed(() =>
664
+ Math.min(5, Math.max(1, draft.value.split("\n").length)),
665
+ )
666
+
667
+ const editorInputType = computed(() => {
668
+ const col = editing.value ? colOf(editing.value.column) : undefined
669
+ switch (col?.type) {
670
+ case "number":
671
+ case "integer":
672
+ return "number"
673
+ case "email":
674
+ return "email"
675
+ case "url":
676
+ return "url"
677
+ case "date":
678
+ return "date"
679
+ case "datetime":
680
+ return "datetime-local"
681
+ default:
682
+ return "text"
683
+ }
684
+ })
685
+
686
+ /** step natif : "any" (décimales) pour number, "1" pour integer */
687
+ const editorStep = computed(() => {
688
+ const col = editing.value ? colOf(editing.value.column) : undefined
689
+ if (col?.type === "number") return "any"
690
+ if (col?.type === "integer") return "1"
691
+ return undefined
692
+ })
693
+
694
+ const startEdit = (row: number, column: string, initial?: string) => {
695
+ if (props.readonly || props.disable) return
696
+ const col = colOf(column)
697
+ if (col?.editable === false || col?.type === "boolean") return
698
+ const raw = state.value[row]?.[column]
699
+ if (col?.type === "select") {
700
+ const opt = col.options?.find((o) => o.value === raw)
701
+ draft.value = initial ?? opt?.label ?? ""
702
+ } else {
703
+ let rawStr = String(raw ?? "")
704
+ if (rawStr.startsWith("'")) rawStr = rawStr.slice(1)
705
+ draft.value = initial ?? rawStr
706
+ }
707
+ editing.value = { row, column }
708
+ emit("cell-edit-start", { row, column })
709
+ nextTick(positionEditor)
710
+ }
711
+
712
+ const positionEditor = () => {
713
+ if (!editing.value || !scrollEl.value) return
714
+ const td = scrollEl.value.querySelector<HTMLElement>(
715
+ `td[data-row="${editing.value.row}"][data-col="${escAttr(editing.value.column)}"]`,
716
+ )
717
+ if (!td) return
718
+ const sc = scrollEl.value.getBoundingClientRect()
719
+ const tr = td.getBoundingClientRect()
720
+ const left = tr.left - sc.left + scrollEl.value.scrollLeft
721
+ const top = tr.top - sc.top + scrollEl.value.scrollTop
722
+ // Une formule élargit l'éditeur (monospace ≈ 7.6px/car), sans dépasser le bord droit
723
+ const maxW = sc.width - (tr.left - sc.left) - 4
724
+ const ideal = editingIsFormula.value
725
+ ? Math.min(480, Math.max(220, draft.value.length * 7.6 + 30))
726
+ : tr.width
727
+ const width = Math.min(maxW, Math.max(tr.width, ideal))
728
+ editorStyle.value = {
729
+ left: left + "px",
730
+ top: top + "px",
731
+ width: width + "px",
732
+ minHeight: tr.height + "px",
733
+ }
734
+ nextTick(() => {
735
+ const el = editingIsFormula.value ? editAreaEl.value : editInputEl.value
736
+ el?.focus({ preventScroll: true })
737
+ if (!editingIsSelect.value) el?.select()
738
+ })
739
+ }
740
+
741
+ // Quand le draft devient une formule (=…), l'éditeur input → textarea : refocus
742
+ watch(editingIsFormula, () => {
743
+ if (!editing.value) return
744
+ nextTick(() => {
745
+ const el = editingIsFormula.value ? editAreaEl.value : editInputEl.value
746
+ el?.focus({ preventScroll: true })
747
+ })
748
+ })
749
+
750
+ const cancelEdit = () => {
751
+ if (!editing.value) return
752
+ const cell = { ...editing.value }
753
+ editing.value = null
754
+ emit("cell-edit-end", { ...cell, canceled: true })
755
+ nextTick(() => focusCell(cell.row, cell.column))
756
+ }
757
+
758
+ const coerceValue = (col: QSpreadsheetColumn | undefined, text: string, old: any) => {
759
+ const t = text.trim()
760
+ if (t === "") return null
761
+ // Formule A1 : conservée brute dans la cellule (booléen/select exclus)
762
+ if (t.startsWith("=")) {
763
+ if (col?.type === "boolean" || col?.type === "select") return old
764
+ return text
765
+ }
766
+ if (col?.type === "number") {
767
+ const n = Number(text)
768
+ return Number.isNaN(n) ? old : n
769
+ }
770
+ if (col?.type === "integer") {
771
+ const n = Number(text)
772
+ return Number.isNaN(n) ? old : Math.trunc(n)
773
+ }
774
+ if (col?.type === "boolean") {
775
+ return text === "true" ? true : text === "false" ? false : old
776
+ }
777
+ if (col?.type === "select") {
778
+ const opt = col.options?.find((o) => o.label === text || String(o.value) === text)
779
+ return opt ? opt.value : old
780
+ }
781
+ return text
782
+ }
783
+
784
+ const commitEdit = async () => {
785
+ if (!editing.value) return
786
+ const { row, column } = editing.value
787
+ const col = colOf(column)
788
+ const old = state.value[row]?.[column]
789
+ const text = draft.value
790
+ const next = coerceValue(col, text, old)
791
+ editing.value = null
792
+ emit("cell-edit-end", { row, column, canceled: false })
793
+ nextTick(() => focusCell(row, column))
794
+ if (next !== old) await validateAndSet(row, column, old, next)
795
+ }
796
+
797
+ const setCellValue = (row: number, column: string, old: any, next: any) => {
798
+ const nextRow = { ...state.value[row], [column]: next }
799
+ const nextRows = [...state.value]
800
+ nextRows[row] = nextRow
801
+ pushRows(nextRows)
802
+ emit("cell-change", { row, column, oldValue: old, newValue: next, rows: nextRows })
803
+ }
804
+
805
+ const clearCell = (row: number, column: string) => {
806
+ const col = colOf(column)
807
+ if (col?.editable === false || props.readonly || props.disable) return
808
+ const old = state.value[row]?.[column]
809
+ if (old === null || old === undefined || old === "") return
810
+ setCellValue(row, column, old, null)
811
+ }
812
+
813
+ const toggleBoolean = (row: number, column: string) => {
814
+ if (props.readonly || props.disable) return
815
+ const col = colOf(column)
816
+ if (col?.editable === false) return
817
+ const old = !!state.value[row]?.[column]
818
+ setCellValue(row, column, old, !old)
819
+ }
820
+
821
+ const clearSelection = () => {
822
+ const rect = selRect.value
823
+ if (!rect) return
824
+ const changed: { row: number; column: string }[] = []
825
+ for (let r = rect.r0; r <= rect.r1; r++) {
826
+ for (let c = rect.c0; c <= rect.c1; c++) {
827
+ const col = cols.value[c]
828
+ if (!col || col.editable === false || props.readonly || props.disable) continue
829
+ const v = state.value[r]?.[col.name]
830
+ if (v !== null && v !== undefined && v !== "") changed.push({ row: r, column: col.name })
831
+ }
832
+ }
833
+ if (!changed.length) return
834
+ pushHistory()
835
+ for (const cell of changed)
836
+ setCellValue(cell.row, cell.column, state.value[cell.row]?.[cell.column], null)
837
+ }
838
+
839
+ const selectRow = (row: number, extend = false) => {
840
+ if (!cols.value.length || row < 0 || row >= state.value.length) return
841
+ const s = sel.value
842
+ if (extend && s) setSel({ ...s, endRow: row, endColumn: colNameAt(cols.value.length - 1) })
843
+ else setSel({ row, column: colNameAt(0), endRow: row, endColumn: colNameAt(cols.value.length - 1) })
844
+ nextTick(() => focusCell(row, colNameAt(0)))
845
+ }
846
+
847
+ const selectCol = (ci: number, extend = false) => {
848
+ if (!state.value.length || !cols.value[ci]) return
849
+ const name = colNameAt(ci)
850
+ const last = state.value.length - 1
851
+ const s = sel.value
852
+ if (extend && s) setSel({ ...s, endRow: last, endColumn: name })
853
+ else setSel({ row: 0, column: name, endRow: last, endColumn: name })
854
+ nextTick(() => focusCell(0, name))
855
+ }
856
+
857
+ const selectAll = () => {
858
+ if (!cols.value.length || !state.value.length) return
859
+ setSel({
860
+ row: 0,
861
+ column: colNameAt(0),
862
+ endRow: state.value.length - 1,
863
+ endColumn: colNameAt(cols.value.length - 1),
864
+ })
865
+ nextTick(() => focusCell(0, colNameAt(0)))
866
+ }
867
+
868
+ const isFullSelection = computed(() => {
869
+ if (!cols.value.length || !state.value.length) return false
870
+ const rect = selRect.value
871
+ if (!rect) return false
872
+ return (
873
+ rect.r0 === 0 &&
874
+ rect.r1 === state.value.length - 1 &&
875
+ rect.c0 === 0 &&
876
+ rect.c1 === cols.value.length - 1
877
+ )
878
+ })
879
+
880
+ // ─── Drag (sélection par glisser) ───
881
+ const isPointerDown = ref(false)
882
+ const onCellPointerDown = (row: number, column: string, e: PointerEvent) => {
883
+ if (props.disable) return
884
+ const mi = mergeInfoOf(row, colIndex(column))
885
+ if (mi && !mi.owner) {
886
+ const m = mergeOwner(row, colIndex(column))!
887
+ row = m.r0
888
+ column = colNameAt(m.c0)
889
+ }
890
+ isPointerDown.value = true
891
+ select(row, column, e.shiftKey)
892
+ emit("cell-click", { row, column, value: cellValue(row, column) })
893
+ }
894
+ const onCellPointerEnter = (row: number, column: string) => {
895
+ if (props.disable || !isPointerDown.value) return
896
+ const s = sel.value
897
+ if (!s) return
898
+ setSel({ ...s, endRow: row, endColumn: column })
899
+ }
900
+
901
+ // ─── Historique (undo / redo) ───
902
+ interface CellFormat {
903
+ bold?: boolean
904
+ italic?: boolean
905
+ wrap?: boolean
906
+ bg?: string
907
+ color?: string
908
+ }
909
+ interface Snapshot {
910
+ rows: Record<string, any>[]
911
+ cols: QSpreadsheetColumn[]
912
+ formats: Record<string, CellFormat>
913
+ rowHeights: Record<number, number>
914
+ widths: Record<string, number | string>
915
+ filters: Record<string, string[] | null>
916
+ hiddenRows: number[]
917
+ hiddenCols: string[]
918
+ merges: MergeRange[]
919
+ rules: CondRule[]
920
+ }
921
+ const undoStack: Snapshot[] = []
922
+ const redoStack: Snapshot[] = []
923
+
924
+ const takeSnapshot = (): Snapshot => ({
925
+ rows: state.value.map((r) => ({ ...r })),
926
+ cols: cols.value.map((c) => ({ ...c })),
927
+ formats: { ...cellFmt.value },
928
+ rowHeights: { ...rowHeights.value },
929
+ widths: { ...colWidths.value },
930
+ filters: JSON.parse(JSON.stringify(filters.value)),
931
+ hiddenRows: [...hiddenRows.value],
932
+ hiddenCols: [...hiddenCols.value],
933
+ merges: merges.value.map((m) => ({ ...m })),
934
+ rules: condRules.value.map((r) => ({ ...r })),
935
+ })
936
+ const pushHistory = () => {
937
+ undoStack.push(takeSnapshot())
938
+ if (undoStack.length > 100) undoStack.shift()
939
+ redoStack.length = 0
940
+ }
941
+ const canUndo = computed(() => undoStack.length > 0 && !props.readonly && !props.disable)
942
+ const canRedo = computed(() => redoStack.length > 0 && !props.readonly && !props.disable)
943
+
944
+ const restoreSnapshot = (s: Snapshot) => {
945
+ cols.value = s.cols.map((c) => ({ ...c }))
946
+ state.value = s.rows.map((r) => ({ ...r }))
947
+ cellFmt.value = { ...s.formats }
948
+ rowHeights.value = { ...s.rowHeights }
949
+ colWidths.value = { ...s.widths }
950
+ filters.value = JSON.parse(JSON.stringify(s.filters))
951
+ hiddenRows.value = [...s.hiddenRows]
952
+ hiddenCols.value = [...s.hiddenCols]
953
+ merges.value = s.merges.map((m) => ({ ...m }))
954
+ condRules.value = s.rules.map((r) => ({ ...r }))
955
+ emit("update:columns", cols.value)
956
+ pushRows(state.value)
957
+ }
958
+ const undo = () => {
959
+ if (!canUndo.value) return
960
+ redoStack.push(takeSnapshot())
961
+ restoreSnapshot(undoStack.pop()!)
962
+ emit("structure-change", { rows: state.value, columns: cols.value, reason: "undo" })
963
+ }
964
+ const redo = () => {
965
+ if (!canRedo.value) return
966
+ undoStack.push(takeSnapshot())
967
+ restoreSnapshot(redoStack.pop()!)
968
+ emit("structure-change", { rows: state.value, columns: cols.value, reason: "redo" })
969
+ }
970
+
971
+ // ─── Lignes & colonnes ───
972
+ const blankRow = (): Record<string, any> => {
973
+ const row: Record<string, any> = {}
974
+ for (const col of cols.value) row[col.name] = col.type === "boolean" ? false : ""
975
+ return row
976
+ }
977
+
978
+ const addRow = (at?: number) => {
979
+ if (props.readonly || props.disable) return
980
+ pushHistory()
981
+ const idx = at ?? state.value.length
982
+ const next = [...state.value]
983
+ next.splice(idx, 0, blankRow())
984
+ state.value = next
985
+ emit("update:rows", next)
986
+ emit("structure-change", { rows: next, columns: cols.value, reason: "add-row" })
987
+ if (cols.value.length) select(idx, colNameAt(0))
988
+ }
989
+
990
+ const removeSelectedRows = () => {
991
+ if (props.readonly || props.disable) return
992
+ pushHistory()
993
+ purgeMergesAndRules()
994
+ const rect = selRect.value
995
+ if (!rect || !state.value.length) return
996
+ const indexes = new Set<number>()
997
+ for (let r = rect.r0; r <= rect.r1; r++) indexes.add(r)
998
+ const sorted = [...indexes].sort((a, b) => b - a)
999
+ const next = [...state.value]
1000
+ for (const i of sorted) next.splice(i, 1)
1001
+ state.value = next
1002
+ dropRowRangeKeys(rect.r0, rect.r1, indexes.size)
1003
+ emit("update:rows", next)
1004
+ emit("structure-change", { rows: next, columns: cols.value, reason: "remove-row" })
1005
+ if (next.length) {
1006
+ const target = Math.min(sorted[sorted.length - 1]!, next.length - 1)
1007
+ select(target, colNameAt(0))
1008
+ } else setSel(null)
1009
+ }
1010
+
1011
+ const addColumn = (at?: number) => {
1012
+ if (props.readonly || props.disable) return
1013
+ pushHistory()
1014
+ let n = cols.value.length + 1
1015
+ const used = new Set(cols.value.map((c) => c.name))
1016
+ let name = "column" + n
1017
+ while (used.has(name)) name = "column" + ++n
1018
+ const col: QSpreadsheetColumn = { name, label: "Column " + n, type: "text" }
1019
+ const idx = at ?? cols.value.length
1020
+ const nextCols = [...cols.value]
1021
+ nextCols.splice(idx, 0, col)
1022
+ cols.value = nextCols
1023
+ const nextRows = state.value.map((r) => ({ ...r, [name]: "" }))
1024
+ state.value = nextRows
1025
+ emit("update:columns", nextCols)
1026
+ emit("update:rows", nextRows)
1027
+ emit("structure-change", { rows: nextRows, columns: nextCols, reason: "add-column" })
1028
+ if (state.value.length) select(0, name)
1029
+ }
1030
+
1031
+ const removeSelectedColumns = () => {
1032
+ if (props.readonly || props.disable) return
1033
+ pushHistory()
1034
+ purgeMergesAndRules()
1035
+ const rect = selRect.value
1036
+ if (!rect || !cols.value.length) return
1037
+ const indexes = new Set<number>()
1038
+ for (let c = rect.c0; c <= rect.c1; c++) indexes.add(c)
1039
+ const removed = [...indexes].sort((a, b) => b - a)
1040
+ const removedNames = removed.map((i) => cols.value[i]!.name)
1041
+ const nextCols = cols.value.filter((_, i) => !indexes.has(i))
1042
+ cols.value = nextCols
1043
+ dropColumnKeys(removedNames)
1044
+ const nextRows = state.value.map((r) => {
1045
+ const nr = { ...r }
1046
+ for (const nm of removedNames) delete nr[nm]
1047
+ return nr
1048
+ })
1049
+ state.value = nextRows
1050
+ emit("update:columns", nextCols)
1051
+ emit("update:rows", nextRows)
1052
+ emit("structure-change", { rows: nextRows, columns: nextCols, reason: "remove-column" })
1053
+ const target = Math.min(removed[removed.length - 1]!, Math.max(0, nextCols.length - 1))
1054
+ if (nextCols.length && state.value.length) select(0, colNameAt(target))
1055
+ else setSel(null)
1056
+ }
1057
+
1058
+ // ─── Tri ───
1059
+ const sortByColumn = (column?: string, desc = false) => {
1060
+ if (props.readonly || props.disable) return
1061
+ const name = column ?? sel.value?.column ?? colNameAt(0)
1062
+ const col = colOf(name)
1063
+ if (!col || !state.value.length) return
1064
+ pushHistory()
1065
+ const ci = colIndex(name)
1066
+ const getVal = (i: number) => {
1067
+ const raw = state.value[i]?.[name]
1068
+ const ev = ci === -1 ? undefined : evalAt(i, name)
1069
+ if (ev === null || ev === undefined || isError(ev)) {
1070
+ return raw === null || raw === undefined ? "" : String(raw).toLowerCase()
1071
+ }
1072
+ if (col?.type === "number" || col?.type === "integer") return Number(ev) || 0
1073
+ if (col?.type === "boolean") return ev ? 1 : 0
1074
+ if (col?.type === "select") {
1075
+ const opt = col.options?.find((o) => o.value === raw)
1076
+ return (opt?.label ?? String(ev)).toLowerCase()
1077
+ }
1078
+ return String(ev).toLowerCase()
1079
+ }
1080
+ const sortedIdx = state.value
1081
+ .map((_, i) => i)
1082
+ .sort((a, b) => {
1083
+ const va = getVal(a)
1084
+ const vb = getVal(b)
1085
+ if (va < vb) return desc ? 1 : -1
1086
+ if (va > vb) return desc ? -1 : 1
1087
+ return 0
1088
+ })
1089
+ const sorted = sortedIdx.map((i) => state.value[i]!)
1090
+ state.value = sorted
1091
+ emit("update:rows", sorted)
1092
+ emit("structure-change", { rows: sorted, columns: cols.value, reason: "sort" })
1093
+ }
1094
+
1095
+ // ─── Copier / coller (TSV) ───
1096
+ const internalClip = ref<string[][]>([])
1097
+
1098
+ const copySelection = async () => {
1099
+ const rect = selRect.value
1100
+ if (!rect) return
1101
+ const lines: string[][] = []
1102
+ for (let r = rect.r0; r <= rect.r1; r++) {
1103
+ const line: string[] = []
1104
+ for (let c = rect.c0; c <= rect.c1; c++) {
1105
+ const name = colNameAt(c)
1106
+ const col = colOf(name)
1107
+ const raw = state.value[r]?.[name]
1108
+ let text: string
1109
+ if (col?.type === "select") {
1110
+ const opt = col.options?.find((o) => o.value === raw)
1111
+ text = opt
1112
+ ? opt.label
1113
+ : raw === null || raw === undefined || raw === ""
1114
+ ? ""
1115
+ : String(raw)
1116
+ } else {
1117
+ // Formules : copie la valeur AFFICHÉE (évaluée), comme Excel
1118
+ text = cellText(r, name)
1119
+ }
1120
+ line.push(text)
1121
+ }
1122
+ lines.push(line)
1123
+ }
1124
+ internalClip.value = lines
1125
+ try {
1126
+ await navigator.clipboard.writeText(lines.map((l) => l.join("\t")).join("\n"))
1127
+ } catch {
1128
+ /* presse-papiers indisponible */
1129
+ }
1130
+ }
1131
+
1132
+ const pasteClip = async () => {
1133
+ if (props.readonly || props.disable) return
1134
+ let lines = internalClip.value
1135
+ if (!lines.length) {
1136
+ try {
1137
+ const text = await navigator.clipboard.readText()
1138
+ lines = text.split(/\r?\n/).map((l) => l.split("\t"))
1139
+ } catch {
1140
+ return
1141
+ }
1142
+ }
1143
+ const s = sel.value
1144
+ if (!lines.length || !s) return
1145
+ pushHistory()
1146
+ const startRow = s.row
1147
+ const startCol = Math.max(0, colIndex(s.column))
1148
+ lines.forEach((line, i) => {
1149
+ line.forEach((text, j) => {
1150
+ const r = startRow + i
1151
+ const c = startCol + j
1152
+ if (r >= state.value.length || c >= cols.value.length) return
1153
+ const col = cols.value[c]!
1154
+ if (col.editable === false) return
1155
+ const old = state.value[r]?.[col.name]
1156
+ const next = coerceValue(col, text, old)
1157
+ if (next !== old) setCellValue(r, col.name, old, next)
1158
+ })
1159
+ })
1160
+ internalClip.value = lines
1161
+ }
1162
+
1163
+ // ─── Aides navigation Excel (bords, page) ───
1164
+ const viewH = ref(0)
1165
+ const updateViewportMetrics = () => {
1166
+ viewH.value = scrollEl.value?.clientHeight ?? 0
1167
+ }
1168
+
1169
+ const nextVisibleIndex = (from: number, dir: 1 | -1): number => {
1170
+ const i = from + dir
1171
+ return i >= 0 && i < visibleRows.value.length ? i : -1
1172
+ }
1173
+ const jumpPage = (dir: 1 | -1, extend = false) => {
1174
+ if (!sel.value || !visibleRows.value.length) return
1175
+ let idx = visibleRows.value.indexOf(sel.value.row)
1176
+ if (idx < 0) idx = 0
1177
+ const page = Math.max(1, Math.floor(viewH.value / props.rowHeight))
1178
+ let target = idx
1179
+ for (let k = 0; k < page; k++) {
1180
+ const n = nextVisibleIndex(target, dir)
1181
+ if (n < 0) break
1182
+ target = n
1183
+ }
1184
+ select(visibleRows.value[target]!, sel.value.column, extend)
1185
+ }
1186
+ const jumpEdge = (dr: number, dc: number, extend = false) => {
1187
+ if (!sel.value) return
1188
+ const { row, column } = sel.value
1189
+ const ci = colIndex(column)
1190
+ const colHas = (r: number, c: number) => {
1191
+ const v = state.value[r]?.[colNameAt(c)]
1192
+ return !isBlankValue(v)
1193
+ }
1194
+ if (dr === 0) {
1195
+ let c = ci
1196
+ const startHas = colHas(row, c)
1197
+ while (true) {
1198
+ const nc = c + dc
1199
+ if (nc < 0 || nc >= cols.value.length) break
1200
+ const nv = colHas(row, nc)
1201
+ if (nv === startHas) c = nc
1202
+ else if (!startHas) {
1203
+ c = nc
1204
+ break
1205
+ } else break
1206
+ }
1207
+ select(row, colNameAt(c), extend)
1208
+ } else {
1209
+ let r = row
1210
+ const startHas = colHas(r, ci)
1211
+ while (true) {
1212
+ const nr = r + dr
1213
+ if (nr < 0 || nr >= state.value.length || !isRowVisible(nr)) break
1214
+ const nv = colHas(nr, ci)
1215
+ if (nv === startHas) r = nr
1216
+ else if (!startHas) {
1217
+ r = nr
1218
+ break
1219
+ } else break
1220
+ }
1221
+ select(r, column, extend)
1222
+ }
1223
+ }
1224
+ const jumpStart = (end = false) => {
1225
+ if (!sel.value) return
1226
+ const name = end ? colNameAt(cols.value.length - 1) : colNameAt(0)
1227
+ select(sel.value.row, name)
1228
+ }
1229
+ const jumpCorner = (end = false) => {
1230
+ if (!cols.value.length || !state.value.length) return
1231
+ if (!end) {
1232
+ select(0, colNameAt(0))
1233
+ return
1234
+ }
1235
+ let last = state.value.length - 1
1236
+ while (last > 0 && !Object.values(state.value[last] ?? {}).some((v) => !isBlankValue(v))) last--
1237
+ select(last, colNameAt(cols.value.length - 1))
1238
+ }
1239
+
1240
+ // ─── Ctrl+D / Ctrl+R : recopie vers le bas / la droite ───
1241
+ const fillDownKey = () => {
1242
+ const rect = selRect.value
1243
+ if (!rect || rect.r0 <= 0) return
1244
+ if (props.readonly || props.disable) return
1245
+ pushHistory()
1246
+ for (let c = rect.c0; c <= rect.c1; c++) {
1247
+ const name = colNameAt(c)
1248
+ const col = colOf(name)
1249
+ if (!col || col.editable === false) continue
1250
+ const src = rect.r0 - 1
1251
+ const sv = state.value[src]?.[name]
1252
+ for (let r = rect.r0; r <= rect.r1; r++) {
1253
+ const old = state.value[r]?.[name]
1254
+ if (old !== sv) setCellValue(r, name, old, sv)
1255
+ }
1256
+ }
1257
+ focusCell(sel.value!.row, sel.value!.column)
1258
+ }
1259
+ const fillRightKey = () => {
1260
+ const rect = selRect.value
1261
+ if (!rect || rect.c0 <= 0) return
1262
+ if (props.readonly || props.disable) return
1263
+ pushHistory()
1264
+ for (let r = rect.r0; r <= rect.r1; r++) {
1265
+ const src = rect.c0 - 1
1266
+ const sname = colNameAt(src)
1267
+ const col = colOf(sname)
1268
+ if (!col || col.editable === false) continue
1269
+ const sv = state.value[r]?.[sname]
1270
+ for (let c = rect.c0; c <= rect.c1; c++) {
1271
+ const name = colNameAt(c)
1272
+ const old = state.value[r]?.[name]
1273
+ if (old !== sv) setCellValue(r, name, old, sv)
1274
+ }
1275
+ }
1276
+ focusCell(sel.value!.row, sel.value!.column)
1277
+ }
1278
+
1279
+ // ─── Clavier global (navigation / édition) ───
1280
+ const onKeydown = (e: KeyboardEvent) => {
1281
+ if (editing.value) return
1282
+ if (!sel.value || !cols.value.length) return
1283
+ const mod = e.ctrlKey || e.metaKey
1284
+ const shift = e.shiftKey
1285
+ const target = e.target as HTMLElement
1286
+ if (
1287
+ target &&
1288
+ (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT")
1289
+ )
1290
+ return
1291
+ if (mod && e.key.toLowerCase() === "z") {
1292
+ e.preventDefault()
1293
+ if (shift) redo()
1294
+ else undo()
1295
+ return
1296
+ }
1297
+ if (mod && e.key.toLowerCase() === "y") {
1298
+ e.preventDefault()
1299
+ redo()
1300
+ return
1301
+ }
1302
+ if (mod && e.key.toLowerCase() === "c") {
1303
+ e.preventDefault()
1304
+ copySelection()
1305
+ return
1306
+ }
1307
+ if (mod && e.key.toLowerCase() === "v") {
1308
+ e.preventDefault()
1309
+ pasteClip()
1310
+ return
1311
+ }
1312
+ if (mod && e.key.toLowerCase() === "a") {
1313
+ e.preventDefault()
1314
+ selectAll()
1315
+ return
1316
+ }
1317
+ if (mod && e.key.toLowerCase() === "d") {
1318
+ e.preventDefault()
1319
+ fillDownKey()
1320
+ return
1321
+ }
1322
+ if (mod && e.key.toLowerCase() === "r") {
1323
+ e.preventDefault()
1324
+ fillRightKey()
1325
+ return
1326
+ }
1327
+ if (mod && e.key.toLowerCase() === "f") {
1328
+ e.preventDefault()
1329
+ openFind()
1330
+ return
1331
+ }
1332
+ if (e.key === "Delete" || e.key === "Backspace") {
1333
+ e.preventDefault()
1334
+ clearSelection()
1335
+ return
1336
+ }
1337
+ const nav: Record<string, [number, number]> = {
1338
+ ArrowUp: [-1, 0],
1339
+ ArrowDown: [1, 0],
1340
+ ArrowLeft: [0, -1],
1341
+ ArrowRight: [0, 1],
1342
+ }
1343
+ const dir = nav[e.key]
1344
+ if (dir) {
1345
+ e.preventDefault()
1346
+ if (mod) jumpEdge(dir[0], dir[1], shift)
1347
+ else moveSelection(dir[0], dir[1], shift)
1348
+ return
1349
+ }
1350
+ if (e.key === "Tab") {
1351
+ e.preventDefault()
1352
+ moveSelection(0, shift ? -1 : 1)
1353
+ return
1354
+ }
1355
+ if (e.key === "Home") {
1356
+ e.preventDefault()
1357
+ if (mod) jumpCorner(false)
1358
+ else jumpStart(false)
1359
+ return
1360
+ }
1361
+ if (e.key === "End") {
1362
+ e.preventDefault()
1363
+ if (mod) jumpCorner(true)
1364
+ else jumpStart(true)
1365
+ return
1366
+ }
1367
+ if (e.key === "PageUp") {
1368
+ e.preventDefault()
1369
+ jumpPage(-1, shift)
1370
+ return
1371
+ }
1372
+ if (e.key === "PageDown") {
1373
+ e.preventDefault()
1374
+ jumpPage(1, shift)
1375
+ return
1376
+ }
1377
+ if (e.key === "Enter" || e.key === "F2") {
1378
+ e.preventDefault()
1379
+ const col = colOf(sel.value.column)
1380
+ if (col?.type === "boolean") toggleBoolean(sel.value.row, sel.value.column)
1381
+ else startEdit(sel.value.row, sel.value.column)
1382
+ return
1383
+ }
1384
+ // Frappe directe : remplace le contenu (comportement Excel)
1385
+ if (e.key.length === 1 && !mod && !e.altKey) {
1386
+ const col = colOf(sel.value.column)
1387
+ if (col && col.editable !== false && col.type !== "boolean") {
1388
+ e.preventDefault()
1389
+ startEdit(sel.value.row, sel.value.column, e.key)
1390
+ }
1391
+ }
1392
+ }
1393
+
1394
+ // ─── Clavier de l'éditeur ───
1395
+ const onEditKeydown = (e: KeyboardEvent) => {
1396
+ // Dans une formule, Shift+Enter insère un retour ligne (textarea multi-lignes)
1397
+ if (editingIsFormula.value && e.key === "Enter" && e.shiftKey) return
1398
+ // Autocomplétion de fonction ouverte : Tab / flèches / Échap la pilotent
1399
+ if (fxOpen.value) {
1400
+ if (e.key === "Tab") {
1401
+ e.preventDefault()
1402
+ e.stopPropagation()
1403
+ acceptFx()
1404
+ return
1405
+ }
1406
+ if (e.key === "ArrowDown") {
1407
+ e.preventDefault()
1408
+ e.stopPropagation()
1409
+ stepFx(1)
1410
+ return
1411
+ }
1412
+ if (e.key === "ArrowUp") {
1413
+ e.preventDefault()
1414
+ e.stopPropagation()
1415
+ stepFx(-1)
1416
+ return
1417
+ }
1418
+ if (e.key === "Escape") {
1419
+ e.preventDefault()
1420
+ e.stopPropagation()
1421
+ fxHide.value = true
1422
+ return
1423
+ }
1424
+ }
1425
+ if (e.key === "Enter") {
1426
+ e.preventDefault()
1427
+ e.stopPropagation()
1428
+ if (editingIsSelect.value && selectOptions.value.length && activeOption.value) {
1429
+ pickOption(activeOption.value)
1430
+ } else {
1431
+ commitEdit()
1432
+ moveSelection(1, 0)
1433
+ }
1434
+ return
1435
+ }
1436
+ if (e.key === "Escape") {
1437
+ e.preventDefault()
1438
+ e.stopPropagation()
1439
+ cancelEdit()
1440
+ return
1441
+ }
1442
+ if (e.key === "Tab") {
1443
+ e.preventDefault()
1444
+ e.stopPropagation()
1445
+ commitEdit()
1446
+ moveSelection(0, e.shiftKey ? -1 : 1)
1447
+ return
1448
+ }
1449
+ if (editingIsSelect.value && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
1450
+ e.preventDefault()
1451
+ e.stopPropagation()
1452
+ const opts = selectOptions.value
1453
+ if (!opts.length) return
1454
+ const cur = activeOption.value ? opts.indexOf(activeOption.value) : -1
1455
+ const delta = e.key === "ArrowDown" ? 1 : -1
1456
+ const next = (cur + delta + opts.length) % opts.length
1457
+ activeOption.value = opts[next]!
1458
+ }
1459
+ }
1460
+
1461
+ // ─── Éditeur "select" (liste d'options) ───
1462
+ const activeOption = ref<QSpreadsheetCellOption | null>(null)
1463
+ const selectOptions = computed<QSpreadsheetCellOption[]>(() => {
1464
+ if (!editing.value) return []
1465
+ const col = colOf(editing.value.column)
1466
+ if (col?.type !== "select") return []
1467
+ const q = draft.value.trim().toLowerCase()
1468
+ return (col.options ?? []).filter(
1469
+ (o) => !q || (o.label ?? String(o.value)).toLowerCase().includes(q),
1470
+ )
1471
+ })
1472
+ watch(selectOptions, () => {
1473
+ if (editing.value && colOf(editing.value.column)?.type === "select") {
1474
+ activeOption.value = selectOptions.value[0] ?? null
1475
+ }
1476
+ })
1477
+ const pickOption = (opt: QSpreadsheetCellOption) => {
1478
+ draft.value = opt.label
1479
+ commitEdit()
1480
+ }
1481
+
1482
+ // ─── Barre de formule (fx) ───
1483
+ const fxDraft = ref("")
1484
+ const fxInputEl = ref<HTMLInputElement | null>(null)
1485
+
1486
+ const syncFx = () => {
1487
+ const s = sel.value
1488
+ const raw =
1489
+ s && s.row >= 0 && s.row < state.value.length
1490
+ ? state.value[s.row]?.[s.column]
1491
+ : undefined
1492
+ fxDraft.value = raw === null || raw === undefined ? "" : String(raw)
1493
+ }
1494
+ watch([sel, state], syncFx)
1495
+
1496
+ const fxCanEdit = computed(() => {
1497
+ if (props.readonly || props.disable || !sel.value) return false
1498
+ const col = colOf(sel.value.column)
1499
+ return !!col && col.editable !== false && col.type !== "boolean" && col.type !== "select"
1500
+ })
1501
+
1502
+ const commitFx = async () => {
1503
+ if (!sel.value) return
1504
+ if (!fxCanEdit.value) {
1505
+ syncFx()
1506
+ return
1507
+ }
1508
+ const s = sel.value
1509
+ const col = colOf(s.column)!
1510
+ const old = state.value[s.row]?.[s.column]
1511
+ const next = coerceValue(col, fxDraft.value, old)
1512
+ if (next !== old) await validateAndSet(s.row, s.column, old, next)
1513
+ nextTick(() => focusCell(s.row, s.column))
1514
+ }
1515
+
1516
+ const onFxInput = (e: Event) => {
1517
+ fxDraft.value = (e.target as HTMLInputElement).value
1518
+ }
1519
+
1520
+ const onFxKeydown = (e: KeyboardEvent) => {
1521
+ if (fxOpen.value) {
1522
+ if (e.key === "Tab") {
1523
+ e.preventDefault()
1524
+ acceptFx()
1525
+ return
1526
+ }
1527
+ if (e.key === "ArrowDown") {
1528
+ e.preventDefault()
1529
+ stepFx(1)
1530
+ return
1531
+ }
1532
+ if (e.key === "ArrowUp") {
1533
+ e.preventDefault()
1534
+ stepFx(-1)
1535
+ return
1536
+ }
1537
+ if (e.key === "Escape") {
1538
+ e.preventDefault()
1539
+ fxHide.value = true
1540
+ return
1541
+ }
1542
+ }
1543
+ if (e.key === "Enter") {
1544
+ e.preventDefault()
1545
+ commitFx()
1546
+ moveSelection(1, 0)
1547
+ return
1548
+ }
1549
+ if (e.key === "Escape") {
1550
+ e.preventDefault()
1551
+ syncFx()
1552
+ }
1553
+ if (e.key === "Tab") {
1554
+ e.preventDefault()
1555
+ commitFx()
1556
+ moveSelection(0, e.shiftKey ? -1 : 1)
1557
+ }
1558
+ }
1559
+
1560
+ // ─── Autocomplétion des fonctions (barre fx + éditeur formule) ───
1561
+ const FORMULA_FNS: { name: string; sig: string }[] = [
1562
+ { name: "SUM", sig: "a, b, …" },
1563
+ { name: "AVERAGE", sig: "a, b, …" },
1564
+ { name: "MIN", sig: "a, b, …" },
1565
+ { name: "MAX", sig: "a, b, …" },
1566
+ { name: "COUNT", sig: "a, b, …" },
1567
+ { name: "COUNTA", sig: "a, b, …" },
1568
+ { name: "IF", sig: "cond, yes, no" },
1569
+ { name: "IFS", sig: "cond1, v1, cond2, v2…" },
1570
+ { name: "IFERROR", sig: "value, fallback" },
1571
+ { name: "IFNA", sig: "value, fallback" },
1572
+ { name: "SWITCH", sig: "expr, v1, r1…, [default]" },
1573
+ { name: "AND", sig: "a, b…" },
1574
+ { name: "OR", sig: "a, b…" },
1575
+ { name: "NOT", sig: "v" },
1576
+ { name: "VLOOKUP", sig: "key, range, col, [approx]" },
1577
+ { name: "ABS", sig: "n" },
1578
+ { name: "ROUND", sig: "n, [digits]" },
1579
+ { name: "ROUNDUP", sig: "n, [digits]" },
1580
+ { name: "ROUNDDOWN", sig: "n, [digits]" },
1581
+ { name: "FLOOR", sig: "n" },
1582
+ { name: "CEILING", sig: "n" },
1583
+ { name: "INT", sig: "n" },
1584
+ { name: "MOD", sig: "a, b" },
1585
+ { name: "SQRT", sig: "n" },
1586
+ { name: "POWER", sig: "n, exp" },
1587
+ { name: "PI", sig: "" },
1588
+ { name: "RAND", sig: "" },
1589
+ { name: "CONCAT", sig: "a, b…" },
1590
+ { name: "CONCATENATE", sig: "a, b…" },
1591
+ { name: "UPPER", sig: "text" },
1592
+ { name: "LOWER", sig: "text" },
1593
+ { name: "TRIM", sig: "text" },
1594
+ { name: "LEN", sig: "text" },
1595
+ { name: "LEFT", sig: "text, [n]" },
1596
+ { name: "RIGHT", sig: "text, [n]" },
1597
+ { name: "MID", sig: "text, start, n" },
1598
+ { name: "FIND", sig: "find, text, [start]" },
1599
+ { name: "SUBSTITUTE", sig: "text, old, new, [n]" },
1600
+ { name: "REPLACE", sig: "text, start, n, new" },
1601
+ { name: "VALUE", sig: "text" },
1602
+ { name: "N", sig: "v" },
1603
+ { name: "ISBLANK", sig: "v" },
1604
+ { name: "ISNUMBER", sig: "v" },
1605
+ { name: "ISTEXT", sig: "v" },
1606
+ { name: "TODAY", sig: "" },
1607
+ { name: "NOW", sig: "" },
1608
+ { name: "DATE", sig: "y, m, d" },
1609
+ { name: "YEAR", sig: "date" },
1610
+ { name: "MONTH", sig: "date" },
1611
+ { name: "DAY", sig: "date" },
1612
+ { name: "EDATE", sig: "date, months" },
1613
+ ]
1614
+ const fxSource = computed(() => (editing.value ? draft.value : fxDraft.value))
1615
+ const fxQuery = computed(() => {
1616
+ const s = fxSource.value
1617
+ if (!s.trim().startsWith("=")) return ""
1618
+ const m = s.match(/[A-Za-z]{1,}$/)
1619
+ return m ? m[0].toLowerCase() : ""
1620
+ })
1621
+ const fxSuggestions = computed(() => {
1622
+ const q = fxQuery.value
1623
+ if (!q) return []
1624
+ return FORMULA_FNS.filter((f) => f.name.toLowerCase().startsWith(q)).slice(0, 12)
1625
+ })
1626
+ const fxHide = ref(false)
1627
+ const fxActive = ref(0)
1628
+ const fxOpen = computed(() => !fxHide.value && fxSuggestions.value.length > 0)
1629
+ watch(fxQuery, () => {
1630
+ fxActive.value = 0
1631
+ fxHide.value = false
1632
+ })
1633
+ const fxPopStyle = ref<Record<string, string>>({})
1634
+ const refreshFxPop = () => {
1635
+ if (!fxOpen.value || !fxInputEl.value) return
1636
+ const r = fxInputEl.value.getBoundingClientRect()
1637
+ fxPopStyle.value = {
1638
+ position: "fixed",
1639
+ left: r.left + "px",
1640
+ top: r.bottom + 4 + "px",
1641
+ width: Math.max(240, r.width) + "px",
1642
+ }
1643
+ }
1644
+ watch(fxOpen, (v) => {
1645
+ if (v) nextTick(refreshFxPop)
1646
+ })
1647
+ const stepFx = (d: number) => {
1648
+ const n = fxSuggestions.value.length
1649
+ fxActive.value = (fxActive.value + d + n) % n
1650
+ }
1651
+ const acceptFx = () => {
1652
+ const f = fxSuggestions.value[fxActive.value]
1653
+ if (!f) return
1654
+ const replaceTail = (s: string) => s.replace(/[A-Za-z]+$/, f.name + "(")
1655
+ if (editing.value) draft.value = replaceTail(draft.value)
1656
+ else fxDraft.value = replaceTail(fxDraft.value)
1657
+ fxActive.value = 0
1658
+ nextTick(() => {
1659
+ const el = editing.value
1660
+ ? editingIsFormula.value
1661
+ ? editAreaEl.value
1662
+ : editInputEl.value
1663
+ : fxInputEl.value
1664
+ el?.focus({ preventScroll: true })
1665
+ })
1666
+ }
1667
+ const acceptFxAt = (i: number) => {
1668
+ fxActive.value = i
1669
+ acceptFx()
1670
+ }
1671
+
1672
+ // ════════ Hauteurs de lignes (resize au drag sur le numéro) ════════
1673
+ const rowHeights = ref<Record<number, number>>({})
1674
+ const rowH = (r: number) => rowHeights.value[r] ?? props.rowHeight
1675
+
1676
+ const rowResizing = ref<{ r: number; y: number; h: number } | null>(null)
1677
+ const startRowResize = (e: PointerEvent, r: number) => {
1678
+ if (props.disable) return
1679
+ pushHistory()
1680
+ e.preventDefault()
1681
+ e.stopPropagation()
1682
+ rowResizing.value = { r, y: e.clientY, h: rowH(r) }
1683
+ window.addEventListener("pointermove", onRowResizeMove)
1684
+ window.addEventListener("pointerup", endRowResize)
1685
+ }
1686
+ const onRowResizeMove = (e: PointerEvent) => {
1687
+ if (!rowResizing.value) return
1688
+ const h = Math.max(24, Math.min(200, rowResizing.value.h + (e.clientY - rowResizing.value.y)))
1689
+ rowHeights.value = { ...rowHeights.value, [rowResizing.value.r]: h }
1690
+ }
1691
+ const endRowResize = () => {
1692
+ rowResizing.value = null
1693
+ window.removeEventListener("pointermove", onRowResizeMove)
1694
+ window.removeEventListener("pointerup", endRowResize)
1695
+ }
1696
+
1697
+ // ─── Re-index des clés indexées par ligne (formats / hauteurs) ───
1698
+ const shiftRowKeys = (at: number, delta: number) => {
1699
+ if (delta === 0) return
1700
+ const nf: Record<string, CellFormat> = {}
1701
+ for (const [k, v] of Object.entries(cellFmt.value)) {
1702
+ const i = k.indexOf(":")
1703
+ const r = Number(k.slice(0, i))
1704
+ nf[r >= at ? r + delta + k.slice(i) : k] = v
1705
+ }
1706
+ cellFmt.value = nf
1707
+ const nh: Record<number, number> = {}
1708
+ for (const [rk, rv] of Object.entries(rowHeights.value)) {
1709
+ const rr = Number(rk)
1710
+ nh[rr >= at ? rr + delta : rr] = rv
1711
+ }
1712
+ rowHeights.value = nh
1713
+ }
1714
+ const dropRowRangeKeys = (r0: number, r1: number, count: number) => {
1715
+ const nf: Record<string, CellFormat> = {}
1716
+ for (const [k, v] of Object.entries(cellFmt.value)) {
1717
+ const i = k.indexOf(":")
1718
+ const r = Number(k.slice(0, i))
1719
+ if (r >= r0 && r <= r1) continue
1720
+ nf[r > r1 ? r - count + k.slice(i) : k] = v
1721
+ }
1722
+ cellFmt.value = nf
1723
+ const nh: Record<number, number> = {}
1724
+ for (const [rk, rv] of Object.entries(rowHeights.value)) {
1725
+ const rr = Number(rk)
1726
+ if (rr >= r0 && rr <= r1) continue
1727
+ nh[rr > r1 ? rr - count : rr] = rv
1728
+ }
1729
+ rowHeights.value = nh
1730
+ }
1731
+ const dropColumnKeys = (names: string[]) => {
1732
+ const nf: Record<string, CellFormat> = {}
1733
+ for (const [k, v] of Object.entries(cellFmt.value)) {
1734
+ const i = k.indexOf(":")
1735
+ const nm = k.slice(i + 1)
1736
+ if (!names.includes(nm)) nf[k] = v
1737
+ }
1738
+ cellFmt.value = nf
1739
+ for (const nm of names) {
1740
+ delete colWidths.value[nm]
1741
+ delete filters.value[nm]
1742
+ }
1743
+ }
1744
+
1745
+ // ════════ Formatage des cellules (interne, non sérialisé) ════════
1746
+ const cellFmt = ref<Record<string, CellFormat>>({})
1747
+ const fmtKey = (r: number, name: string) => r + ":" + name
1748
+ const cellFmtOf = (r: number, name: string): CellFormat | undefined => cellFmt.value[fmtKey(r, name)]
1749
+
1750
+ const FORMAT_COLORS: string[] = [
1751
+ "", "#ffffff", "#f8fafc", "#f1f5f9", "#e2e8f0", "#fee2e2", "#fecaca", "#ffedd5",
1752
+ "#fef9c3", "#fef08a", "#dcfce7", "#bbf7d0", "#dbeafe", "#bfdbfe", "#ede9fe",
1753
+ "#e9d5ff", "#fce7f3", "#cffafe", "#f97316", "#ef4444", "#22c55e", "#3b82f6",
1754
+ "#8b5cf6", "#ec4899",
1755
+ ]
1756
+ const TEXT_COLORS: string[] = [
1757
+ "", "#0f172a", "#1e293b", "#475569", "#64748b", "#94a3b8", "#ef4444", "#f97316",
1758
+ "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#ffffff",
1759
+ ]
1760
+ const swatchInline = (c: string) =>
1761
+ c ? { backgroundColor: c } : { backgroundColor: "transparent", boxShadow: "inset 0 0 0 1px var(--q-spreadsheet-border)" }
1762
+
1763
+ const anchorCell = computed(() => {
1764
+ if (!sel.value) return null
1765
+ return { r: sel.value.row, name: sel.value.column }
1766
+ })
1767
+ const anchorFmt = computed(() =>
1768
+ anchorCell.value ? cellFmtOf(anchorCell.value.r, anchorCell.value.name) : undefined,
1769
+ )
1770
+ const isBoldSel = computed(() => !!anchorFmt.value?.bold)
1771
+ const isItalicSel = computed(() => !!anchorFmt.value?.italic)
1772
+ const currentBg = computed(() => anchorFmt.value?.bg ?? "")
1773
+ const currentColor = computed(() => anchorFmt.value?.color ?? "")
1774
+
1775
+ const patchFormatSelection = (patch: Record<string, string | boolean | undefined>) => {
1776
+ const rect = selRect.value
1777
+ if (!rect || props.readonly || props.disable) return
1778
+ pushHistory()
1779
+ const nf = { ...cellFmt.value }
1780
+ for (let r = rect.r0; r <= rect.r1; r++) {
1781
+ for (let c = rect.c0; c <= rect.c1; c++) {
1782
+ const col = cols.value[c]
1783
+ if (!col) continue
1784
+ const k = fmtKey(r, col.name)
1785
+ const cur: Record<string, string | boolean | undefined> = { ...(nf[k] ?? {}) }
1786
+ for (const [key, val] of Object.entries(patch)) {
1787
+ if (val === undefined || val === "") delete cur[key]
1788
+ else cur[key] = val
1789
+ }
1790
+ if (Object.keys(cur).length) nf[k] = cur as CellFormat
1791
+ else delete nf[k]
1792
+ }
1793
+ }
1794
+ cellFmt.value = nf
1795
+ }
1796
+ const toggleBoldSelection = () => {
1797
+ patchFormatSelection({ bold: !isBoldSel.value })
1798
+ }
1799
+ const toggleItalicSelection = () => {
1800
+ patchFormatSelection({ italic: !isItalicSel.value })
1801
+ }
1802
+ const setBgColorSelection = (c: string) => {
1803
+ patchFormatSelection({ bg: c || undefined })
1804
+ }
1805
+ const setTextColorSelection = (c: string) => {
1806
+ patchFormatSelection({ color: c || undefined })
1807
+ }
1808
+ const clearFormatSelection = () => {
1809
+ const rect = selRect.value
1810
+ if (!rect || props.readonly || props.disable) return
1811
+ pushHistory()
1812
+ const nf = { ...cellFmt.value }
1813
+ for (let r = rect.r0; r <= rect.r1; r++) {
1814
+ for (let c = rect.c0; c <= rect.c1; c++) {
1815
+ const col = cols.value[c]
1816
+ if (!col) continue
1817
+ delete nf[fmtKey(r, col.name)]
1818
+ }
1819
+ }
1820
+ cellFmt.value = nf
1821
+ }
1822
+
1823
+ // ════════ Freeze panes (gel lignes / colonnes) ════════
1824
+ const frozenRowsN = computed(() => Math.max(0, Math.min(props.frozenRows ?? 0, state.value.length)))
1825
+ const frozenColsN = computed(() => Math.max(0, Math.min(props.frozenCols ?? 0, cols.value.length)))
1826
+ const gutterW = computed(() => (props.showRowNumbers ? 34 : 0))
1827
+ const headH = computed(() => (props.showColumnHeaders ? 28 : 0))
1828
+ const colWNum = (ci: number) => {
1829
+ const c = cols.value[ci]
1830
+ if (!c) return props.defaultColWidth
1831
+ const w = colWidths.value[c.name] ?? c.width ?? props.defaultColWidth
1832
+ return typeof w === "number" ? w : parseFloat(w) || props.defaultColWidth
1833
+ }
1834
+ const frozenLeftOf = (ci: number) => {
1835
+ let l = gutterW.value
1836
+ for (let j = 0; j < ci; j++) l += colWNum(j)
1837
+ return l
1838
+ }
1839
+ const frozenTopOf = (ri: number) => {
1840
+ let t = headH.value
1841
+ for (let i = 0; i < ri; i++) t += rowH(i)
1842
+ return t
1843
+ }
1844
+ const isFrozenRow = (ri: number) => ri < frozenRowsN.value
1845
+ const isFrozenCol = (ci: number) => ci < frozenColsN.value
1846
+
1847
+ const cellInlineStyle = (ri: number, ci: number, name: string) => {
1848
+ const st: Record<string, string> = {}
1849
+ const fmt = cellFmtOf(ri, name)
1850
+ const col = colOf(name)
1851
+ const colBg = col?.cellBackground?.(state.value[ri]?.[name], state.value[ri] ?? {})
1852
+ const selected = inSelection(ri, name)
1853
+ let bg: string | undefined
1854
+ if (fmt?.bg) bg = fmt.bg
1855
+ else if (selected) bg = "var(--q-spreadsheet-range)"
1856
+ else if (colBg) bg = colBg
1857
+ else if (isFrozenRow(ri) || isFrozenCol(ci)) bg = "var(--q-spreadsheet-bg)"
1858
+ if (bg) st.backgroundColor = bg
1859
+ if (fmt?.color) st.color = fmt.color
1860
+ if (fmt?.bold) st.fontWeight = "700"
1861
+ if (fmt?.italic) st.fontStyle = "italic"
1862
+ if (isFrozenCol(ci)) st.left = frozenLeftOf(ci) + "px"
1863
+ if (isFrozenRow(ri)) st.top = frozenTopOf(ri) + "px"
1864
+ return Object.keys(st).length ? st : undefined
1865
+ }
1866
+ const cellFreezeClass = (ri: number, ci: number) =>
1867
+ cn(
1868
+ isFrozenRow(ri) && isFrozenCol(ci) && "q-spreadsheet__cell--frozen-both",
1869
+ isFrozenRow(ri) && !isFrozenCol(ci) && "q-spreadsheet__cell--frozen-row",
1870
+ !isFrozenRow(ri) && isFrozenCol(ci) && "q-spreadsheet__cell--frozen-col",
1871
+ )
1872
+ const colheadInline = (ci: number) => (isFrozenCol(ci) ? { left: frozenLeftOf(ci) + "px" } : undefined)
1873
+ const rownumInline = (ri: number) => (isFrozenRow(ri) ? { top: frozenTopOf(ri) + "px" } : undefined)
1874
+
1875
+ // ════════ Filtres par colonne ════════
1876
+ const FILTER_BLANK = "__q_spreadsheet_blank__"
1877
+ const filters = ref<Record<string, string[] | null>>({})
1878
+ const activeFilterOf = (name: string) => filters.value[name] ?? null
1879
+ const hasFilterFor = (name: string) => {
1880
+ const v = filters.value[name]
1881
+ return !!v && v.length > 0
1882
+ }
1883
+ const hasActiveFilters = computed(() =>
1884
+ Object.values(filters.value).some((v) => v !== null && v.length > 0),
1885
+ )
1886
+ const rowFilteredOut = (r: number) => {
1887
+ for (const [name, allowed] of Object.entries(filters.value)) {
1888
+ if (!allowed || !allowed.length) continue
1889
+ const raw = state.value[r]?.[name]
1890
+ const key = isBlankValue(raw) ? FILTER_BLANK : String(raw)
1891
+ if (!allowed.includes(key)) return true
1892
+ }
1893
+ return false
1894
+ }
1895
+ // Lignes / colonnes masquées (Hide) + règles conditionnelles + fusions
1896
+ const hiddenRows = ref<number[]>([])
1897
+ const hiddenCols = ref<string[]>([])
1898
+ const hiddenRowSet = computed(() => new Set(hiddenRows.value))
1899
+ const isRowHidden = (r: number) => hiddenRowSet.value.has(r)
1900
+ const isColHiddenName = (name: string) => hiddenCols.value.includes(name)
1901
+ const visibleColsCount = computed(
1902
+ () => cols.value.length - hiddenCols.value.filter((n) => colOf(n)).length,
1903
+ )
1904
+ interface MergeRange {
1905
+ r0: number
1906
+ c0: number
1907
+ r1: number
1908
+ c1: number
1909
+ }
1910
+ const merges = ref<MergeRange[]>([])
1911
+ interface CondRule {
1912
+ id: string
1913
+ r0: number
1914
+ c0: number
1915
+ r1: number
1916
+ c1: number
1917
+ kind: "gt" | "lt" | "eq" | "gte" | "lte" | "contains" | "blank" | "notblank" | "formula" | "always"
1918
+ value?: string | number
1919
+ colName?: string
1920
+ formula?: string
1921
+ /** Rendu : fill (fond uni), bar (barre de données), scale (échelle de couleurs) */
1922
+ mode?: "fill" | "bar" | "scale"
1923
+ barColor?: string
1924
+ scaleLow?: string
1925
+ scaleHigh?: string
1926
+ bg?: string
1927
+ color?: string
1928
+ bold?: boolean
1929
+ }
1930
+ const condRules = ref<CondRule[]>([])
1931
+ const evalRuleFormula = (expr: string, r: number): boolean => {
1932
+ try {
1933
+ const v = evaluateFormula(expr, {
1934
+ row: r,
1935
+ resolveField: () => undefined,
1936
+ resolveCell: (rr, cc) => {
1937
+ const ev = evaluated.value[rr]?.[cc]
1938
+ if (ev === undefined || ev === null) return null
1939
+ return ev as FormulaValue
1940
+ },
1941
+ })
1942
+ if (isError(v)) return false
1943
+ if (typeof v === "boolean") return v
1944
+ if (typeof v === "number") return v !== 0
1945
+ return String(v).trim().toUpperCase() === "TRUE"
1946
+ } catch {
1947
+ return false
1948
+ }
1949
+ }
1950
+ const condRuleOf = (r: number, c: number, name?: string): CondRule | undefined =>
1951
+ condRules.value.find((rule) => {
1952
+ if (rule.colName !== undefined) {
1953
+ if (name === undefined || rule.colName !== name) return false
1954
+ return true
1955
+ }
1956
+ return r >= rule.r0 && r <= rule.r1 && c >= rule.c0 && c <= rule.c1
1957
+ })
1958
+
1959
+ const visibleRows = computed<number[]>(() => {
1960
+ const out: number[] = []
1961
+ for (let r = 0; r < state.value.length; r++) {
1962
+ if (isRowHidden(r)) continue
1963
+ if (hasActiveFilters.value && rowFilteredOut(r)) continue
1964
+ out.push(r)
1965
+ }
1966
+ return out
1967
+ })
1968
+ const visibleSet = computed(() => new Set(visibleRows.value))
1969
+ const isRowVisible = (r: number) => !hasActiveFilters.value || visibleSet.value.has(r)
1970
+
1971
+ // ════════ Virtualisation des lignes (grandes grilles) ════════
1972
+ const VIRTUAL_MIN = 150
1973
+ const VIRTUAL_OVERSCAN = 12
1974
+ const virtualOn = computed(
1975
+ () =>
1976
+ (props.virtualScroll ?? true) &&
1977
+ visibleRows.value.length > VIRTUAL_MIN &&
1978
+ frozenRowsN.value === 0 &&
1979
+ merges.value.length === 0,
1980
+ )
1981
+ interface VisRow {
1982
+ top: number
1983
+ h: number
1984
+ bottom: number
1985
+ }
1986
+ const visInfo = computed<{ rows: VisRow[]; total: number }>(() => {
1987
+ const vr = visibleRows.value
1988
+ let top = headH.value
1989
+ const rows: VisRow[] = vr.map((ri) => {
1990
+ const h = rowH(ri)
1991
+ const info = { top, h, bottom: top + h }
1992
+ top += h
1993
+ return info
1994
+ })
1995
+ return { rows, total: top }
1996
+ })
1997
+ const renderRange = ref<{ start: number; end: number }>({ start: 0, end: -1 })
1998
+ const topPad = ref(0)
1999
+ const bottomPad = ref(0)
2000
+ const pendingFocus = ref<{ row: number; col: string } | null>(null)
2001
+ const flushPendingFocus = () => {
2002
+ if (!pendingFocus.value) return
2003
+ const pf = pendingFocus.value
2004
+ const idx = visibleRows.value.indexOf(pf.row)
2005
+ const { start, end } = renderRange.value
2006
+ if (idx < 0 || idx < start || idx > end) return
2007
+ pendingFocus.value = null
2008
+ focusCell(pf.row, pf.col)
2009
+ }
2010
+ const renderRows = computed<number[]>(() => {
2011
+ if (!virtualOn.value) return visibleRows.value
2012
+ const { start, end } = renderRange.value
2013
+ return end >= start ? visibleRows.value.slice(start, end + 1) : []
2014
+ })
2015
+ const updateRange = () => {
2016
+ const info = visInfo.value
2017
+ const n = info.rows.length
2018
+ const el = scrollEl.value
2019
+ if (!virtualOn.value || !n || !el) {
2020
+ renderRange.value = { start: 0, end: n - 1 }
2021
+ topPad.value = 0
2022
+ bottomPad.value = 0
2023
+ return
2024
+ }
2025
+ const top = Math.max(0, el.scrollTop)
2026
+ const bottom = top + Math.max(el.clientHeight || 60, 60)
2027
+ let lo = 0
2028
+ let hi = n - 1
2029
+ while (lo < hi) {
2030
+ const mid = (lo + hi) >> 1
2031
+ if (info.rows[mid]!.bottom > top) hi = mid
2032
+ else lo = mid + 1
2033
+ }
2034
+ const start = Math.max(0, lo - VIRTUAL_OVERSCAN)
2035
+ let end = start
2036
+ while (end < n && info.rows[end]!.top < bottom + VIRTUAL_OVERSCAN * props.rowHeight) end++
2037
+ end = Math.min(n - 1, end + VIRTUAL_OVERSCAN)
2038
+ renderRange.value = { start, end }
2039
+ topPad.value = Math.max(0, info.rows[start]!.top - headH.value)
2040
+ bottomPad.value = Math.max(0, info.total - info.rows[end]!.bottom)
2041
+ // La ligne cible est maintenant rendue : on peut la focuser
2042
+ if (pendingFocus.value) nextTick(flushPendingFocus)
2043
+ }
2044
+ watch([visibleRows, rowHeights], () => {
2045
+ if (virtualOn.value) nextTick(updateRange)
2046
+ else updateRange()
2047
+ })
2048
+ const ensureRowVisible = (realRow: number) => {
2049
+ if (!virtualOn.value) return
2050
+ const idx = visibleRows.value.indexOf(realRow)
2051
+ const el = scrollEl.value
2052
+ if (idx < 0 || !el) return
2053
+ const { start, end } = renderRange.value
2054
+ if (idx >= start && idx <= end) return
2055
+ const info = visInfo.value.rows[idx]
2056
+ if (!info) return
2057
+ // scroll-behavior: smooth (CSS) anime la remontée / descente ; le focus est
2058
+ // différé jusqu'à ce que la ligne soit effectivement rendue par la fenêtre
2059
+ pendingFocus.value = { row: realRow, col: cols.value[sel.value?.column ? colIndex(sel.value.column) : 0]?.name ?? "" }
2060
+ el.scrollTop = Math.max(0, info.top - headH.value - VIRTUAL_OVERSCAN * props.rowHeight)
2061
+ updateRange()
2062
+ }
2063
+
2064
+ interface FilterValueItem {
2065
+ key: string
2066
+ blank: boolean
2067
+ label: string
2068
+ count: number
2069
+ active: boolean
2070
+ }
2071
+ const filterValueItems = (name: string): FilterValueItem[] => {
2072
+ const col = colOf(name)
2073
+ const map = new Map<string, { blank: boolean; raw: any; count: number }>()
2074
+ for (const row of state.value) {
2075
+ const raw = row?.[name]
2076
+ const key = isBlankValue(raw) ? FILTER_BLANK : String(raw)
2077
+ const found = map.get(key)
2078
+ if (found) found.count++
2079
+ else map.set(key, { blank: isBlankValue(raw), raw, count: 1 })
2080
+ }
2081
+ const allowed = activeFilterOf(name)
2082
+ const items: FilterValueItem[] = [...map.entries()].map(([key, info]) => {
2083
+ let label: string
2084
+ if (info.blank) label = t("blanks")
2085
+ else if (col?.type === "select") {
2086
+ const opt = col.options?.find((o) => o.value === info.raw)
2087
+ label = opt?.label ?? String(info.raw)
2088
+ } else label = String(info.raw)
2089
+ return { key, blank: info.blank, label, count: info.count, active: allowed ? allowed.includes(key) : true }
2090
+ })
2091
+ items.sort((a, b) => a.label.localeCompare(b.label))
2092
+ return items
2093
+ }
2094
+ const setFilterAll = (name: string) => {
2095
+ pushHistory()
2096
+ filters.value = { ...filters.value, [name]: null }
2097
+ }
2098
+ const setFilterOnly = (name: string, keys: string[]) => {
2099
+ pushHistory()
2100
+ filters.value = { ...filters.value, [name]: keys.length ? keys : null }
2101
+ }
2102
+ const toggleFilterValue = (name: string, key: string) => {
2103
+ pushHistory()
2104
+ const cur = activeFilterOf(name)
2105
+ const arr = cur ? [...cur] : []
2106
+ const i = arr.indexOf(key)
2107
+ if (i >= 0) arr.splice(i, 1)
2108
+ else arr.push(key)
2109
+ filters.value = { ...filters.value, [name]: arr.length ? arr : null }
2110
+ }
2111
+
2112
+ // ════════ Menus (clic droit + filtre colonne) ════════
2113
+ type CtxKind = "cell" | "row" | "col"
2114
+ const ctxMenu = ref<{ kind: CtxKind; x: number; y: number } | null>(null)
2115
+ const ctxRoot = ref<HTMLElement | null>(null)
2116
+ const filterMenu = ref<{ ci: number; x: number; y: number } | null>(null)
2117
+ const filterRoot = ref<HTMLElement | null>(null)
2118
+ const filterSearch = ref("")
2119
+
2120
+ const placePop = (el: HTMLElement, x: number, y: number) => {
2121
+ const mw = el.offsetWidth
2122
+ const mh = el.offsetHeight
2123
+ let lx = x
2124
+ let ty = y
2125
+ if (x + mw > window.innerWidth - 8) lx = Math.max(8, window.innerWidth - mw - 8)
2126
+ if (y + mh > window.innerHeight - 8) ty = Math.max(8, window.innerHeight - mh - 8)
2127
+ el.style.left = lx + "px"
2128
+ el.style.top = ty + "px"
2129
+ }
2130
+ const openCtx = (kind: CtxKind, e: MouseEvent) => {
2131
+ e.preventDefault()
2132
+ e.stopPropagation()
2133
+ if (props.disable) return
2134
+ ctxMenu.value = { kind, x: e.clientX, y: e.clientY }
2135
+ nextTick(() => {
2136
+ if (ctxRoot.value) placePop(ctxRoot.value, e.clientX, e.clientY)
2137
+ })
2138
+ }
2139
+ const closeCtx = () => {
2140
+ ctxMenu.value = null
2141
+ }
2142
+ const openFilter = (ci: number, e: MouseEvent) => {
2143
+ e.preventDefault()
2144
+ e.stopPropagation()
2145
+ filterSearch.value = ""
2146
+ filterMenu.value = { ci, x: e.clientX, y: e.clientY }
2147
+ nextTick(() => {
2148
+ if (filterRoot.value) placePop(filterRoot.value, e.clientX, e.clientY)
2149
+ })
2150
+ }
2151
+ const closeFilter = () => {
2152
+ filterMenu.value = null
2153
+ }
2154
+ const closePops = () => {
2155
+ closeCtx()
2156
+ closeFilter()
2157
+ }
2158
+ const onCellContext = (ri: number, name: string, e: MouseEvent) => {
2159
+ if (props.disable) return
2160
+ select(ri, name)
2161
+ openCtx("cell", e)
2162
+ }
2163
+ const onRowContext = (ri: number, e: MouseEvent) => {
2164
+ if (props.disable) return
2165
+ selectRow(ri, e.shiftKey)
2166
+ openCtx("row", e)
2167
+ }
2168
+ const onColContext = (ci: number, e: MouseEvent) => {
2169
+ if (props.disable) return
2170
+ selectCol(ci, e.shiftKey)
2171
+ openCtx("col", e)
2172
+ }
2173
+ const onDocPointerDown = (e: PointerEvent) => {
2174
+ const t = e.target as HTMLElement
2175
+ if (!t || !t.closest) return
2176
+ if (
2177
+ (ctxMenu.value && !t.closest(".q-spreadsheet__ctx")) ||
2178
+ (filterMenu.value && !t.closest(".q-spreadsheet__fpop") && !t.closest(".q-spreadsheet__fbtn"))
2179
+ ) {
2180
+ closePops()
2181
+ }
2182
+ }
2183
+
2184
+ // actions du menu contextuel
2185
+ const canEdit = computed(() => !props.readonly && !props.disable)
2186
+ const doCut = async () => {
2187
+ await copySelection()
2188
+ clearSelection()
2189
+ closeCtx()
2190
+ }
2191
+ const doCopy = async () => {
2192
+ await copySelection()
2193
+ closeCtx()
2194
+ }
2195
+ const doPaste = async () => {
2196
+ await pasteClip()
2197
+ closeCtx()
2198
+ }
2199
+ const doClearCells = () => {
2200
+ clearSelection()
2201
+ closeCtx()
2202
+ }
2203
+ const activeColName = computed(() => sel.value?.column ?? colNameAt(0))
2204
+ const doSortBy = (desc = false) => {
2205
+ sortByColumn(activeColName.value, desc)
2206
+ closeCtx()
2207
+ }
2208
+
2209
+ // ─── Insertion au curseur (lignes / colonnes) ───
2210
+ const insertRowAt = (pos: "above" | "below") => {
2211
+ if (!canEdit.value) return
2212
+ pushHistory()
2213
+ purgeMergesAndRules()
2214
+ if (!state.value.length) {
2215
+ addRow()
2216
+ closeCtx()
2217
+ return
2218
+ }
2219
+ const rect = selRect.value
2220
+ const anchor = sel.value
2221
+ const at = rect
2222
+ ? pos === "above"
2223
+ ? rect.r0
2224
+ : rect.r1 + 1
2225
+ : anchor
2226
+ ? pos === "above"
2227
+ ? anchor.row
2228
+ : anchor.row + 1
2229
+ : state.value.length
2230
+ const idx = Math.max(0, Math.min(state.value.length, at))
2231
+ const next = [...state.value]
2232
+ next.splice(idx, 0, blankRow())
2233
+ state.value = next
2234
+ shiftRowKeys(idx, 1)
2235
+ emit("update:rows", next)
2236
+ emit("structure-change", { rows: next, columns: cols.value, reason: "add-row" })
2237
+ if (cols.value.length) select(idx, anchor?.column ?? colNameAt(0))
2238
+ closeCtx()
2239
+ }
2240
+ const insertColumnAt = (pos: "left" | "right") => {
2241
+ if (!canEdit.value) return
2242
+ pushHistory()
2243
+ purgeMergesAndRules()
2244
+ const rect = selRect.value
2245
+ const anchor = sel.value
2246
+ const fromIdx = anchor ? colIndex(anchor.column) : 0
2247
+ const at = rect
2248
+ ? pos === "left"
2249
+ ? rect.c0
2250
+ : rect.c1 + 1
2251
+ : fromIdx + (pos === "left" ? 0 : 1)
2252
+ const idx = Math.max(0, Math.min(cols.value.length, at))
2253
+ let n = cols.value.length + 1
2254
+ const used = new Set(cols.value.map((c) => c.name))
2255
+ let name = "column" + n
2256
+ while (used.has(name)) name = "column" + ++n
2257
+ const col: QSpreadsheetColumn = { name, label: "Column " + n, type: "text" }
2258
+ const nextCols = [...cols.value]
2259
+ nextCols.splice(idx, 0, col)
2260
+ cols.value = nextCols
2261
+ colWidths.value = { ...colWidths.value, [name]: props.defaultColWidth }
2262
+ const nextRows = state.value.map((r) => ({ ...r, [name]: "" }))
2263
+ state.value = nextRows
2264
+ emit("update:columns", nextCols)
2265
+ emit("update:rows", nextRows)
2266
+ emit("structure-change", { rows: nextRows, columns: nextCols, reason: "add-column" })
2267
+ if (state.value.length) select(anchor?.row ?? 0, name)
2268
+ closeCtx()
2269
+ }
2270
+
2271
+ // ─── Rendu badge (option select) ───
2272
+ const badgeStyle = (opt: QSpreadsheetCellOption) => {
2273
+ if (opt.color) {
2274
+ return { backgroundColor: colorValue(opt.color), color: foregroundFor(opt.color) }
2275
+ }
2276
+ return undefined // style par défaut via CSS
2277
+ }
2278
+
2279
+ const badgeOf = (col: QSpreadsheetColumn | undefined, val: any) => {
2280
+ if (col?.type !== "select" || !col.chip) return undefined
2281
+ const opt = col.options?.find((o) => o.value === val)
2282
+ if (!opt) return undefined
2283
+ return { label: opt.label, style: badgeStyle(opt) }
2284
+ }
2285
+
2286
+ // ════════ Autofill (poignée de recopie en bas-droite) ════════
2287
+ interface FillRect {
2288
+ r0: number
2289
+ r1: number
2290
+ c0: number
2291
+ c1: number
2292
+ }
2293
+ const showFill = computed(
2294
+ () =>
2295
+ !props.readonly &&
2296
+ !props.disable &&
2297
+ !!sel.value &&
2298
+ !!selRect.value &&
2299
+ !editing.value &&
2300
+ cols.value.length > 0 &&
2301
+ state.value.length > 0,
2302
+ )
2303
+ const fillHandleStyle = ref<Record<string, string>>({})
2304
+ const fillBase = ref<FillRect | null>(null)
2305
+ const fillTarget = ref<FillRect | null>(null)
2306
+ const fillDragging = ref(false)
2307
+ const fillCopy = ref(false)
2308
+
2309
+ const updateFillHandle = () => {
2310
+ if (!showFill.value || !scrollEl.value) {
2311
+ fillHandleStyle.value = {}
2312
+ return
2313
+ }
2314
+ const s = sel.value!
2315
+ const td = scrollEl.value.querySelector<HTMLElement>(
2316
+ `td[data-row="${s.row}"][data-col="${escAttr(s.column)}"]`,
2317
+ )
2318
+ if (!td) {
2319
+ fillHandleStyle.value = {}
2320
+ return
2321
+ }
2322
+ const sc = scrollEl.value.getBoundingClientRect()
2323
+ const tr = td.getBoundingClientRect()
2324
+ fillHandleStyle.value = {
2325
+ left: tr.right - sc.left + scrollEl.value.scrollLeft - 4 + "px",
2326
+ top: tr.bottom - sc.top + scrollEl.value.scrollTop - 4 + "px",
2327
+ }
2328
+ }
2329
+ watch([sel, state], () => nextTick(updateFillHandle))
2330
+
2331
+ const inFillTargetCell = (r: number, c: number) => {
2332
+ const t = fillTarget.value
2333
+ return !!t && r >= t.r0 && r <= t.r1 && c >= t.c0 && c <= t.c1
2334
+ }
2335
+ const inFillBaseCell = (r: number, c: number) => {
2336
+ const b = fillBase.value
2337
+ return !!b && r >= b.r0 && r <= b.r1 && c >= b.c0 && c <= b.c1
2338
+ }
2339
+ const fillCellClass = (r: number, c: number) =>
2340
+ cn(
2341
+ fillDragging.value &&
2342
+ !inFillBaseCell(r, c) &&
2343
+ inFillTargetCell(r, c) &&
2344
+ "q-spreadsheet__cell--fill",
2345
+ )
2346
+
2347
+ const startFill = (e: PointerEvent) => {
2348
+ if (!showFill.value || !selRect.value) return
2349
+ e.preventDefault()
2350
+ e.stopPropagation()
2351
+ const rect = selRect.value!
2352
+ fillBase.value = { r0: rect.r0, r1: rect.r1, c0: rect.c0, c1: rect.c1 }
2353
+ fillTarget.value = { ...fillBase.value }
2354
+ fillDragging.value = true
2355
+ fillCopy.value = e.ctrlKey || e.metaKey
2356
+ window.addEventListener("pointermove", onFillMove)
2357
+ window.addEventListener("pointerup", endFill)
2358
+ }
2359
+ const cellAtPoint = (e: PointerEvent): { r: number; c: number } | null => {
2360
+ const el = document.elementFromPoint(e.clientX, e.clientY)
2361
+ const td = el && el.closest ? (el.closest("td[data-row][data-col]") as HTMLElement | null) : null
2362
+ if (!td) return null
2363
+ const r = Number(td.dataset.row)
2364
+ const c = colIndex(td.dataset.col ?? "")
2365
+ if (!Number.isInteger(r) || r < 0 || c < 0 || c >= cols.value.length) return null
2366
+ return { r, c }
2367
+ }
2368
+ const onFillMove = (e: PointerEvent) => {
2369
+ const at = cellAtPoint(e)
2370
+ const b = fillBase.value
2371
+ if (!at || !b) return
2372
+ const t: FillRect = { ...b }
2373
+ if (at.r < t.r0) t.r0 = at.r
2374
+ if (at.r > t.r1) t.r1 = at.r
2375
+ if (at.c < t.c0) t.c0 = at.c
2376
+ if (at.c > t.c1) t.c1 = at.c
2377
+ fillTarget.value = t
2378
+ fillCopy.value = e.ctrlKey || e.metaKey
2379
+ }
2380
+ const endFill = () => {
2381
+ window.removeEventListener("pointermove", onFillMove)
2382
+ window.removeEventListener("pointerup", endFill)
2383
+ fillDragging.value = false
2384
+ applyFill()
2385
+ fillBase.value = null
2386
+ fillTarget.value = null
2387
+ }
2388
+
2389
+ const applyFill = () => {
2390
+ const b = fillBase.value
2391
+ const t = fillTarget.value
2392
+ if (!b || !t || props.readonly || props.disable) return
2393
+ const same =
2394
+ t.r0 === b.r0 && t.r1 === b.r1 && t.c0 === b.c0 && t.c1 === b.c1
2395
+ if (same) return
2396
+ pushHistory()
2397
+ const h = b.r1 - b.r0 + 1
2398
+ const w = b.c1 - b.c0 + 1
2399
+ const vertDown = t.r1 > b.r1 && t.r0 === b.r0
2400
+ const vertUp = t.r0 < b.r0 && t.r1 === b.r1
2401
+ const horizRight = t.c1 > b.c1 && t.c0 === b.c0
2402
+ const horizLeft = t.c0 < b.c0 && t.c1 === b.c1
2403
+ const vertOnly = t.c0 === b.c0 && t.c1 === b.c1
2404
+ const horizOnly = t.r0 === b.r0 && t.r1 === b.r1
2405
+ const series = (vertDown && vertOnly) || (horizRight && horizOnly)
2406
+
2407
+ for (let rr = t.r0; rr <= t.r1; rr++) {
2408
+ for (let cc = t.c0; cc <= t.c1; cc++) {
2409
+ if (rr >= b.r0 && rr <= b.r1 && cc >= b.c0 && cc <= b.c1) continue
2410
+ if (rr < 0 || rr >= state.value.length || cc < 0 || cc >= cols.value.length) continue
2411
+ const name = colNameAt(cc)
2412
+ const col = colOf(name)
2413
+ if (!col || col.editable === false) continue
2414
+
2415
+ let val: any
2416
+ // Série numérique / dates (2 graines) vers le bas ou la droite (Ctrl = copie)
2417
+ if (
2418
+ !fillCopy.value &&
2419
+ vertDown &&
2420
+ vertOnly &&
2421
+ cc >= b.c0 &&
2422
+ cc <= b.c1 &&
2423
+ rr > b.r1
2424
+ ) {
2425
+ const s0 = state.value[b.r0]?.[name]
2426
+ const s1 = state.value[b.r1]?.[name]
2427
+ if (b.r1 > b.r0 && typeof s0 === "number" && typeof s1 === "number" && s1 !== s0) {
2428
+ const step = (s1 - s0) / (b.r1 - b.r0)
2429
+ val = s1 + step * (rr - b.r1)
2430
+ } else if (
2431
+ b.r1 > b.r0 &&
2432
+ typeof s0 === "string" &&
2433
+ typeof s1 === "string" &&
2434
+ ISO_DATE.test(s0) &&
2435
+ ISO_DATE.test(s1)
2436
+ ) {
2437
+ const d0 = +new Date(s0 + "T00:00:00Z")
2438
+ const d1 = +new Date(s1 + "T00:00:00Z")
2439
+ const step = Math.round((d1 - d0) / 86400000)
2440
+ if (step !== 0) val = isoAddDays(s1, step * (rr - b.r1))
2441
+ }
2442
+ } else if (
2443
+ !fillCopy.value &&
2444
+ horizRight &&
2445
+ horizOnly &&
2446
+ rr >= b.r0 &&
2447
+ rr <= b.r1 &&
2448
+ cc > b.c1
2449
+ ) {
2450
+ const cA = colNameAt(b.c0)
2451
+ const cB = colNameAt(b.c1)
2452
+ const s0 = state.value[rr]?.[cA]
2453
+ const s1 = state.value[rr]?.[cB]
2454
+ if (b.c1 > b.c0 && typeof s0 === "number" && typeof s1 === "number" && s1 !== s0) {
2455
+ const step = (s1 - s0) / (b.c1 - b.c0)
2456
+ val = s1 + step * (cc - b.c1)
2457
+ }
2458
+ }
2459
+ // Recopie du motif (source cyclique) — vers le bas, la droite, le haut,
2460
+ // la gauche, les formules étant décalées
2461
+ if (val === undefined) {
2462
+ const sr = b.r0 + (((rr - b.r0) % h) + h) % h
2463
+ const sc = b.c0 + (((cc - b.c0) % w) + w) % w
2464
+ let raw = state.value[sr]?.[colNameAt(sc)]
2465
+ if (typeof raw === "string" && raw.startsWith("=")) {
2466
+ raw = shiftFormulaRefs(raw, rr - sr, cc - sc)
2467
+ }
2468
+ val = raw
2469
+ }
2470
+ const old = state.value[rr]?.[name]
2471
+ if (val !== old && !(val === undefined && (old === null || old === undefined || old === "")))
2472
+ setCellValue(rr, name, old, val)
2473
+ }
2474
+ }
2475
+ // Sélectionne la zone remplie (Excel garde la plage étendue)
2476
+ setSel({ row: t.r0, column: colNameAt(t.c0), endRow: t.r1, endColumn: colNameAt(t.c1) })
2477
+ nextTick(() => focusCell(b.r0, colNameAt(b.c0)))
2478
+ }
2479
+
2480
+ const isActiveCol = (c: number) => {
2481
+ const rect = selRect.value
2482
+ if (!rect) return false
2483
+ return rect.c0 === c && rect.c1 === c && rect.r0 === 0 && rect.r1 === state.value.length - 1
2484
+ }
2485
+ const isActiveRow = (r: number) => {
2486
+ const rect = selRect.value
2487
+ if (!rect) return false
2488
+ return rect.r0 === r && rect.r1 === r && rect.c0 === 0 && rect.c1 === cols.value.length - 1
2489
+ }
2490
+
2491
+ // ─── Largeur / resize des colonnes ───
2492
+ const colWidths = ref<Record<string, string | number>>({})
2493
+ const syncColWidths = () => {
2494
+ const next: Record<string, string | number> = {}
2495
+ for (const c of cols.value) next[c.name] = c.width ?? props.defaultColWidth
2496
+ colWidths.value = next
2497
+ }
2498
+ watch(cols, syncColWidths, { immediate: true, deep: true })
2499
+
2500
+ const widthStyle = (col: QSpreadsheetColumn) => {
2501
+ const w = colWidths.value[col.name] ?? col.width ?? props.defaultColWidth
2502
+ return typeof w === "number" ? w + "px" : w
2503
+ }
2504
+ const minW = (col: QSpreadsheetColumn) => (col.minWidth ?? 60) + "px"
2505
+ const maxW = (col: QSpreadsheetColumn) => (col.maxWidth ?? 600) + "px"
2506
+
2507
+ const resizing = ref<{ col: QSpreadsheetColumn; x: number; w: number } | null>(null)
2508
+ const startResize = (e: PointerEvent, col: QSpreadsheetColumn) => {
2509
+ if (props.disable) return
2510
+ pushHistory()
2511
+ e.preventDefault()
2512
+ e.stopPropagation()
2513
+ const current = colWidths.value[col.name]
2514
+ resizing.value = {
2515
+ col,
2516
+ x: e.clientX,
2517
+ w: typeof current === "number" ? current : props.defaultColWidth,
2518
+ }
2519
+ window.addEventListener("pointermove", onResizeMove)
2520
+ window.addEventListener("pointerup", endResize)
2521
+ }
2522
+ const onResizeMove = (e: PointerEvent) => {
2523
+ if (!resizing.value) return
2524
+ const { col, x, w } = resizing.value
2525
+ const next = Math.max(col.minWidth ?? 60, Math.min(col.maxWidth ?? 600, w + (e.clientX - x)))
2526
+ colWidths.value = { ...colWidths.value, [col.name]: next }
2527
+ }
2528
+ const endResize = () => {
2529
+ resizing.value = null
2530
+ window.removeEventListener("pointermove", onResizeMove)
2531
+ window.removeEventListener("pointerup", endResize)
2532
+ }
2533
+
2534
+ const onPointerUpGlobal = () => {
2535
+ isPointerDown.value = false
2536
+ endResize()
2537
+ endRowResize()
2538
+ const s = sel.value
2539
+ if (s) focusCell(s.row, s.column)
2540
+ }
2541
+ const onViewportScroll = () => {
2542
+ positionEditor()
2543
+ updateFillHandle()
2544
+ updateViewportMetrics()
2545
+ updateRange()
2546
+ }
2547
+
2548
+ /** Double-clic sur la poignée : étend la série jusqu'à la fin des données */
2549
+ const fillHandleDbl = () => {
2550
+ if (!showFill.value) return
2551
+ const b = selRect.value
2552
+ if (!b) return
2553
+ const ends: number[] = []
2554
+ for (let c = b.c0; c <= b.c1; c++) {
2555
+ const name = colNameAt(c)
2556
+ if (!colOf(name)) continue
2557
+ let r = b.r1 + 1
2558
+ while (r < state.value.length && !isBlankValue(state.value[r]?.[name])) r++
2559
+ if (r - 1 > b.r1) ends.push(r - 1)
2560
+ }
2561
+ if (!ends.length) return
2562
+ const endRow = Math.min(...ends)
2563
+ fillBase.value = { r0: b.r0, r1: b.r1, c0: b.c0, c1: b.c1 }
2564
+ fillTarget.value = { r0: b.r0, r1: endRow, c0: b.c0, c1: b.c1 }
2565
+ applyFill()
2566
+ fillBase.value = null
2567
+ fillTarget.value = null
2568
+ nextTick(() => focusCell(b.r0, colNameAt(b.c0)))
2569
+ }
2570
+ const filterCol = computed(() => (filterMenu.value ? (cols.value[filterMenu.value.ci] ?? null) : null))
2571
+ const filterName = computed(() => filterCol.value?.name ?? "")
2572
+ const filterIsAll = computed(() => {
2573
+ const n = filterName.value
2574
+ if (!n) return true
2575
+ const v = filters.value[n]
2576
+ return !v || !v.length
2577
+ })
2578
+ const filterItemsFor = computed(() => {
2579
+ if (!filterMenu.value) return []
2580
+ const col = cols.value[filterMenu.value.ci]
2581
+ if (!col) return []
2582
+ const q = filterSearch.value.trim().toLowerCase()
2583
+ return filterValueItems(col.name).filter(
2584
+ (i) => !q || i.label.toLowerCase().includes(q),
2585
+ )
2586
+ })
2587
+ const filterToggleKey = (key: string) => toggleFilterValue(filterName.value, key)
2588
+ const filterSelectAll = () => setFilterAll(filterName.value)
2589
+ const filterClear = () => setFilterOnly(filterName.value, [])
2590
+ const onFilterSearch = (e: Event) => {
2591
+ filterSearch.value = (e.target as HTMLInputElement).value
2592
+ }
2593
+
2594
+ onMounted(() => {
2595
+ window.addEventListener("pointerup", onPointerUpGlobal)
2596
+ window.addEventListener("pointerdown", onDocPointerDown)
2597
+ window.addEventListener("resize", onResize)
2598
+ nextTick(() => {
2599
+ updateViewportMetrics()
2600
+ updateRange()
2601
+ })
2602
+ })
2603
+ onBeforeUnmount(() => {
2604
+ window.removeEventListener("pointerup", onPointerUpGlobal)
2605
+ window.removeEventListener("pointerdown", onDocPointerDown)
2606
+ window.removeEventListener("resize", onResize)
2607
+ endResize()
2608
+ endRowResize()
2609
+ })
2610
+ const onResize = () => {
2611
+ updateViewportMetrics()
2612
+ updateRange()
2613
+ }
2614
+
2615
+ const heightStyle = computed(() =>
2616
+ typeof props.height === "number" ? props.height + "px" : props.height,
2617
+ )
2618
+
2619
+ const effectiveRadius = useRadius("QSpreadsheet", () => props.radius)
2620
+ const rootClasses = computed(() =>
2621
+ cn(
2622
+ "q-spreadsheet",
2623
+ props.dense && "q-spreadsheet--dense",
2624
+ props.flat && "q-spreadsheet--flat",
2625
+ props.bordered && "q-spreadsheet--bordered",
2626
+ props.dark && "q-spreadsheet--dark",
2627
+ props.readonly && "q-spreadsheet--readonly",
2628
+ props.disable && "q-spreadsheet--disable",
2629
+ ),
2630
+ )
2631
+
2632
+ const cellContent = (row: number, col: QSpreadsheetColumn) => {
2633
+ const v = state.value[row]?.[col.name]
2634
+ if (col.type === "select") {
2635
+ const opt = col.options?.find((o) => o.value === v)
2636
+ if (opt) return opt.label
2637
+ }
2638
+ return cellText(row, col.name)
2639
+ }
2640
+
2641
+ const editingCol = computed(() => (editing.value ? colOf(editing.value.column) : undefined))
2642
+
2643
+ const selLabel = computed(() => {
2644
+ if (!sel.value) return ""
2645
+ const ci = colIndex(sel.value.column)
2646
+ if (ci === -1) return ""
2647
+ return colLetter(ci) + (sel.value.row + 1)
2648
+ })
2649
+
2650
+ const optionIsActive = (opt: QSpreadsheetCellOption) => activeOption.value === opt
2651
+
2652
+ const focus = () => rootEl.value?.focus()
2653
+
2654
+ // ════════ Multi-feuilles (classeur) ════════
2655
+ interface SheetExtras {
2656
+ cols: QSpreadsheetColumn[]
2657
+ widths: Record<string, number | string>
2658
+ filters: Record<string, string[] | null>
2659
+ formats: Record<string, CellFormat>
2660
+ rowHeights: Record<number, number>
2661
+ rules: CondRule[]
2662
+ merges: MergeRange[]
2663
+ hiddenRows: number[]
2664
+ hiddenCols: string[]
2665
+ }
2666
+ const sheetsMode = computed(() => Array.isArray(props.sheets))
2667
+ const multiMode = computed(() => sheetsMode.value && (props.sheets?.length ?? 0) > 0)
2668
+ const localSheets = ref<QSpreadsheetSheet[]>([])
2669
+ const sheetIdx = ref(0)
2670
+ const sheetMeta = ref<Record<string, SheetExtras>>({})
2671
+ const switching = ref(false)
2672
+
2673
+ const makeExtras = (cols: QSpreadsheetColumn[]): SheetExtras => {
2674
+ const widths: Record<string, number | string> = {}
2675
+ for (const c of cols) widths[c.name] = c.width ?? props.defaultColWidth
2676
+ return {
2677
+ cols: cols.map((c) => ({ ...c })),
2678
+ widths,
2679
+ filters: {},
2680
+ formats: {},
2681
+ rowHeights: {},
2682
+ rules: [],
2683
+ merges: [],
2684
+ hiddenRows: [],
2685
+ hiddenCols: [],
2686
+ }
2687
+ }
2688
+ const persistCurrent = () => {
2689
+ const s = localSheets.value[sheetIdx.value]
2690
+ if (!s) return
2691
+ const key = s.key!
2692
+ s.rows = state.value.map((r) => ({ ...r }))
2693
+ s.columns = cols.value.map((c) => ({ ...c }))
2694
+ const ext = sheetMeta.value[key] ?? makeExtras(cols.value)
2695
+ ext.cols = cols.value.map((c) => ({ ...c }))
2696
+ ext.widths = { ...colWidths.value }
2697
+ ext.filters = JSON.parse(JSON.stringify(filters.value))
2698
+ ext.formats = { ...cellFmt.value }
2699
+ ext.rowHeights = { ...rowHeights.value }
2700
+ ext.rules = condRules.value.map((r) => ({ ...r }))
2701
+ ext.merges = merges.value.map((m) => ({ ...m }))
2702
+ ext.hiddenRows = [...hiddenRows.value]
2703
+ ext.hiddenCols = [...hiddenCols.value]
2704
+ sheetMeta.value = { ...sheetMeta.value, [key]: ext }
2705
+ }
2706
+ const loadSheetIntoEngine = (idx: number) => {
2707
+ const s = localSheets.value[idx]
2708
+ if (!s) return
2709
+ const key = s.key!
2710
+ let ext = sheetMeta.value[key]
2711
+ if (!ext) {
2712
+ const colsArr = s.columns && s.columns.length ? s.columns : deriveCols(s.rows ?? [])
2713
+ const fresh = makeExtras(colsArr)
2714
+ // Le watcher de colonnes recale les largeurs depuis col.width : on les y grave
2715
+ fresh.cols = fresh.cols.map((c) => ({
2716
+ ...c,
2717
+ width: fresh.widths[c.name] ?? c.width,
2718
+ }))
2719
+ ext = fresh
2720
+ sheetMeta.value = { ...sheetMeta.value, [key]: ext }
2721
+ }
2722
+ cols.value = ext.cols.map((c) => ({ ...c, type: c.type ?? "text", options: c.options ?? [] }))
2723
+ state.value = (s.rows ?? []).map((r) => ({ ...r }))
2724
+ cellFmt.value = { ...ext.formats }
2725
+ rowHeights.value = { ...ext.rowHeights }
2726
+ filters.value = JSON.parse(JSON.stringify(ext.filters))
2727
+ condRules.value = (ext.rules ?? []).map((r) => ({ ...r }))
2728
+ merges.value = (ext.merges ?? []).map((m) => ({ ...m }))
2729
+ hiddenRows.value = [...(ext.hiddenRows ?? [])]
2730
+ hiddenCols.value = [...(ext.hiddenCols ?? [])]
2731
+ colWidths.value = { ...ext.widths }
2732
+ setSel(null)
2733
+ editing.value = null
2734
+ undoStack.length = 0
2735
+ redoStack.length = 0
2736
+ if (scrollEl.value) scrollEl.value.scrollTop = 0
2737
+ }
2738
+ watch(
2739
+ () => props.sheets,
2740
+ (v) => {
2741
+ if (!Array.isArray(v)) {
2742
+ localSheets.value = []
2743
+ return
2744
+ }
2745
+ if (v === localSheets.value) return
2746
+ const prevKey = localSheets.value[sheetIdx.value]?.key
2747
+ let n = 1
2748
+ localSheets.value = v.map((s) => {
2749
+ const key = s.key ?? "sheet-" + n
2750
+ n++
2751
+ return {
2752
+ ...s,
2753
+ key,
2754
+ name: s.name ?? sheetName(n),
2755
+ rows: (s.rows ?? []).map((r) => ({ ...r })),
2756
+ columns: s.columns ? s.columns.map((c) => ({ ...c })) : undefined,
2757
+ }
2758
+ })
2759
+ let idx = localSheets.value.findIndex((s) => s.key === prevKey)
2760
+ if (idx < 0) idx = 0
2761
+ sheetIdx.value = idx
2762
+ switching.value = true
2763
+ loadSheetIntoEngine(idx)
2764
+ switching.value = false
2765
+ },
2766
+ { immediate: true },
2767
+ )
2768
+ // Synchronise le record courant à chaque mutation de données / colonnes
2769
+ watch([state, cols], () => {
2770
+ if (!multiMode.value || switching.value) return
2771
+ persistCurrent()
2772
+ emit("update:sheets", localSheets.value)
2773
+ })
2774
+
2775
+ const activeSheetName = computed(() => {
2776
+ const s = localSheets.value[sheetIdx.value]
2777
+ if (s) return s.name ?? "Sheet " + (sheetIdx.value + 1)
2778
+ return props.sheets?.[0]?.name ?? sheetName(1)
2779
+ })
2780
+ const sheetRename = ref<{ idx: number; val: string } | null>(null)
2781
+ const startSheetRename = (idx: number) => {
2782
+ sheetRename.value = { idx, val: localSheets.value[idx]?.name ?? "" }
2783
+ }
2784
+ const commitSheetRename = () => {
2785
+ if (sheetRename.value) {
2786
+ const s = localSheets.value[sheetRename.value.idx]
2787
+ if (s) {
2788
+ s.name = sheetRename.value.val.trim() || s.name
2789
+ emit("update:sheets", localSheets.value)
2790
+ }
2791
+ sheetRename.value = null
2792
+ }
2793
+ }
2794
+ const goSheet = (idx: number) => {
2795
+ if (idx === sheetIdx.value || !multiMode.value) return
2796
+ persistCurrent()
2797
+ sheetIdx.value = idx
2798
+ switching.value = true
2799
+ loadSheetIntoEngine(idx)
2800
+ switching.value = false
2801
+ }
2802
+ const addSheet = () => {
2803
+ if (!sheetsMode.value) return
2804
+ if (!multiMode.value) {
2805
+ // premier onglet (propriété sheets vide) — devient multi
2806
+ const first: QSpreadsheetSheet = {
2807
+ key: "sheet-1",
2808
+ name: "Sheet 1",
2809
+ columns: cols.value.map((c) => ({ ...c })),
2810
+ rows: state.value.map((r) => ({ ...r })),
2811
+ }
2812
+ localSheets.value = [first]
2813
+ sheetMeta.value = { "sheet-1": makeExtras(cols.value) }
2814
+ sheetIdx.value = 0
2815
+ }
2816
+ const num = localSheets.value.length + 1
2817
+ const key = "sheet-" + num + "-" + Date.now().toString(36)
2818
+ const sheet: QSpreadsheetSheet = { key, name: sheetName(num), rows: [], columns: [] }
2819
+ localSheets.value = [...localSheets.value, sheet]
2820
+ sheetMeta.value = { ...sheetMeta.value, [key]: makeExtras([]) }
2821
+ persistCurrent()
2822
+ sheetIdx.value = localSheets.value.length - 1
2823
+ switching.value = true
2824
+ loadSheetIntoEngine(sheetIdx.value)
2825
+ switching.value = false
2826
+ emit("update:sheets", localSheets.value)
2827
+ }
2828
+ const removeSheet = (idx: number) => {
2829
+ if (!multiMode.value || localSheets.value.length <= 1) return
2830
+ persistCurrent()
2831
+ const key = localSheets.value[idx]?.key
2832
+ const next = localSheets.value.filter((_, i) => i !== idx)
2833
+ localSheets.value = next
2834
+ if (key) {
2835
+ const meta = { ...sheetMeta.value }
2836
+ delete meta[key]
2837
+ sheetMeta.value = meta
2838
+ }
2839
+ const ni = Math.min(idx, next.length - 1)
2840
+ sheetIdx.value = ni
2841
+ switching.value = true
2842
+ loadSheetIntoEngine(ni)
2843
+ switching.value = false
2844
+ emit("update:sheets", localSheets.value)
2845
+ }
2846
+
2847
+ // ════════ Sérialisation / export ════════
2848
+ const currentKeyOf = () => {
2849
+ const s = localSheets.value[sheetIdx.value]
2850
+ return s?.key ?? props.sheets?.[sheetIdx.value]?.key ?? "sheet-1"
2851
+ }
2852
+ const buildDocument = (): QSpreadsheetDocument => {
2853
+ persistCurrent()
2854
+ if (!multiMode.value) {
2855
+ // Persiste aussi les extras de la feuille unique (règles comprises)
2856
+ const ext = makeExtras(cols.value)
2857
+ ext.widths = { ...colWidths.value }
2858
+ ext.filters = JSON.parse(JSON.stringify(filters.value))
2859
+ ext.formats = { ...cellFmt.value }
2860
+ ext.rowHeights = { ...rowHeights.value }
2861
+ ext.rules = condRules.value.map((r) => ({ ...r }))
2862
+ ext.merges = merges.value.map((m) => ({ ...m }))
2863
+ ext.hiddenRows = [...hiddenRows.value]
2864
+ ext.hiddenCols = [...hiddenCols.value]
2865
+ sheetMeta.value = { ...sheetMeta.value, "sheet-1": ext }
2866
+ }
2867
+ const all =
2868
+ multiMode.value
2869
+ ? localSheets.value
2870
+ : [
2871
+ {
2872
+ key: "sheet-1",
2873
+ name: "Sheet 1",
2874
+ columns: cols.value.map((c) => ({ ...c })),
2875
+ rows: state.value.map((r) => ({ ...r })),
2876
+ } as QSpreadsheetSheet,
2877
+ ]
2878
+ const sheets = all.map((s) => {
2879
+ const ext =
2880
+ sheetMeta.value[s.key!] ?? makeExtras(s.columns ?? deriveCols(s.rows ?? []))
2881
+ return {
2882
+ ...s,
2883
+ rows: (s.rows ?? []).map((r) => ({ ...r })),
2884
+ columns: (s.columns ?? ext.cols).map((c) => ({ ...c })),
2885
+ formats: { ...ext.formats },
2886
+ widths: { ...ext.widths },
2887
+ rowHeights: { ...ext.rowHeights },
2888
+ filters: JSON.parse(JSON.stringify(ext.filters)),
2889
+ rules: ext.rules.map((r) => ({ ...r })),
2890
+ merges: ext.merges.map((m) => ({ ...m })),
2891
+ hiddenRows: [...ext.hiddenRows],
2892
+ hiddenCols: [...ext.hiddenCols],
2893
+ }
2894
+ })
2895
+ return { version: 1, active: currentKeyOf(), sheets }
2896
+ }
2897
+ const toJSON = (): string => JSON.stringify(buildDocument())
2898
+ const exportJson = () => {
2899
+ const doc = buildDocument()
2900
+ const blob = new Blob([JSON.stringify(doc, null, 2)], { type: "application/json" })
2901
+ downloadBlob(blob, (activeSheetName.value || "sheet").replace(/\s+/g, "-").toLowerCase() + ".json")
2902
+ }
2903
+ const loadDocument = (doc: QSpreadsheetDocument | string) => {
2904
+ const d = typeof doc === "string" ? (JSON.parse(doc) as QSpreadsheetDocument) : doc
2905
+ if (!Array.isArray(d.sheets) || !d.sheets.length) return
2906
+ let n = 1
2907
+ const local: QSpreadsheetSheet[] = []
2908
+ const meta: Record<string, SheetExtras> = {}
2909
+ for (const s of d.sheets) {
2910
+ const key = s.key ?? "sheet-" + n
2911
+ n++
2912
+ local.push({
2913
+ key,
2914
+ name: s.name ?? sheetName(n),
2915
+ columns: s.columns ? s.columns.map((c) => ({ ...c })) : undefined,
2916
+ rows: (s.rows ?? []).map((r) => ({ ...r })),
2917
+ })
2918
+ meta[key] = {
2919
+ cols: (s.columns ?? deriveCols(s.rows ?? [])).map((c) => ({ ...c })),
2920
+ widths: s.widths ?? {},
2921
+ filters: s.filters ?? {},
2922
+ formats: s.formats ?? {},
2923
+ rowHeights: s.rowHeights ?? {},
2924
+ rules: ((s as any).rules ?? []).map((r: CondRule) => ({ ...r })),
2925
+ merges: ((s as any).merges ?? []).map((m: MergeRange) => ({ ...m })),
2926
+ hiddenRows: [...(((s as any).hiddenRows as number[]) ?? [])],
2927
+ hiddenCols: [...(((s as any).hiddenCols as string[]) ?? [])],
2928
+ }
2929
+ }
2930
+ localSheets.value = local
2931
+ sheetMeta.value = meta
2932
+ const idx = Math.max(0, localSheets.value.findIndex((s) => s.key === d.active))
2933
+ sheetIdx.value = idx
2934
+ switching.value = true
2935
+ loadSheetIntoEngine(idx)
2936
+ switching.value = false
2937
+ emit("update:sheets", localSheets.value)
2938
+ }
2939
+ const downloadBlob = (blob: Blob, filename: string) => {
2940
+ const url = URL.createObjectURL(blob)
2941
+ const a = document.createElement("a")
2942
+ a.href = url
2943
+ a.download = filename
2944
+ a.click()
2945
+ URL.revokeObjectURL(url)
2946
+ }
2947
+ const getCsv = (opts: { delimiter?: string; includeHeaders?: boolean } = {}): string => {
2948
+ const delim = opts.delimiter ?? ","
2949
+ const lines: string[] = []
2950
+ if (opts.includeHeaders !== false)
2951
+ lines.push(cols.value.map((c) => csvEscape(c.label ?? c.name, delim)).join(delim))
2952
+ for (let r = 0; r < state.value.length; r++) {
2953
+ lines.push(
2954
+ cols.value
2955
+ .map((c) => {
2956
+ if (c.type === "select") {
2957
+ const opt = c.options?.find((o) => o.value === state.value[r]?.[c.name])
2958
+ const raw = state.value[r]?.[c.name]
2959
+ return csvEscape(
2960
+ opt?.label ?? (raw === null || raw === undefined ? "" : String(raw)),
2961
+ delim,
2962
+ )
2963
+ }
2964
+ return csvEscape(cellText(r, c.name), delim)
2965
+ })
2966
+ .join(delim),
2967
+ )
2968
+ }
2969
+ return lines.join("\r\n")
2970
+ }
2971
+ const exportCsv = (opts: { delimiter?: string; includeHeaders?: boolean } = {}) => {
2972
+ const blob = new Blob(["\uFEFF" + getCsv(opts)], { type: "text/csv;charset=utf-8" })
2973
+ downloadBlob(blob, (activeSheetName.value || "sheet").replace(/\s+/g, "-").toLowerCase() + ".csv")
2974
+ }
2975
+
2976
+ // ════════ Find & Replace (barre de recherche) ════════
2977
+ const findOpen = ref(false)
2978
+ const findQuery = ref("")
2979
+ const findReplaceText = ref("")
2980
+ const findCase = ref(false)
2981
+ const findMatches = ref<{ r: number; ci: number; name: string }[]>([])
2982
+ const findIdx = ref(-1)
2983
+ const findInputEl = ref<HTMLInputElement | null>(null)
2984
+
2985
+ const findTextOf = (r: number, ci: number): string => {
2986
+ const col = cols.value[ci]
2987
+ const raw = state.value[r]?.[col?.name ?? ""]
2988
+ if (!col) return ""
2989
+ if (col.type === "select") {
2990
+ const opt = col.options?.find((o) => o.value === raw)
2991
+ return opt?.label ?? (raw === null || raw === undefined ? "" : String(raw))
2992
+ }
2993
+ return raw === null || raw === undefined ? "" : String(raw)
2994
+ }
2995
+ const findScan = () => {
2996
+ const q = findQuery.value
2997
+ if (!q) {
2998
+ findMatches.value = []
2999
+ findIdx.value = -1
3000
+ return
3001
+ }
3002
+ const needle = findCase.value ? q : q.toLowerCase()
3003
+ const out: { r: number; ci: number; name: string }[] = []
3004
+ for (const ri of visibleRows.value) {
3005
+ for (let ci = 0; ci < cols.value.length; ci++) {
3006
+ const hay = findCase.value ? findTextOf(ri, ci) : findTextOf(ri, ci).toLowerCase()
3007
+ if (hay.includes(needle)) out.push({ r: ri, ci, name: colNameAt(ci) })
3008
+ }
3009
+ }
3010
+ findMatches.value = out
3011
+ findIdx.value = out.length ? Math.min(Math.max(0, findIdx.value), out.length - 1) : -1
3012
+ }
3013
+ const findNext = (dir = 1) => {
3014
+ findScan()
3015
+ const n = findMatches.value.length
3016
+ if (!n) return
3017
+ findIdx.value = (findIdx.value + dir + n) % n
3018
+ const m = findMatches.value[findIdx.value]!
3019
+ select(m.r, m.name)
3020
+ }
3021
+ const isFindCell = (r: number, ci: number) =>
3022
+ findOpen.value &&
3023
+ findQuery.value &&
3024
+ findMatches.value.some((m) => m.r === r && m.ci === ci)
3025
+ const isFindCurrent = (r: number, ci: number) =>
3026
+ findIdx.value >= 0 &&
3027
+ findMatches.value[findIdx.value]?.r === r &&
3028
+ findMatches.value[findIdx.value]?.ci === ci
3029
+ const findMarkClass = (r: number, ci: number) =>
3030
+ cn(
3031
+ findOpen.value &&
3032
+ findQuery.value &&
3033
+ isFindCell(r, ci) &&
3034
+ (isFindCurrent(r, ci)
3035
+ ? "q-spreadsheet__cell--find-cur"
3036
+ : "q-spreadsheet__cell--find"),
3037
+ )
3038
+ const findReplaceCurrent = () => {
3039
+ const m = findMatches.value[findIdx.value]
3040
+ if (!m || props.readonly || props.disable) return
3041
+ const col = cols.value[m.ci]
3042
+ const raw = state.value[m.r]?.[m.name]
3043
+ if (typeof raw !== "string" || !findQuery.value) return
3044
+ const next = findCase.value
3045
+ ? raw.split(findQuery.value).join(findReplaceText.value)
3046
+ : raw.replace(new RegExp(escapeRegExp(findQuery.value), "gi"), findReplaceText.value)
3047
+ if (next !== raw) setCellValue(m.r, m.name, raw, next)
3048
+ findScan()
3049
+ findNext(1)
3050
+ }
3051
+ const findReplaceAll = () => {
3052
+ if (props.readonly || props.disable || !findQuery.value) return
3053
+ pushHistory()
3054
+ const changed: { r: number; name: string; old: string; next: string }[] = []
3055
+ for (const m of findMatches.value) {
3056
+ const raw = state.value[m.r]?.[m.name]
3057
+ if (typeof raw !== "string") continue
3058
+ const next = findCase.value
3059
+ ? raw.split(findQuery.value).join(findReplaceText.value)
3060
+ : raw.replace(new RegExp(escapeRegExp(findQuery.value), "gi"), findReplaceText.value)
3061
+ if (next !== raw) changed.push({ r: m.r, name: m.name, old: raw, next })
3062
+ }
3063
+ for (const c of changed) setCellValue(c.r, c.name, c.old, c.next)
3064
+ findScan()
3065
+ }
3066
+ const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
3067
+ const openFind = () => {
3068
+ findOpen.value = !findOpen.value
3069
+ if (findOpen.value) {
3070
+ findQuery.value = findQuery.value || (sel.value ? String(state.value[sel.value.row]?.[sel.value.column] ?? "") : "")
3071
+ nextTick(() => {
3072
+ findScan()
3073
+ findInputEl.value?.focus()
3074
+ findInputEl.value?.select()
3075
+ })
3076
+ }
3077
+ }
3078
+ const closeFind = () => {
3079
+ findOpen.value = false
3080
+ findMatches.value = []
3081
+ findIdx.value = -1
3082
+ }
3083
+ const onFindInput = (e: Event) => {
3084
+ findQuery.value = (e.target as HTMLInputElement).value
3085
+ findScan()
3086
+ findIdx.value = -1
3087
+ findNext(1)
3088
+ }
3089
+ const onFindKeydown = (e: KeyboardEvent) => {
3090
+ if (e.key === "Enter") {
3091
+ e.preventDefault()
3092
+ findNext(e.shiftKey ? -1 : 1)
3093
+ } else if (e.key === "Escape") {
3094
+ e.preventDefault()
3095
+ closeFind()
3096
+ }
3097
+ }
3098
+
3099
+ // ════════ Import CSV + presse-papiers avancé ════════
3100
+ const importCsv = (text: string, opts: { delimiter?: string; headers?: boolean } = {}) => {
3101
+ if (props.readonly || props.disable) return
3102
+ const rows = parseCsv(text, opts.delimiter ?? ",")
3103
+ if (!rows.length || (rows.length === 1 && rows[0]!.length === 1 && rows[0]![0] === "")) return
3104
+ let colsArr: QSpreadsheetColumn[]
3105
+ let data: string[][]
3106
+ if (opts.headers) {
3107
+ colsArr = rows[0]!.map((h, i) => ({
3108
+ name: "col" + (i + 1),
3109
+ label: h.trim() || "Column " + (i + 1),
3110
+ type: "text" as const,
3111
+ }))
3112
+ data = rows.slice(1)
3113
+ } else if (cols.value.length) {
3114
+ colsArr = cols.value.map((c) => ({ ...c }))
3115
+ data = rows
3116
+ } else {
3117
+ const width = Math.max(...rows.map((row) => row.length))
3118
+ colsArr = Array.from({ length: width }, (_, i) => ({
3119
+ name: "col" + (i + 1),
3120
+ label: "Column " + (i + 1),
3121
+ type: "text" as const,
3122
+ }))
3123
+ data = rows
3124
+ }
3125
+ const rowObjs = data.map((row) => {
3126
+ const obj: Record<string, any> = {}
3127
+ colsArr.forEach((c, ci) => {
3128
+ obj[c.name] = ci < row.length && row[ci]!.trim() !== "" ? row[ci]! : null
3129
+ })
3130
+ return obj
3131
+ })
3132
+ pushHistory()
3133
+ cols.value = colsArr
3134
+ state.value = rowObjs
3135
+ cellFmt.value = {}
3136
+ rowHeights.value = {}
3137
+ filters.value = {}
3138
+ condRules.value = []
3139
+ emit("update:columns", colsArr)
3140
+ emit("update:rows", state.value)
3141
+ setSel(null)
3142
+ if (colsArr.length && state.value.length) select(0, colsArr[0]!.name)
3143
+ }
3144
+ const copyFormulas = async () => {
3145
+ const rect = selRect.value
3146
+ if (!rect) return
3147
+ const lines: string[][] = []
3148
+ for (let r = rect.r0; r <= rect.r1; r++) {
3149
+ const line: string[] = []
3150
+ for (let c = rect.c0; c <= rect.c1; c++) {
3151
+ const name = colNameAt(c)
3152
+ const raw = state.value[r]?.[name]
3153
+ line.push(raw === null || raw === undefined ? "" : String(raw))
3154
+ }
3155
+ lines.push(line)
3156
+ }
3157
+ internalClip.value = lines
3158
+ try {
3159
+ await navigator.clipboard.writeText(lines.map((l) => l.join("\t")).join("\n"))
3160
+ } catch {
3161
+ /* presse-papiers indisponible */
3162
+ }
3163
+ }
3164
+ // Paste values only : un texte qui commence par "=" est collé en TEXTE
3165
+ // littéral (préfixe apostrophe, masqué à l'affichage), pas en formule.
3166
+ const coerceLiteral = (col: QSpreadsheetColumn | undefined, text: string, old: any) => {
3167
+ const t = text.trim()
3168
+ if (t === "") return null
3169
+ if (t.startsWith("=")) return "'" + text
3170
+ return coerceValue(col, text, old)
3171
+ }
3172
+ const pasteValues = async () => {
3173
+ if (props.readonly || props.disable) return
3174
+ let lines = internalClip.value
3175
+ if (!lines.length) {
3176
+ try {
3177
+ const text = await navigator.clipboard.readText()
3178
+ lines = text.split(/\r?\n/).map((l) => l.split("\t"))
3179
+ } catch {
3180
+ return
3181
+ }
3182
+ }
3183
+ const s = sel.value
3184
+ if (!lines.length || !s) return
3185
+ pushHistory()
3186
+ const r0 = s.row
3187
+ const c0 = Math.max(0, colIndex(s.column))
3188
+ for (let i = 0; i < lines.length; i++) {
3189
+ for (let j = 0; j < (lines[i]?.length ?? 0); j++) {
3190
+ const r = r0 + i
3191
+ const c = c0 + j
3192
+ if (r >= state.value.length || c >= cols.value.length) continue
3193
+ const col = cols.value[c]!
3194
+ if (col.editable === false) continue
3195
+ const old = state.value[r]?.[col.name]
3196
+ const next = coerceLiteral(col, lines[i]![j] ?? "", old)
3197
+ if (next !== old) setCellValue(r, col.name, old, next)
3198
+ }
3199
+ }
3200
+ internalClip.value = lines
3201
+ }
3202
+ /** Ouvre un sélecteur de fichier : CSV/TSV → importCsv, JSON → loadDocument */
3203
+ const importFile = () => {
3204
+ const input = document.createElement("input")
3205
+ input.type = "file"
3206
+ input.accept = ".csv,.tsv,.json,text/csv,application/json"
3207
+ input.onchange = () => {
3208
+ const file = input.files?.[0]
3209
+ if (!file) return
3210
+ const reader = new FileReader()
3211
+ reader.onload = () => {
3212
+ const text = String(reader.result ?? "")
3213
+ if (!text) return
3214
+ if (/\.json$/i.test(file.name)) {
3215
+ try {
3216
+ loadDocument(text)
3217
+ } catch {
3218
+ /* JSON invalide : ignoré */
3219
+ }
3220
+ return
3221
+ }
3222
+ const first = text.split(/\r?\n/).find((l) => l.trim() !== "") ?? ""
3223
+ const cnt = (ch: string) => (first.match(new RegExp(ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? []).length
3224
+ let delim = ","
3225
+ if (first.includes("\t")) delim = "\t"
3226
+ else if (cnt(";") > cnt(",")) delim = ";"
3227
+ importCsv(text, { delimiter: delim, headers: true })
3228
+ }
3229
+ reader.readAsText(file)
3230
+ }
3231
+ input.click()
3232
+ }
3233
+ const pasteTransposed = async () => {
3234
+ if (props.readonly || props.disable) return
3235
+ let lines = internalClip.value
3236
+ if (!lines.length) {
3237
+ try {
3238
+ const text = await navigator.clipboard.readText()
3239
+ lines = text.split(/\r?\n/).map((l) => l.split("\t"))
3240
+ } catch {
3241
+ return
3242
+ }
3243
+ }
3244
+ const s = sel.value
3245
+ if (!lines.length || !s) return
3246
+ pushHistory()
3247
+ const r0 = s.row
3248
+ const c0 = Math.max(0, colIndex(s.column))
3249
+ for (let i = 0; i < lines.length; i++) {
3250
+ for (let j = 0; j < (lines[i]?.length ?? 0); j++) {
3251
+ const r = r0 + j
3252
+ const c = c0 + i
3253
+ if (r >= state.value.length || c >= cols.value.length) continue
3254
+ const col = cols.value[c]!
3255
+ if (col.editable === false) continue
3256
+ const old = state.value[r]?.[col.name]
3257
+ const next = coerceValue(col, lines[i]![j] ?? "", old)
3258
+ if (next !== old) setCellValue(r, col.name, old, next)
3259
+ }
3260
+ }
3261
+ }
3262
+
3263
+ // ════════ Mise en page : wrap + hauteur auto, masquage, fusions ════════
3264
+ const anchorWrap = computed(() => !!anchorFmt.value?.wrap)
3265
+ const toggleWrapSelection = () => {
3266
+ patchFormatSelection({ wrap: !anchorWrap.value })
3267
+ }
3268
+ const isWrappedAt = (r: number, name: string) => !!cellFmtOf(r, name)?.wrap
3269
+ const rowContentHeight = (r: number): number => {
3270
+ let maxLines = 1
3271
+ for (let c = 0; c < cols.value.length; c++) {
3272
+ const col = cols.value[c]!
3273
+ if (isColHiddenName(col.name)) continue
3274
+ if (!isWrappedAt(r, col.name)) continue
3275
+ const raw = state.value[r]?.[col.name]
3276
+ if (raw === null || raw === undefined) continue
3277
+ const text = String(raw)
3278
+ const w = Math.max(40, colWNum(c) - 14)
3279
+ const perLine = Math.max(1, Math.floor(w / 7.2))
3280
+ let lines = 0
3281
+ for (const seg of text.split("\n")) lines += Math.max(1, Math.ceil(seg.length / perLine))
3282
+ maxLines = Math.max(maxLines, lines)
3283
+ }
3284
+ return maxLines * 17 + 6
3285
+ }
3286
+ const rowDisplayH = (r: number) => Math.max(rowH(r), rowContentHeight(r))
3287
+ const cellTextWrapClass = (r: number, name: string) =>
3288
+ isWrappedAt(r, name) ? "q-spreadsheet__cell-text--wrap" : undefined
3289
+
3290
+ const hideSelectedRows = () => {
3291
+ const rect = selRect.value
3292
+ if (!rect) return
3293
+ pushHistory()
3294
+ const set = new Set(hiddenRows.value)
3295
+ for (let r = rect.r0; r <= rect.r1; r++) set.add(r)
3296
+ hiddenRows.value = [...set]
3297
+ setSel(null)
3298
+ }
3299
+ const hideRowAt = (r: number) => {
3300
+ pushHistory()
3301
+ if (!hiddenRowSet.value.has(r)) hiddenRows.value = [...hiddenRows.value, r]
3302
+ setSel(null)
3303
+ }
3304
+ const showAllRows = () => {
3305
+ pushHistory()
3306
+ hiddenRows.value = []
3307
+ }
3308
+ const hideColumnName = (name: string) => {
3309
+ pushHistory()
3310
+ if (!isColHiddenName(name)) hiddenCols.value = [...hiddenCols.value, name]
3311
+ setSel(null)
3312
+ }
3313
+ const hideColumnAt = (ci: number) => hideColumnName(colNameAt(ci))
3314
+ const showColumnName = (name: string) => {
3315
+ pushHistory()
3316
+ hiddenCols.value = hiddenCols.value.filter((n) => n !== name)
3317
+ }
3318
+ const showAllHidden = () => {
3319
+ pushHistory()
3320
+ hiddenRows.value = []
3321
+ hiddenCols.value = []
3322
+ }
3323
+ const hiddenColEntries = computed(
3324
+ () =>
3325
+ hiddenCols.value
3326
+ .map((name) => ({ name, ci: colIndex(name) }))
3327
+ .filter((e) => e.ci >= 0),
3328
+ )
3329
+ const hasHiddenRows = computed(() => hiddenRows.value.length > 0)
3330
+ const hasHiddenCols = computed(() => hiddenColEntries.value.length > 0)
3331
+
3332
+ const mergeOwner = (r: number, c: number) =>
3333
+ merges.value.find((m) => r >= m.r0 && r <= m.r1 && c >= m.c0 && c <= m.c1)
3334
+ const mergeInfoOf = (r: number, c: number) => {
3335
+ const m = mergeOwner(r, c)
3336
+ if (!m) return null
3337
+ return {
3338
+ owner: r === m.r0 && c === m.c0,
3339
+ rows: m.r1 - m.r0 + 1,
3340
+ cols: m.c1 - m.c0 + 1,
3341
+ }
3342
+ }
3343
+ const mergeCells = () => {
3344
+ const rect = selRect.value
3345
+ if (!rect || (rect.r0 === rect.r1 && rect.c0 === rect.c1)) return
3346
+ pushHistory()
3347
+ merges.value = [...merges.value, { r0: rect.r0, c0: rect.c0, r1: rect.r1, c1: rect.c1 }]
3348
+ const owner = colNameAt(rect.c0)
3349
+ select(rect.r0, owner)
3350
+ }
3351
+ const canMergeSel = computed(() => {
3352
+ const rect = selRect.value
3353
+ return !!rect && (rect.r0 !== rect.r1 || rect.c0 !== rect.c1)
3354
+ })
3355
+ const unmergeCells = () => {
3356
+ const rect = selRect.value
3357
+ if (!rect) return
3358
+ pushHistory()
3359
+ merges.value = merges.value.filter(
3360
+ (m) => !(m.r0 >= rect.r0 && m.r1 <= rect.r1 && m.c0 >= rect.c0 && m.c1 <= rect.c1),
3361
+ )
3362
+ }
3363
+ const inAnyMerge = (r: number, c: number) => !!mergeOwner(r, c)
3364
+ const purgeMergesAndRules = () => {
3365
+ merges.value = []
3366
+ condRules.value = []
3367
+ }
3368
+
3369
+ // ════════ Conditional formatting (règles par plage) ════════
3370
+ const cfOpen = ref(false)
3371
+ const cfDraft = ref<CondRule>({
3372
+ id: "",
3373
+ r0: 0,
3374
+ c0: 0,
3375
+ r1: 0,
3376
+ c1: 0,
3377
+ kind: "gt",
3378
+ value: 0,
3379
+ bg: "#fee2e2",
3380
+ })
3381
+ const openCf = () => {
3382
+ const rect = selRect.value
3383
+ cfOpen.value = !cfOpen.value
3384
+ if (cfOpen.value) {
3385
+ if (rect) {
3386
+ cfDraft.value = {
3387
+ ...cfDraft.value,
3388
+ r0: rect.r0,
3389
+ c0: rect.c0,
3390
+ r1: rect.r1,
3391
+ c1: rect.c1,
3392
+ }
3393
+ }
3394
+ if (cfDraft.value.id === "") cfDraft.value = { ...cfDraft.value, id: "cf-" + Date.now().toString(36) }
3395
+ }
3396
+ }
3397
+ const openCfFromMenu = () => {
3398
+ openCf()
3399
+ closeCtx()
3400
+ }
3401
+ const cfColScope = computed({
3402
+ get: () => !!cfDraft.value.colName,
3403
+ set: (v: boolean) => {
3404
+ if (v) cfDraft.value = { ...cfDraft.value, colName: colNameAt(cfDraft.value.c0) || undefined }
3405
+ else cfDraft.value = { ...cfDraft.value, colName: undefined }
3406
+ },
3407
+ })
3408
+ const editCondRule = (rule: CondRule) => {
3409
+ cfDraft.value = { ...rule }
3410
+ }
3411
+ const addCondRule = () => {
3412
+ pushHistory()
3413
+ const d = cfDraft.value
3414
+ const norm: CondRule = {
3415
+ ...d,
3416
+ mode: d.mode ?? "fill",
3417
+ barColor: d.barColor ?? d.bg ?? "#93c5fd",
3418
+ scaleLow: d.scaleLow ?? "#fca5a5",
3419
+ scaleHigh: d.scaleHigh ?? "#86efac",
3420
+ }
3421
+ condRules.value = [...condRules.value.filter((r) => r.id !== norm.id), norm]
3422
+ }
3423
+ const removeCondRule = (id: string) => {
3424
+ pushHistory()
3425
+ condRules.value = condRules.value.filter((r) => r.id !== id)
3426
+ }
3427
+ const clearCondRules = () => {
3428
+ pushHistory()
3429
+ condRules.value = []
3430
+ }
3431
+ const hexToRgb = (h: string) => {
3432
+ let s = h.replace("#", "")
3433
+ if (s.length === 3) s = s.split("").map((c) => c + c).join("")
3434
+ if (!/^[0-9a-f]{6}$/i.test(s)) return null
3435
+ return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]
3436
+ }
3437
+ const colNumericRange = (name: string): [number, number] | null => {
3438
+ let min = Infinity
3439
+ let max = -Infinity
3440
+ for (const row of state.value) {
3441
+ const v = row?.[name]
3442
+ if (typeof v === "number" && !Number.isNaN(v)) {
3443
+ if (v < min) min = v
3444
+ if (v > max) max = v
3445
+ }
3446
+ }
3447
+ return Number.isFinite(min) ? [min, max] : null
3448
+ }
3449
+ const condStyleOf = (r: number, c: number, name: string) => {
3450
+ if (cellFmtOf(r, name)?.bg) return undefined // formatage manuel prioritaire
3451
+ const rule = condRuleOf(r, c, name)
3452
+ if (!rule) return undefined
3453
+ const col = colOf(name)
3454
+ const val: any = state.value[r]?.[name]
3455
+ const num = col?.type === "number" || col?.type === "integer"
3456
+ const n = num ? Number(val) : NaN
3457
+ const s = val === null || val === undefined ? "" : String(val)
3458
+ const hit =
3459
+ rule.kind === "always"
3460
+ ? true
3461
+ : rule.kind === "formula"
3462
+ ? evalRuleFormula(rule.formula ?? "FALSE", r)
3463
+ : rule.kind === "blank"
3464
+ ? val === null || val === undefined || s === ""
3465
+ : rule.kind === "notblank"
3466
+ ? !(val === null || val === undefined || s === "")
3467
+ : rule.kind === "contains"
3468
+ ? s.toLowerCase().includes(String(rule.value ?? "").toLowerCase())
3469
+ : num && !Number.isNaN(n)
3470
+ ? rule.kind === "gt"
3471
+ ? n > Number(rule.value)
3472
+ : rule.kind === "gte"
3473
+ ? n >= Number(rule.value)
3474
+ : rule.kind === "lt"
3475
+ ? n < Number(rule.value)
3476
+ : rule.kind === "lte"
3477
+ ? n <= Number(rule.value)
3478
+ : rule.kind === "eq" && n === Number(rule.value)
3479
+ : false
3480
+ if (!hit) return undefined
3481
+ const st: Record<string, string> = {}
3482
+ if (rule.mode === "bar" && num && !Number.isNaN(n)) {
3483
+ const range = colNumericRange(name)
3484
+ const pct = range
3485
+ ? Math.max(0, Math.min(100, ((n - range[0]) / (range[1] - range[0] || 1)) * 100))
3486
+ : 0
3487
+ const bar = rule.barColor ?? rule.bg ?? "#93c5fd"
3488
+ st.backgroundImage = `linear-gradient(90deg, ${bar} ${pct}%, transparent ${pct}%)`
3489
+ st.backgroundColor = "transparent"
3490
+ return st
3491
+ }
3492
+ if (rule.mode === "scale" && num && !Number.isNaN(n)) {
3493
+ const range = colNumericRange(name)
3494
+ if (range) {
3495
+ const t = Math.max(0, Math.min(1, (n - range[0]) / (range[1] - range[0] || 1)))
3496
+ const low = hexToRgb(rule.scaleLow ?? "#fca5a5")
3497
+ const high = hexToRgb(rule.scaleHigh ?? "#86efac")
3498
+ if (low && high) {
3499
+ const lr = low[0] ?? 0
3500
+ const lg = low[1] ?? 0
3501
+ const lb = low[2] ?? 0
3502
+ const hr = high[0] ?? 255
3503
+ const hg = high[1] ?? 255
3504
+ const hb = high[2] ?? 255
3505
+ st.backgroundColor = `rgb(${Math.round(lr + (hr - lr) * t)}, ${Math.round(
3506
+ lg + (hg - lg) * t,
3507
+ )}, ${Math.round(lb + (hb - lb) * t)})`
3508
+ return st
3509
+ }
3510
+ }
3511
+ }
3512
+ if (rule.bg) st.backgroundColor = rule.bg
3513
+ if (rule.color) st.color = rule.color
3514
+ if (rule.bold) st.fontWeight = "700"
3515
+ return Object.keys(st).length ? st : undefined
3516
+ }
3517
+
3518
+ // ════════ Validation de colonne (min / max / entier / pattern) ════════
3519
+ const valErrors = ref<Record<string, string>>({})
3520
+ const valKey = (r: number, name: string) => r + ":" + name
3521
+ const guardValidation = async (
3522
+ row: number,
3523
+ col: QSpreadsheetColumn | undefined,
3524
+ value: any,
3525
+ ): Promise<string | null> => {
3526
+ let ark: Promise<{ type: (s: string) => (v: unknown) => { message?: string }[] } | null> | null = null
3527
+ const arkCheck = async (schema: string): Promise<string | null> => {
3528
+ if (!ark)
3529
+ ark = import("arktype")
3530
+ .then((m) => m as any)
3531
+ .catch(() => null)
3532
+ const m = await ark
3533
+ if (!m) return "ArkType unavailable"
3534
+ const problems = m.type(schema)(value)
3535
+ const p = Array.isArray(problems) ? problems[0] : undefined
3536
+ return p?.message ?? null
3537
+ }
3538
+ const check = async (v: QSpreadsheetValidation | undefined): Promise<string | null> => {
3539
+ if (!v) return null
3540
+ const blank = value === null || value === undefined || value === ""
3541
+ if (v.required && blank) return v.message ?? "Required"
3542
+ if (blank) return null // champ vide autorisé (sauf required)
3543
+ if (v.list && !v.list.some((x) => String(x) === String(value))) return v.message ?? "Not in the allowed list"
3544
+ if (v.integer && typeof value === "number" && !Number.isInteger(value))
3545
+ return v.message ?? "Integer required"
3546
+ if (typeof value === "number") {
3547
+ if (v.min !== undefined && value < v.min) return v.message ?? "Value below minimum (" + v.min + ")"
3548
+ if (v.max !== undefined && value > v.max) return v.message ?? "Value above maximum (" + v.max + ")"
3549
+ }
3550
+ if (v.pattern && typeof value === "string" && !new RegExp(v.pattern).test(value))
3551
+ return v.message ?? "Value does not match the required format"
3552
+ if (v.schema) {
3553
+ const e = await arkCheck(v.schema)
3554
+ if (e) return v.message ?? e
3555
+ }
3556
+ return null
3557
+ }
3558
+ const colErr = await check(col?.validation)
3559
+ if (colErr) return colErr
3560
+ if (props.validators?.length && col) {
3561
+ const ci = colIndex(col.name)
3562
+ for (const rv of props.validators) {
3563
+ if (row >= rv.r0 && row <= rv.r1 && ci >= rv.c0 && ci <= rv.c1) {
3564
+ const e = await check(rv.validation)
3565
+ if (e) return e
3566
+ }
3567
+ }
3568
+ }
3569
+ return null
3570
+ }
3571
+ const clearValError = (r: number, name: string) => {
3572
+ const k = valKey(r, name)
3573
+ if (valErrors.value[k]) {
3574
+ const n = { ...valErrors.value }
3575
+ delete n[k]
3576
+ valErrors.value = n
3577
+ }
3578
+ }
3579
+ const valErrorOf = (r: number, name: string) => valErrors.value[valKey(r, name)]
3580
+ const validateAndSet = async (r: number, name: string, old: any, next: any): Promise<boolean> => {
3581
+ const err = await guardValidation(r, colOf(name), next)
3582
+ if (err) {
3583
+ valErrors.value = { ...valErrors.value, [valKey(r, name)]: err }
3584
+ return false
3585
+ }
3586
+ clearValError(r, name)
3587
+ setCellValue(r, name, old, next)
3588
+ return true
3589
+ }
3590
+
3591
+ // Helpers de rendu (template) : erreurs, merge, wrap, style conditionnel
3592
+ const errCellClass = (r: number, name: string) =>
3593
+ valErrorOf(r, name) ? "q-spreadsheet__cell--invalid" : undefined
3594
+ const tdTitle = (r: number, col: QSpreadsheetColumn): string => {
3595
+ const err = valErrorOf(r, col.name)
3596
+ if (err) return "⚠ " + err
3597
+ const t = cellTitle(r, col.name)
3598
+ if (t) return t
3599
+ const raw = cellValue(r, col.name)
3600
+ return col.type === "select" && raw !== null && raw !== undefined ? String(raw) : ""
3601
+ }
3602
+ const extraCellStyle = (r: number, c: number, name: string) => {
3603
+ const st: Record<string, string> = {}
3604
+ if (isWrappedAt(r, name)) st.whiteSpace = "normal"
3605
+ const cond = condStyleOf(r, c, name)
3606
+ if (cond) Object.assign(st, cond)
3607
+ return Object.keys(st).length ? st : undefined
3608
+ }
3609
+ const skipMergedCell = (r: number, c: number) => {
3610
+ const info = mergeInfoOf(r, c)
3611
+ return !!info && !info.owner
3612
+ }
3613
+ const mergeRowSpan = (r: number, c: number): number | undefined => {
3614
+ const info = mergeInfoOf(r, c)
3615
+ return info && info.owner && info.rows > 1 ? info.rows : undefined
3616
+ }
3617
+ const mergeColSpan = (r: number, c: number): number | undefined => {
3618
+ const info = mergeInfoOf(r, c)
3619
+ return info && info.owner && info.cols > 1 ? info.cols : undefined
3620
+ }
3621
+ const hasMergeAt = (r: number, c: number) => !!mergeInfoOf(r, c)
3622
+
3623
+ // ════════ Zoom & barre d'état ════════
3624
+ const ZOOM_MIN = 0.5
3625
+ const ZOOM_MAX = 2
3626
+ const zoomLevel = ref(1)
3627
+ const zoomIn = () => {
3628
+ zoomLevel.value = Math.min(ZOOM_MAX, Math.round((zoomLevel.value + 0.1) * 100) / 100)
3629
+ }
3630
+ const zoomOut = () => {
3631
+ zoomLevel.value = Math.max(ZOOM_MIN, Math.round((zoomLevel.value - 0.1) * 100) / 100)
3632
+ }
3633
+ const resetZoom = () => {
3634
+ zoomLevel.value = 1
3635
+ }
3636
+ const getZoom = () => zoomLevel.value
3637
+ const zoomTableStyle = computed(() => ({ zoom: String(zoomLevel.value) }))
3638
+ const zoomPct = computed(() => Math.round(zoomLevel.value * 100) + "%")
3639
+
3640
+ const selectionStats = computed(() => {
3641
+ const rect = selRect.value
3642
+ let count = 0
3643
+ let sum = 0
3644
+ if (rect) {
3645
+ for (let r = rect.r0; r <= rect.r1; r++) {
3646
+ for (let c = rect.c0; c <= rect.c1; c++) {
3647
+ const ev = evalAt(r, colNameAt(c))
3648
+ if (typeof ev === "number" && !Number.isNaN(ev)) {
3649
+ sum += ev
3650
+ count++
3651
+ }
3652
+ }
3653
+ }
3654
+ }
3655
+ return { count, sum, avg: count ? sum / count : 0 }
3656
+ })
3657
+ const statText = (n: number) => {
3658
+ if (!Number.isFinite(n)) return "—"
3659
+ const s = Math.abs(n) >= 1000 ? n.toLocaleString("en-US", { maximumFractionDigits: 1 }) : String(Math.round(n * 100) / 100)
3660
+ return s
3661
+ }
3662
+ const statusDims = computed(() => {
3663
+ const rect = selRect.value
3664
+ return rect ? (rect.r1 - rect.r0 + 1) + " × " + (rect.c1 - rect.c0 + 1) : null
3665
+ })
3666
+ const statusSheets = computed(() => (multiMode.value ? fmt("statusSheets", { n: localSheets.value.length }) : ""))
3667
+ const statusRowInfo = computed(() =>
3668
+ (hasActiveFilters.value || hiddenRows.value.length
3669
+ ? fmt("statusRowsShown", { total: state.value.length, shown: visibleRows.value.length })
3670
+ : fmt("statusRows", { total: state.value.length })),
3671
+ )
3672
+
3673
+ // ════════ Fill formats only (bas de la sélection) ════════
3674
+ const fillFormatsDown = () => {
3675
+ const rect = selRect.value
3676
+ if (!rect || props.readonly || props.disable) return
3677
+ pushHistory()
3678
+ const nf = { ...cellFmt.value }
3679
+ for (let c = rect.c0; c <= rect.c1; c++) {
3680
+ const name = colNameAt(c)
3681
+ const srcKey = fmtKey(rect.r0, name)
3682
+ const src = nf[srcKey]
3683
+ if (!src) continue
3684
+ for (let r = rect.r0 + 1; r <= rect.r1; r++) {
3685
+ nf[fmtKey(r, name)] = { ...src }
3686
+ }
3687
+ }
3688
+ cellFmt.value = nf
3689
+ }
3690
+
3691
+ // ════════ Drag & drop : réordonner lignes / colonnes ════════
3692
+ const dragRow = ref<number | null>(null)
3693
+ const dragRowTarget = ref<number | null>(null)
3694
+ const dragCol = ref<number | null>(null)
3695
+ const dragColTarget = ref<number | null>(null)
3696
+
3697
+ const reorderRowsBy = (from: number, to: number) => {
3698
+ if (from === to || props.readonly || props.disable) return
3699
+ pushHistory()
3700
+ const next = [...state.value]
3701
+ const item = next.splice(from, 1)[0] as Record<string, any>
3702
+ next.splice(to, 0, item)
3703
+ state.value = next
3704
+ emit("update:rows", next)
3705
+ }
3706
+ const reorderColsBy = (from: number, to: number) => {
3707
+ if (from === to || props.readonly || props.disable) return
3708
+ const next = [...cols.value]
3709
+ const item = next.splice(from, 1)[0] as QSpreadsheetColumn
3710
+ next.splice(to, 0, item)
3711
+ cols.value = next
3712
+ emit("update:columns", next)
3713
+ }
3714
+ const rowDragStart = (ri: number, e: PointerEvent) => {
3715
+ if (props.disable || props.readonly) return
3716
+ e.preventDefault()
3717
+ dragRow.value = ri
3718
+ dragRowTarget.value = ri
3719
+ window.addEventListener("pointermove", onRowDragMove)
3720
+ window.addEventListener("pointerup", onRowDragEnd)
3721
+ }
3722
+ const onRowDragMove = (e: PointerEvent) => {
3723
+ const el = document.elementFromPoint(e.clientX, e.clientY)
3724
+ const th = el && el.closest ? (el.closest(".q-spreadsheet__rownum") as HTMLElement | null) : null
3725
+ const idx = th ? Number(th.dataset.row) : NaN
3726
+ if (!Number.isNaN(idx)) dragRowTarget.value = idx
3727
+ }
3728
+ const onRowDragEnd = () => {
3729
+ window.removeEventListener("pointermove", onRowDragMove)
3730
+ window.removeEventListener("pointerup", onRowDragEnd)
3731
+ if (dragRow.value !== null && dragRowTarget.value !== null)
3732
+ reorderRowsBy(dragRow.value, dragRowTarget.value)
3733
+ dragRow.value = null
3734
+ dragRowTarget.value = null
3735
+ }
3736
+ const colDragStart = (ci: number, e: PointerEvent) => {
3737
+ if (props.disable || props.readonly) return
3738
+ e.preventDefault()
3739
+ dragCol.value = ci
3740
+ dragColTarget.value = ci
3741
+ window.addEventListener("pointermove", onColDragMove)
3742
+ window.addEventListener("pointerup", onColDragEnd)
3743
+ }
3744
+ const onColDragMove = (e: PointerEvent) => {
3745
+ const el = document.elementFromPoint(e.clientX, e.clientY)
3746
+ const th = el && el.closest ? (el.closest(".q-spreadsheet__colhead") as HTMLElement | null) : null
3747
+ const idx = th ? Number(th.dataset.colIndex) : NaN
3748
+ if (!Number.isNaN(idx)) dragColTarget.value = idx
3749
+ }
3750
+ const onColDragEnd = () => {
3751
+ window.removeEventListener("pointermove", onColDragMove)
3752
+ window.removeEventListener("pointerup", onColDragEnd)
3753
+ if (dragCol.value !== null && dragColTarget.value !== null)
3754
+ reorderColsBy(dragCol.value, dragColTarget.value)
3755
+ dragCol.value = null
3756
+ dragColTarget.value = null
3757
+ }
3758
+
3759
+ defineExpose({
3760
+ select,
3761
+ selectRow,
3762
+ selectCol,
3763
+ selectAll,
3764
+ moveSelection,
3765
+ startEdit,
3766
+ commitEdit,
3767
+ cancelEdit,
3768
+ addRow,
3769
+ insertRowAt,
3770
+ removeSelectedRows,
3771
+ addColumn,
3772
+ insertColumnAt,
3773
+ removeSelectedColumns,
3774
+ sortByColumn,
3775
+ clearSelection,
3776
+ copySelection,
3777
+ pasteClip,
3778
+ undo,
3779
+ redo,
3780
+ toggleBoldSelection,
3781
+ toggleItalicSelection,
3782
+ setBgColorSelection,
3783
+ setTextColorSelection,
3784
+ clearFormatSelection,
3785
+ setFilterAll,
3786
+ setFilterOnly,
3787
+ toggleFilterValue,
3788
+ visibleRows,
3789
+ goSheet,
3790
+ addSheet,
3791
+ removeSheet,
3792
+ activeSheetName,
3793
+ toJSON,
3794
+ loadDocument,
3795
+ exportJson,
3796
+ exportCsv,
3797
+ getCsv,
3798
+ openFind,
3799
+ closeFind,
3800
+ importCsv,
3801
+ importFile,
3802
+ copyFormulas,
3803
+ pasteValues,
3804
+ pasteTransposed,
3805
+ toggleWrapSelection,
3806
+ mergeCells,
3807
+ unmergeCells,
3808
+ hideSelectedRows,
3809
+ hideRowAt,
3810
+ showAllRows,
3811
+ hideColumnAt,
3812
+ showColumnName,
3813
+ openCf,
3814
+ addCondRule,
3815
+ removeCondRule,
3816
+ clearCondRules,
3817
+ zoomIn,
3818
+ zoomOut,
3819
+ resetZoom,
3820
+ getZoom,
3821
+ fillFormatsDown,
3822
+ focus,
3823
+ })
3824
+ </script>
3825
+
3826
+ <template>
3827
+ <div class="q-spreadsheet" :class="rootClasses" :style="radiusStyle(effectiveRadius)">
3828
+ <!-- ═══════ Onglets de feuilles (classeur, haut) ═══════ -->
3829
+ <div
3830
+ v-if="sheetsMode && sheetsPosition === 'top'"
3831
+ class="q-spreadsheet__sheets"
3832
+ role="tablist"
3833
+ aria-label="Sheets"
3834
+ >
3835
+ <div
3836
+ v-for="(s, i) in localSheets"
3837
+ :key="s.key"
3838
+ class="q-spreadsheet__sheet-tab"
3839
+ :class="{ 'q-spreadsheet__sheet-tab--on': i === sheetIdx }"
3840
+ role="tab"
3841
+ :aria-selected="i === sheetIdx ? 'true' : 'false'"
3842
+ @click="goSheet(i)"
3843
+ @dblclick.stop="startSheetRename(i)"
3844
+ >
3845
+ <input
3846
+ v-if="sheetRename && sheetRename.idx === i"
3847
+ v-model="sheetRename.val"
3848
+ class="q-spreadsheet__sheet-rename"
3849
+ @click.stop
3850
+ @blur="commitSheetRename"
3851
+ @keydown.enter.prevent="commitSheetRename"
3852
+ @keydown.esc.prevent="sheetRename = null"
3853
+ />
3854
+ <template v-else>
3855
+ <span class="q-spreadsheet__sheet-name-label">{{ s.name }}</span>
3856
+ <button
3857
+ v-if="localSheets.length > 1"
3858
+ type="button"
3859
+ class="q-spreadsheet__sheet-close"
3860
+ aria-label="Remove sheet"
3861
+ @click.stop="removeSheet(i)"
3862
+ >
3863
+ <Icon :icon="icons.x" aria-hidden="true" />
3864
+ </button>
3865
+ </template>
3866
+ </div>
3867
+ <button
3868
+ type="button"
3869
+ class="q-spreadsheet__sheet-add"
3870
+ title="Add sheet"
3871
+ aria-label="Add sheet"
3872
+ @click="addSheet"
3873
+ >
3874
+ <Icon :icon="icons.plus" aria-hidden="true" />
3875
+ </button>
3876
+ </div>
3877
+
3878
+ <!-- ═══════ Barre d'outils ═══════ -->
3879
+ <div v-if="showToolbar" class="q-spreadsheet__toolbar" role="toolbar" aria-label="Spreadsheet">
3880
+ <span class="q-spreadsheet__sheet-name" :title="selLabel || 'Sheet'">
3881
+ <Icon :icon="icons.fileSpreadsheet" aria-hidden="true" />
3882
+ <span v-if="selLabel">{{ selLabel }}</span>
3883
+ <span v-else>{{ t('sheet') }}</span>
3884
+ </span>
3885
+
3886
+ <span class="q-spreadsheet__tb-group">
3887
+ <button class="q-spreadsheet__tool" type="button" :disabled="!canUndo" title="Undo (Ctrl+Z)" aria-label="Undo" @click="undo">
3888
+ <Icon :icon="icons.undo2" aria-hidden="true" />
3889
+ </button>
3890
+ <button class="q-spreadsheet__tool" type="button" :disabled="!canRedo" title="Redo (Ctrl+Y)" aria-label="Redo" @click="redo">
3891
+ <Icon :icon="icons.redo2" aria-hidden="true" />
3892
+ </button>
3893
+ </span>
3894
+
3895
+ <span class="q-spreadsheet__tb-group">
3896
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add row" aria-label="Add row" @click="addRow()">
3897
+ <Icon :icon="icons.rows3" aria-hidden="true" />
3898
+ </button>
3899
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
3900
+ <Icon :icon="icons.minus" aria-hidden="true" />
3901
+ </button>
3902
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add column" aria-label="Add column" @click="addColumn()">
3903
+ <Icon :icon="icons.columns3" aria-hidden="true" />
3904
+ </button>
3905
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect" title="Remove selected column(s)" aria-label="Remove columns" @click="removeSelectedColumns">
3906
+ <Icon :icon="icons.x" aria-hidden="true" />
3907
+ </button>
3908
+ </span>
3909
+
3910
+ <span class="q-spreadsheet__tb-group">
3911
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !sel" title="Clear selected cells" aria-label="Clear cells" @click="clearSelection">
3912
+ <Icon :icon="icons.eraser" aria-hidden="true" />
3913
+ </button>
3914
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !sel" title="Sort A→Z by active column" aria-label="Sort ascending" @click="sortByColumn(sel?.column, false)">
3915
+ <Icon :icon="icons.sortAsc" aria-hidden="true" />
3916
+ </button>
3917
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !sel" title="Sort Z→A by active column" aria-label="Sort descending" @click="sortByColumn(sel?.column, true)">
3918
+ <Icon :icon="icons.sortDesc" aria-hidden="true" />
3919
+ </button>
3920
+ <button class="q-spreadsheet__tool" type="button" :disabled="!sel" title="Copy (Ctrl+C)" aria-label="Copy" @click="copySelection">
3921
+ <Icon :icon="icons.copy" aria-hidden="true" />
3922
+ </button>
3923
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Paste (Ctrl+V)" aria-label="Paste" @click="pasteClip">
3924
+ <Icon :icon="icons.clipboardPaste" aria-hidden="true" />
3925
+ </button>
3926
+ <button
3927
+ class="q-spreadsheet__tool"
3928
+ type="button"
3929
+ title="Find & Replace (Ctrl+F)"
3930
+ aria-label="Find"
3931
+ @click="openFind"
3932
+ >
3933
+ <Icon :icon="icons.search" aria-hidden="true" />
3934
+ </button>
3935
+ <button
3936
+ class="q-spreadsheet__tool"
3937
+ type="button"
3938
+ :class="{ 'q-spreadsheet__tool--on': cfOpen }"
3939
+ title="Conditional formatting"
3940
+ aria-label="Conditional formatting"
3941
+ @click="openCf"
3942
+ >
3943
+ <Icon :icon="icons.highlighter" aria-hidden="true" />
3944
+ </button>
3945
+ </span>
3946
+ </div>
3947
+
3948
+ <!-- ═══════ Barre de formule (fx) ═══════ -->
3949
+ <div v-if="showFormulaBar && cols.length" class="q-spreadsheet__fx">
3950
+ <span class="q-spreadsheet__fx-ref">{{ selLabel || "—" }}</span>
3951
+ <span class="q-spreadsheet__fx-badge" title="Formula bar">fx</span>
3952
+ <input
3953
+ ref="fxInputEl"
3954
+ class="q-spreadsheet__fx-input"
3955
+ :value="fxDraft"
3956
+ :disabled="readonly || disable"
3957
+ :placeholder="fxCanEdit ? t('fxPlaceholder') : t('fxNoSel')"
3958
+ spellcheck="false"
3959
+ @input="onFxInput"
3960
+ @keydown="onFxKeydown"
3961
+ @blur="commitFx"
3962
+ />
3963
+ </div>
3964
+
3965
+ <!-- ═══════ Barre Find & Replace ═══════ -->
3966
+ <div v-if="findOpen" class="q-spreadsheet__find">
3967
+ <Icon :icon="icons.search" class="q-spreadsheet__find-icon" aria-hidden="true" />
3968
+ <input
3969
+ ref="findInputEl"
3970
+ class="q-spreadsheet__find-input"
3971
+ :value="findQuery"
3972
+ :placeholder="t('findPh')"
3973
+ spellcheck="false"
3974
+ @input="onFindInput"
3975
+ @keydown="onFindKeydown"
3976
+ />
3977
+ <input
3978
+ class="q-spreadsheet__find-input q-spreadsheet__find-input--repl"
3979
+ v-model="findReplaceText"
3980
+ :placeholder="t('replacePh')"
3981
+ spellcheck="false"
3982
+ @keydown.enter.prevent="findReplaceCurrent"
3983
+ @keydown.esc="closeFind"
3984
+ />
3985
+ <span class="q-spreadsheet__find-count">
3986
+ {{ findQuery && findMatches.length ? (findIdx + 1) + " / " + findMatches.length : "0" }}
3987
+ </span>
3988
+ <button class="q-spreadsheet__tool" type="button" :title="t('prev')" :aria-label="t('prev')" :disabled="!findMatches.length" @click="findNext(-1)">
3989
+ <Icon :icon="icons.chevronUp" aria-hidden="true" />
3990
+ </button>
3991
+ <button class="q-spreadsheet__tool" type="button" :title="t('next')" :aria-label="t('next')" :disabled="!findMatches.length" @click="findNext(1)">
3992
+ <Icon :icon="icons.chevronDown" aria-hidden="true" />
3993
+ </button>
3994
+ <button class="q-spreadsheet__tool" type="button" :title="t('replace')" :aria-label="t('replace')" :disabled="!findMatches.length || readonly || disable" @click="findReplaceCurrent">
3995
+ <Icon :icon="icons.refreshCw" aria-hidden="true" />
3996
+ </button>
3997
+ <button class="q-spreadsheet__tool" type="button" :title="t('replaceAll')" :aria-label="t('replaceAll')" :disabled="!findMatches.length || readonly || disable" @click="findReplaceAll">
3998
+ <Icon :icon="icons.eraser" aria-hidden="true" />
3999
+ </button>
4000
+ <button class="q-spreadsheet__tool q-spreadsheet__find-close" type="button" :title="t('close')" aria-label="Close find" @click="closeFind">
4001
+ <Icon :icon="icons.x" aria-hidden="true" />
4002
+ </button>
4003
+ </div>
4004
+
4005
+ <!-- ═══════ Panneau Conditional formatting ═══════ -->
4006
+ <div v-if="cfOpen" class="q-spreadsheet__cf">
4007
+ <Icon :icon="icons.highlighter" aria-hidden="true" />
4008
+ <span class="q-spreadsheet__cf-title">
4009
+ {{ cfDraft.c0 !== cfDraft.c1 || cfDraft.r0 !== cfDraft.r1
4010
+ ? colLetter(cfDraft.c0) + (cfDraft.r0 + 1) + ':' + colLetter(cfDraft.c1) + (cfDraft.r1 + 1)
4011
+ : t('condSelection') }}
4012
+ </span>
4013
+ <select v-model="cfDraft.kind" class="q-spreadsheet__cf-kind" title="Condition">
4014
+ <option value="gt">{{ t('condGreater') }}</option>
4015
+ <option value="gte">{{ t('condGe') }}</option>
4016
+ <option value="lt">{{ t('condLess') }}</option>
4017
+ <option value="lte">{{ t('condLe') }}</option>
4018
+ <option value="eq">{{ t('condEq') }}</option>
4019
+ <option value="contains">{{ t('condContains') }}</option>
4020
+ <option value="blank">{{ t('condBlank') }}</option>
4021
+ <option value="notblank">{{ t('condNotBlank') }}</option>
4022
+ <option value="formula">{{ t('condFormula') }}</option>
4023
+ <option value="always">always</option>
4024
+ </select>
4025
+ <select v-model="cfDraft.mode" class="q-spreadsheet__cf-kind" title="Style">
4026
+ <option value="fill">fill</option>
4027
+ <option value="bar">data bar</option>
4028
+ <option value="scale">color scale</option>
4029
+ </select>
4030
+ <input
4031
+ v-if="cfDraft.mode === 'bar'"
4032
+ v-model="cfDraft.barColor"
4033
+ type="color"
4034
+ class="q-spreadsheet__cf-kind"
4035
+ title="Bar color"
4036
+ style="width: 34px; padding: 1px"
4037
+ />
4038
+ <template v-if="cfDraft.mode === 'scale'">
4039
+ <input v-model="cfDraft.scaleLow" type="color" class="q-spreadsheet__cf-kind" title="Low color" style="width: 34px; padding: 1px" />
4040
+ <input v-model="cfDraft.scaleHigh" type="color" class="q-spreadsheet__cf-kind" title="High color" style="width: 34px; padding: 1px" />
4041
+ </template>
4042
+ <input
4043
+ v-model="cfDraft.value"
4044
+ class="q-spreadsheet__find-input"
4045
+ type="text"
4046
+ style="width: 90px"
4047
+ :placeholder="cfDraft.kind === 'formula' ? '=A1>10' : t('condValue')"
4048
+ spellcheck="false"
4049
+ />
4050
+ <label class="q-spreadsheet__cf-col" title="Apply to the whole column">
4051
+ <input v-model="cfColScope" type="checkbox" />
4052
+ Column
4053
+ </label>
4054
+ <div class="q-spreadsheet__swatches q-spreadsheet__swatches--mini">
4055
+ <button
4056
+ v-for="c in ['#fee2e2', '#fef9c3', '#dcfce7', '#dbeafe', '#ede9fe', '#ffffff']"
4057
+ :key="c"
4058
+ type="button"
4059
+ class="q-spreadsheet__swatch"
4060
+ :class="{ 'q-spreadsheet__swatch--on': cfDraft.bg === c }"
4061
+ :style="swatchInline(c)"
4062
+ @click="cfDraft.bg = c"
4063
+ />
4064
+ </div>
4065
+ <button
4066
+ class="q-spreadsheet__tool"
4067
+ type="button"
4068
+ :class="{ 'q-spreadsheet__tool--on': cfDraft.bold }"
4069
+ title="Bold"
4070
+ aria-label="Bold"
4071
+ @click="cfDraft.bold = !cfDraft.bold"
4072
+ >
4073
+ <Icon :icon="icons.bold" aria-hidden="true" />
4074
+ </button>
4075
+ <button class="demo-btn q-spreadsheet__cf-apply" type="button" @click="addCondRule">
4076
+ {{ condRules.some((r) => r.id === cfDraft.id) ? t('condUpdate') : t('condAdd') }}
4077
+ </button>
4078
+ <button class="q-spreadsheet__tool" type="button" :title="t('condClear')" :aria-label="t('condClear')" @click="clearCondRules">
4079
+ <Icon :icon="icons.trash2" aria-hidden="true" />
4080
+ </button>
4081
+ <button class="q-spreadsheet__tool" type="button" :title="t('close')" aria-label="Close conditional" @click="openCf">
4082
+ <Icon :icon="icons.x" aria-hidden="true" />
4083
+ </button>
4084
+
4085
+ <div v-if="condRules.length" class="q-spreadsheet__cf-list">
4086
+ <span
4087
+ v-for="rule in condRules"
4088
+ :key="rule.id"
4089
+ class="q-spreadsheet__cf-chip"
4090
+ :class="{ 'q-spreadsheet__cf-chip--on': rule.id === cfDraft.id }"
4091
+ :style="rule.bg ? { backgroundColor: rule.bg } : undefined"
4092
+ title="Click to edit this rule"
4093
+ @click="editCondRule(rule)"
4094
+ >
4095
+ {{ rule.colName
4096
+ ? 'Col ' + rule.colName
4097
+ : colLetter(rule.c0) + (rule.r0 + 1) + (rule.c0 !== rule.c1 || rule.r0 !== rule.r1 ? ':' + colLetter(rule.c1) + (rule.r1 + 1) : '') }}
4098
+ · {{ rule.kind }}
4099
+ <button
4100
+ type="button"
4101
+ class="q-spreadsheet__sheet-close"
4102
+ aria-label="Remove rule"
4103
+ @click.stop="removeCondRule(rule.id)"
4104
+ >
4105
+ <Icon :icon="icons.x" aria-hidden="true" />
4106
+ </button>
4107
+ </span>
4108
+ </div>
4109
+ </div>
4110
+
4111
+ <!-- ═══════ Grille ═══════ -->
4112
+ <div
4113
+ :ref="setGridRef"
4114
+ class="q-spreadsheet__viewport"
4115
+ :style="{ height: heightStyle }"
4116
+ tabindex="0"
4117
+ @keydown="onKeydown"
4118
+ @scroll="onViewportScroll"
4119
+ @contextmenu.prevent
4120
+ >
4121
+ <table class="q-spreadsheet__table" cellspacing="0" :style="zoomTableStyle">
4122
+ <thead v-if="showColumnHeaders">
4123
+ <tr class="q-spreadsheet__head-row">
4124
+ <th
4125
+ class="q-spreadsheet__corner"
4126
+ aria-label="Select all"
4127
+ @click="selectAll"
4128
+ @contextmenu="selectAll"
4129
+ >
4130
+ <Icon v-if="isFullSelection" :icon="icons.check" aria-hidden="true" />
4131
+ </th>
4132
+ <th
4133
+ v-for="(col, ci) in cols"
4134
+ :key="col.name"
4135
+ :data-col-index="ci"
4136
+ class="q-spreadsheet__colhead"
4137
+ :class="[
4138
+ col.headerClass,
4139
+ { 'q-spreadsheet__colhead--sel': isActiveCol(ci) },
4140
+ isFrozenCol(ci) && 'q-spreadsheet__colhead--frozen',
4141
+ isColHiddenName(col.name) && 'q-spreadsheet__colhead--hide',
4142
+ dragCol !== null && dragColTarget === ci && 'q-spreadsheet__colhead--drop',
4143
+ ]"
4144
+ @pointerdown="colDragStart(ci, $event)"
4145
+ :style="[
4146
+ { width: widthStyle(col), minWidth: minW(col), maxWidth: maxW(col) },
4147
+ col.headerStyle,
4148
+ colheadInline(ci),
4149
+ ]"
4150
+ @click="selectCol(ci, $event.shiftKey)"
4151
+ @contextmenu="onColContext(ci, $event)"
4152
+ >
4153
+ <span class="q-spreadsheet__colhead-inner">
4154
+ <span class="q-spreadsheet__colhead-letter">{{ colLetter(ci) }}</span>
4155
+ <span class="q-spreadsheet__colhead-label">{{ col.label || col.name }}</span>
4156
+ </span>
4157
+ <button
4158
+ class="q-spreadsheet__fbtn"
4159
+ :class="{ 'q-spreadsheet__fbtn--on': hasFilterFor(col.name) }"
4160
+ type="button"
4161
+ tabindex="-1"
4162
+ title="Filter column"
4163
+ aria-label="Filter column"
4164
+ @pointerdown.stop
4165
+ @click.stop="openFilter(ci, $event)"
4166
+ >
4167
+ <Icon :icon="icons.filter" aria-hidden="true" />
4168
+ </button>
4169
+ <span
4170
+ class="q-spreadsheet__resize"
4171
+ title="Resize column"
4172
+ @pointerdown.stop.prevent="startResize($event, col)"
4173
+ />
4174
+ </th>
4175
+ </tr>
4176
+ </thead>
4177
+ <tbody>
4178
+ <tr v-if="cols.length === 0">
4179
+ <td :colspan="(showRowNumbers ? 1 : 0) || 1" class="q-spreadsheet__no-rows">
4180
+ Add a column with the toolbar (+) or right-click to start.
4181
+ </td>
4182
+ </tr>
4183
+ <template v-if="virtualOn && topPad > 0">
4184
+ <tr class="q-spreadsheet__vpad" aria-hidden="true">
4185
+ <td
4186
+ :colspan="(showRowNumbers ? 1 : 0) + visibleColsCount"
4187
+ :style="{ height: topPad + 'px' }"
4188
+ />
4189
+ </tr>
4190
+ </template>
4191
+ <tr
4192
+ v-for="ri in renderRows"
4193
+ :key="ri"
4194
+ :style="{ height: rowDisplayH(ri) + 'px' }"
4195
+ >
4196
+ <th
4197
+ v-if="showRowNumbers"
4198
+ :data-row="ri"
4199
+ class="q-spreadsheet__rownum"
4200
+ :class="[
4201
+ { 'q-spreadsheet__rownum--sel': isActiveRow(ri) },
4202
+ isFrozenRow(ri) && 'q-spreadsheet__rownum--frozen',
4203
+ dragRow !== null && dragRowTarget === ri && 'q-spreadsheet__rownum--drop',
4204
+ ]"
4205
+ :style="rownumInline(ri)"
4206
+ @pointerdown="rowDragStart(ri, $event)"
4207
+ @click="selectRow(ri, $event.shiftKey)"
4208
+ @contextmenu="onRowContext(ri, $event)"
4209
+ >
4210
+ {{ ri + 1 }}
4211
+ <span
4212
+ class="q-spreadsheet__row-resize"
4213
+ title="Resize row"
4214
+ @pointerdown.stop.prevent="startRowResize($event, ri)"
4215
+ />
4216
+ </th>
4217
+ <td
4218
+ v-for="(col, ci) in cols"
4219
+ :key="col.name"
4220
+ class="q-spreadsheet__cell"
4221
+ :class="[
4222
+ cellClasses(ri, col.name),
4223
+ cellFreezeClass(ri, ci),
4224
+ fillCellClass(ri, ci),
4225
+ findMarkClass(ri, ci),
4226
+ errCellClass(ri, col.name),
4227
+ hasMergeAt(ri, ci) && 'q-spreadsheet__cell--merged',
4228
+ (isColHiddenName(col.name) || skipMergedCell(ri, ci)) && 'q-spreadsheet__cell--hide',
4229
+ ]"
4230
+ :rowspan="mergeRowSpan(ri, ci)"
4231
+ :colspan="mergeColSpan(ri, ci)"
4232
+ :style="[
4233
+ { width: widthStyle(col), minWidth: minW(col), maxWidth: maxW(col) },
4234
+ cellInlineStyle(ri, ci, col.name),
4235
+ extraCellStyle(ri, ci, col.name),
4236
+ ]"
4237
+ :data-row="ri"
4238
+ :data-col="col.name"
4239
+ :title="tdTitle(ri, col)"
4240
+ tabindex="-1"
4241
+ @pointerdown="onCellPointerDown(ri, col.name, $event)"
4242
+ @pointerenter="onCellPointerEnter(ri, col.name)"
4243
+ @dblclick="startEdit(ri, col.name)"
4244
+ @contextmenu="onCellContext(ri, col.name, $event)"
4245
+ >
4246
+ <!-- booléen -->
4247
+ <button
4248
+ v-if="col.type === 'boolean'"
4249
+ type="button"
4250
+ class="q-spreadsheet__checkbox"
4251
+ :class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
4252
+ :disabled="readonly || disable || col.editable === false"
4253
+ aria-label="Toggle"
4254
+ @click.stop="toggleBoolean(ri, col.name)"
4255
+ >
4256
+ <Icon v-if="cellValue(ri, col.name)" :icon="icons.check" aria-hidden="true" />
4257
+ </button>
4258
+
4259
+ <!-- select : badge coloré -->
4260
+ <span
4261
+ v-else-if="col.type === 'select' && badgeOf(col, cellValue(ri, col.name))"
4262
+ class="q-spreadsheet__badge"
4263
+ :style="badgeOf(col, cellValue(ri, col.name))!.style"
4264
+ >
4265
+ {{ badgeOf(col, cellValue(ri, col.name))!.label }}
4266
+ </span>
4267
+
4268
+ <!-- valeur -->
4269
+ <span
4270
+ v-else
4271
+ class="q-spreadsheet__cell-text"
4272
+ :class="cellTextWrapClass(ri, col.name)"
4273
+ >{{ cellContent(ri, col) }}</span>
4274
+ </td>
4275
+ </tr>
4276
+ <template v-if="virtualOn && bottomPad > 0">
4277
+ <tr class="q-spreadsheet__vpad" aria-hidden="true">
4278
+ <td
4279
+ :colspan="(showRowNumbers ? 1 : 0) + visibleColsCount"
4280
+ :style="{ height: bottomPad + 'px' }"
4281
+ />
4282
+ </tr>
4283
+ </template>
4284
+ <tr v-if="cols.length > 0 && visibleRows.length === 0">
4285
+ <td
4286
+ :colspan="(showRowNumbers ? 1 : 0) + visibleColsCount"
4287
+ class="q-spreadsheet__no-rows"
4288
+ >
4289
+ <template v-if="hasActiveFilters">{{ t('noRowsFilter') }}</template>
4290
+ <template v-else>{{ t('noRowsYet') }}</template>
4291
+ </td>
4292
+ </tr>
4293
+ </tbody>
4294
+ </table>
4295
+
4296
+ <!-- ═══════ Poignée d'autofill ═══════ -->
4297
+ <span
4298
+ v-if="showFill"
4299
+ class="q-spreadsheet__fill"
4300
+ :style="fillHandleStyle"
4301
+ @pointerdown.stop.prevent="startFill($event)"
4302
+ @dblclick.stop.prevent="fillHandleDbl"
4303
+ />
4304
+
4305
+ <!-- ═══════ Éditeur overlay ═══════ -->
4306
+ <div
4307
+ v-if="editing && editingCol"
4308
+ class="q-spreadsheet__editor"
4309
+ :class="{
4310
+ 'q-spreadsheet__editor--select': editingCol.type === 'select',
4311
+ 'q-spreadsheet__editor--formula': editingIsFormula,
4312
+ }"
4313
+ :style="editorStyle"
4314
+ @mousedown.stop
4315
+ >
4316
+ <textarea
4317
+ v-if="editingIsFormula"
4318
+ ref="editAreaEl"
4319
+ v-model="draft"
4320
+ class="q-spreadsheet__editor-input q-spreadsheet__editor-area"
4321
+ :rows="draftLines"
4322
+ spellcheck="false"
4323
+ @keydown="onEditKeydown"
4324
+ @blur="commitEdit"
4325
+ />
4326
+ <input
4327
+ v-else
4328
+ ref="editInputEl"
4329
+ v-model="draft"
4330
+ class="q-spreadsheet__editor-input"
4331
+ :type="editorInputType"
4332
+ :step="editorStep"
4333
+ :placeholder="editingCol.type === 'select' ? 'Type to filter…' : ''"
4334
+ @keydown="onEditKeydown"
4335
+ @blur="commitEdit"
4336
+ />
4337
+ <div v-if="editingCol.type === 'select' && selectOptions.length" class="q-spreadsheet__options">
4338
+ <button
4339
+ v-for="opt in selectOptions"
4340
+ :key="String(opt.value)"
4341
+ type="button"
4342
+ class="q-spreadsheet__option"
4343
+ :class="{ 'q-spreadsheet__option--active': optionIsActive(opt) }"
4344
+ @mousedown.prevent
4345
+ @click="pickOption(opt)"
4346
+ >
4347
+ <span class="q-spreadsheet__option-swatch" :style="badgeStyle(opt)" />
4348
+ {{ opt.label }}
4349
+ </button>
4350
+ </div>
4351
+ <div v-if="editingIsFormula && fxOpen" class="q-spreadsheet__fnsug">
4352
+ <button
4353
+ v-for="(f, fi) in fxSuggestions"
4354
+ :key="f.name"
4355
+ type="button"
4356
+ class="q-spreadsheet__fnsug-item"
4357
+ :class="{ 'q-spreadsheet__fnsug-item--on': fxActive === fi }"
4358
+ @mousedown.prevent
4359
+ @click="acceptFxAt(fi)"
4360
+ >
4361
+ <code>{{ f.name }}</code>
4362
+ <span>({{ f.sig }})</span>
4363
+ </button>
4364
+ </div>
4365
+ </div>
4366
+ </div>
4367
+
4368
+ <!-- ═══════ Onglets de feuilles (classeur, bas — Excel) ═══════ -->
4369
+ <div
4370
+ v-if="sheetsMode && sheetsPosition === 'bottom'"
4371
+ class="q-spreadsheet__sheets q-spreadsheet__sheets--bottom"
4372
+ role="tablist"
4373
+ aria-label="Sheets"
4374
+ >
4375
+ <div
4376
+ v-for="(s, i) in localSheets"
4377
+ :key="s.key"
4378
+ class="q-spreadsheet__sheet-tab"
4379
+ :class="{ 'q-spreadsheet__sheet-tab--on': i === sheetIdx }"
4380
+ role="tab"
4381
+ :aria-selected="i === sheetIdx ? 'true' : 'false'"
4382
+ @click="goSheet(i)"
4383
+ @dblclick.stop="startSheetRename(i)"
4384
+ >
4385
+ <input
4386
+ v-if="sheetRename && sheetRename.idx === i"
4387
+ v-model="sheetRename.val"
4388
+ class="q-spreadsheet__sheet-rename"
4389
+ @click.stop
4390
+ @blur="commitSheetRename"
4391
+ @keydown.enter.prevent="commitSheetRename"
4392
+ @keydown.esc.prevent="sheetRename = null"
4393
+ />
4394
+ <template v-else>
4395
+ <span class="q-spreadsheet__sheet-name-label">{{ s.name }}</span>
4396
+ <button
4397
+ v-if="localSheets.length > 1"
4398
+ type="button"
4399
+ class="q-spreadsheet__sheet-close"
4400
+ aria-label="Remove sheet"
4401
+ @click.stop="removeSheet(i)"
4402
+ >
4403
+ <Icon :icon="icons.x" aria-hidden="true" />
4404
+ </button>
4405
+ </template>
4406
+ </div>
4407
+ <button
4408
+ type="button"
4409
+ class="q-spreadsheet__sheet-add"
4410
+ title="Add sheet"
4411
+ aria-label="Add sheet"
4412
+ @click="addSheet"
4413
+ >
4414
+ <Icon :icon="icons.plus" aria-hidden="true" />
4415
+ </button>
4416
+ </div>
4417
+
4418
+ <!-- ═══════ Barre d'état ═══════ -->
4419
+ <div class="q-spreadsheet__status">
4420
+ <span class="q-spreadsheet__status-cell" :title="selLabel || 'Sheet'">
4421
+ <Icon :icon="icons.fileSpreadsheet" aria-hidden="true" />
4422
+ {{ selLabel || t('sheet') }}
4423
+ </span>
4424
+ <span v-if="statusDims" class="q-spreadsheet__status-item">{{ statusDims }}</span>
4425
+ <span v-if="selectionStats.count" class="q-spreadsheet__status-item q-spreadsheet__status-stats">
4426
+ Σ {{ statText(selectionStats.sum) }} · x̄ {{ statText(selectionStats.avg) }} · n {{ selectionStats.count }}
4427
+ </span>
4428
+ <span v-if="hasActiveFilters || hiddenRows.length" class="q-spreadsheet__status-chip">
4429
+ {{ statusRowInfo }}
4430
+ </span>
4431
+ <span class="q-spreadsheet__status-spacer" />
4432
+ <span v-if="multiMode" class="q-spreadsheet__status-item">{{ statusSheets }}</span>
4433
+ <span class="q-spreadsheet__status-zoom">
4434
+ <button class="q-spreadsheet__tool" type="button" title="Zoom out" aria-label="Zoom out" @click="zoomOut">
4435
+ <Icon :icon="icons.minus" aria-hidden="true" />
4436
+ </button>
4437
+ <button class="q-spreadsheet__status-zoom-pct" type="button" title="Reset zoom" @click="resetZoom">
4438
+ {{ zoomPct }}
4439
+ </button>
4440
+ <button class="q-spreadsheet__tool" type="button" title="Zoom in" aria-label="Zoom in" @click="zoomIn">
4441
+ <Icon :icon="icons.plus" aria-hidden="true" />
4442
+ </button>
4443
+ </span>
4444
+ </div>
4445
+
4446
+ <!-- ═══════ Menu contextuel (clic droit) ═══════ -->
4447
+ <Teleport to="body">
4448
+ <div
4449
+ v-if="ctxMenu"
4450
+ ref="ctxRoot"
4451
+ class="q-spreadsheet__ctx"
4452
+ style="position: fixed; left: 0; top: 0"
4453
+ @contextmenu.prevent
4454
+ >
4455
+ <div class="q-spreadsheet__ctx-title">
4456
+ <span v-if="ctxMenu.kind === 'row' && sel">{{ t('rowLbl') }} {{ sel.row + 1 }}</span>
4457
+ <span v-else-if="ctxMenu.kind === 'col' && sel">{{ t('colLbl') }} {{ colLetter(colIndex(sel.column)) }}</span>
4458
+ <span v-else>{{ selLabel || t('cellLbl') }}</span>
4459
+ </div>
4460
+
4461
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="doCut">
4462
+ <Icon :icon="icons.scissors" aria-hidden="true" /> {{ t('cut') }}
4463
+ </button>
4464
+ <button type="button" class="q-spreadsheet__mi" :disabled="!sel" @click="doCopy">
4465
+ <Icon :icon="icons.copy" aria-hidden="true" /> {{ t('copy') }}
4466
+ </button>
4467
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="doPaste">
4468
+ <Icon :icon="icons.clipboardPaste" aria-hidden="true" /> {{ t('paste') }}
4469
+ </button>
4470
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !sel" @click="doClearCells">
4471
+ <Icon :icon="icons.eraser" aria-hidden="true" /> {{ t('clearContents') }}
4472
+ </button>
4473
+
4474
+ <div class="q-spreadsheet__sep" />
4475
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('above')">
4476
+ <Icon :icon="icons.arrowUp" aria-hidden="true" /> {{ t('insertRowAbove') }}
4477
+ </button>
4478
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('below')">
4479
+ <Icon :icon="icons.arrowDown" aria-hidden="true" /> {{ t('insertRowBelow') }}
4480
+ </button>
4481
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect" @click="removeSelectedRows">
4482
+ <Icon :icon="icons.trash2" aria-hidden="true" /> {{ t('deleteRows') }}
4483
+ </button>
4484
+ <div class="q-spreadsheet__sep" />
4485
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertColumnAt('left')">
4486
+ <Icon :icon="icons.chevronLeft" aria-hidden="true" /> {{ t('insertColLeft') }}
4487
+ </button>
4488
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertColumnAt('right')">
4489
+ <Icon :icon="icons.chevronRight" aria-hidden="true" /> {{ t('insertColRight') }}
4490
+ </button>
4491
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect" @click="removeSelectedColumns">
4492
+ <Icon :icon="icons.x" aria-hidden="true" /> {{ t('deleteCols') }}
4493
+ </button>
4494
+ <div class="q-spreadsheet__sep" />
4495
+ <button type="button" class="q-spreadsheet__mi" :disabled="!sel" @click="doSortBy(false)">
4496
+ <Icon :icon="icons.sortAsc" aria-hidden="true" /> {{ t('sortAsc') }}
4497
+ </button>
4498
+ <button type="button" class="q-spreadsheet__mi" :disabled="!sel" @click="doSortBy(true)">
4499
+ <Icon :icon="icons.sortDesc" aria-hidden="true" /> {{ t('sortDesc') }}
4500
+ </button>
4501
+ <div class="q-spreadsheet__sep" />
4502
+ <button type="button" class="q-spreadsheet__mi" :class="{ 'q-spreadsheet__mi--on': isBoldSel }" :disabled="!canEdit" @click="toggleBoldSelection">
4503
+ <Icon :icon="icons.bold" aria-hidden="true" /> {{ t('bold') }}
4504
+ </button>
4505
+ <button type="button" class="q-spreadsheet__mi" :class="{ 'q-spreadsheet__mi--on': isItalicSel }" :disabled="!canEdit" @click="toggleItalicSelection">
4506
+ <Icon :icon="icons.italic" aria-hidden="true" /> {{ t('italic') }}
4507
+ </button>
4508
+
4509
+ <div class="q-spreadsheet__ctx-label">{{ t('fillColor') }}</div>
4510
+ <div class="q-spreadsheet__swatches">
4511
+ <button
4512
+ v-for="c in FORMAT_COLORS"
4513
+ :key="'bg' + c"
4514
+ type="button"
4515
+ class="q-spreadsheet__swatch"
4516
+ :class="{ 'q-spreadsheet__swatch--on': currentBg === c }"
4517
+ :style="swatchInline(c)"
4518
+ :title="c || 'No fill'"
4519
+ :disabled="!canEdit"
4520
+ @click="setBgColorSelection(c)"
4521
+ />
4522
+ </div>
4523
+ <div class="q-spreadsheet__ctx-label">{{ t('textColor') }}</div>
4524
+ <div class="q-spreadsheet__swatches">
4525
+ <button
4526
+ v-for="c in TEXT_COLORS"
4527
+ :key="'fg' + c"
4528
+ type="button"
4529
+ class="q-spreadsheet__swatch"
4530
+ :class="{ 'q-spreadsheet__swatch--on': currentColor === c }"
4531
+ :style="swatchInline(c)"
4532
+ :title="c || 'Automatic'"
4533
+ :disabled="!canEdit"
4534
+ @click="setTextColorSelection(c)"
4535
+ />
4536
+ </div>
4537
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !sel" @click="clearFormatSelection">
4538
+ <Icon :icon="icons.eraser" aria-hidden="true" /> {{ t('clearFormat') }}
4539
+ </button>
4540
+ <div class="q-spreadsheet__sep" />
4541
+ <button type="button" class="q-spreadsheet__mi" :class="{ 'q-spreadsheet__mi--on': anchorWrap }" :disabled="!canEdit || !sel" @click="toggleWrapSelection">
4542
+ <Icon :icon="icons.alignLeft" aria-hidden="true" /> {{ t('wrapText') }}
4543
+ </button>
4544
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !canMergeSel" @click="mergeCells">
4545
+ <Icon :icon="icons.tableCellsMerge" aria-hidden="true" /> {{ t('mergeCells') }}
4546
+ </button>
4547
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="unmergeCells">
4548
+ <Icon :icon="icons.x" aria-hidden="true" /> {{ t('unmerge') }}
4549
+ </button>
4550
+ <div class="q-spreadsheet__sep" />
4551
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !sel" @click="hideSelectedRows">
4552
+ <Icon :icon="icons.eyeOff" aria-hidden="true" /> {{ t('hideRows') }}
4553
+ </button>
4554
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !sel" @click="hideColumnName(activeColName)">
4555
+ <Icon :icon="icons.eyeOff" aria-hidden="true" /> {{ t('hideCol') }}
4556
+ </button>
4557
+ <button v-if="hasHiddenRows || hasHiddenCols" type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="showAllHidden">
4558
+ <Icon :icon="icons.eye" aria-hidden="true" /> {{ t('showHidden') }}
4559
+ </button>
4560
+ <template v-for="entry in hiddenColEntries" :key="entry.name">
4561
+ <button type="button" class="q-spreadsheet__mi" @click="showColumnName(entry.name)">
4562
+ <Icon :icon="icons.eye" aria-hidden="true" /> {{ fmt('showCol', { name: entry.name }) }}
4563
+ </button>
4564
+ </template>
4565
+ <div class="q-spreadsheet__sep" />
4566
+ <button type="button" class="q-spreadsheet__mi" :disabled="!sel" @click="openCfFromMenu">
4567
+ <Icon :icon="icons.highlighter" aria-hidden="true" /> {{ t('condFormat') }}
4568
+ </button>
4569
+ </div>
4570
+ </Teleport>
4571
+
4572
+ <!-- ═══════ Popup filtre de colonne ═══════ -->
4573
+ <Teleport to="body">
4574
+ <div
4575
+ v-if="filterMenu && filterCol"
4576
+ ref="filterRoot"
4577
+ class="q-spreadsheet__fpop"
4578
+ style="position: fixed; left: 0; top: 0"
4579
+ @click.stop
4580
+ @contextmenu.prevent
4581
+ >
4582
+ <div class="q-spreadsheet__fpop-head">
4583
+ <Icon :icon="icons.filter" aria-hidden="true" />
4584
+ <span class="q-spreadsheet__fpop-name">{{ filterCol.label || filterCol.name }}</span>
4585
+ <button type="button" class="q-spreadsheet__fpop-close" aria-label="Close" @click="closeFilter">
4586
+ <Icon :icon="icons.x" aria-hidden="true" />
4587
+ </button>
4588
+ </div>
4589
+ <input
4590
+ v-model="filterSearch"
4591
+ class="q-spreadsheet__fpop-search"
4592
+ :placeholder="t('searchValues')"
4593
+ spellcheck="false"
4594
+ />
4595
+ <div class="q-spreadsheet__fpop-list">
4596
+ <button
4597
+ type="button"
4598
+ class="q-spreadsheet__fpop-row q-spreadsheet__fpop-row--all"
4599
+ :class="{ 'q-spreadsheet__fpop-row--on': filterIsAll }"
4600
+ @click="filterSelectAll"
4601
+ >
4602
+ <span class="q-spreadsheet__fpop-check">
4603
+ <Icon v-if="filterIsAll" :icon="icons.check" aria-hidden="true" />
4604
+ </span>
4605
+ <span class="q-spreadsheet__fpop-label">{{ t('selectAll') }}</span>
4606
+ </button>
4607
+ <button
4608
+ type="button"
4609
+ class="q-spreadsheet__fpop-row"
4610
+ :class="{ 'q-spreadsheet__fpop-row--on': !item.active }"
4611
+ v-for="item in filterItemsFor"
4612
+ :key="item.key"
4613
+ @click="filterToggleKey(item.key)"
4614
+ >
4615
+ <span class="q-spreadsheet__fpop-check">
4616
+ <Icon v-if="item.active" :icon="icons.check" aria-hidden="true" />
4617
+ </span>
4618
+ <span class="q-spreadsheet__fpop-label" :class="{ 'q-spreadsheet__fpop-label--blank': item.blank }">
4619
+ {{ item.label }}
4620
+ </span>
4621
+ <span class="q-spreadsheet__fpop-count">{{ item.count }}</span>
4622
+ </button>
4623
+ <div v-if="filterItemsFor.length === 0" class="q-spreadsheet__fpop-empty">
4624
+ {{ t('noValues') }}
4625
+ </div>
4626
+ </div>
4627
+ <button
4628
+ v-if="!filterIsAll"
4629
+ type="button"
4630
+ class="q-spreadsheet__mi"
4631
+ @click="filterClear"
4632
+ >
4633
+ <Icon :icon="icons.eraser" aria-hidden="true" /> {{ t('clearFilter') }}
4634
+ </button>
4635
+ </div>
4636
+ </Teleport>
4637
+
4638
+ <!-- ═══════ Suggestions de fonctions (barre fx) ═══════ -->
4639
+ <Teleport to="body">
4640
+ <div
4641
+ v-if="fxOpen && !editing"
4642
+ class="q-spreadsheet__fpop q-spreadsheet__fnsug q-spreadsheet__fnsug--fx"
4643
+ :style="fxPopStyle"
4644
+ @click.stop
4645
+ @contextmenu.prevent
4646
+ >
4647
+ <button
4648
+ v-for="(f, fi) in fxSuggestions"
4649
+ :key="f.name"
4650
+ type="button"
4651
+ class="q-spreadsheet__fnsug-item"
4652
+ :class="{ 'q-spreadsheet__fnsug-item--on': fxActive === fi }"
4653
+ @mousedown.prevent
4654
+ @click="acceptFxAt(fi)"
4655
+ >
4656
+ <code>{{ f.name }}</code>
4657
+ <span>({{ f.sig }})</span>
4658
+ </button>
4659
+ </div>
4660
+ </Teleport>
4661
+ </div>
4662
+ </template>