@dnax/ui 0.0.27 → 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.
- package/components/QBar.vue +42 -0
- package/components/QBoard.vue +285 -0
- package/components/QConfigProvider.vue +9 -5
- package/components/QDialog.vue +26 -12
- package/components/QEditorJs.vue +146 -0
- package/components/QFab.vue +19 -2
- package/components/QInteract.vue +387 -0
- package/components/QSpreadsheet.vue +4662 -0
- package/components/QSyntax.vue +22 -9
- package/components/QTiptap.vue +993 -0
- package/components/internal/QTiptapTableView.vue +123 -0
- package/components/internal/QTiptapTaskItemView.vue +49 -0
- package/index.ts +18 -0
- package/lib/config.ts +6 -0
- package/lib/formula.test.ts +206 -0
- package/lib/formula.ts +292 -0
- package/lib/icons.ts +43 -0
- package/lib/intersection.ts +118 -0
- package/lib/q.ts +5 -1
- package/lib/spreadsheet.test.ts +92 -0
- package/lib/spreadsheet.ts +175 -0
- package/lib/storage.ts +175 -0
- package/lib/tiptap-table.ts +19 -0
- package/lib/tiptap-task-list.ts +16 -0
- package/lib/touchHold.ts +221 -0
- package/lib/touchPan.ts +237 -0
- package/lib/touchRepeat.ts +148 -0
- package/lib/touchSwipe.ts +182 -0
- package/lib/useComponentProps.ts +7 -0
- package/module.ts +10 -0
- package/package.json +27 -1
- package/scripts/generate-exports.ts +12 -0
- package/styles/main.css +2217 -32
|
@@ -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>
|