@dnax/ui 0.0.75 → 0.0.77
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/QFilePicker.vue +22 -65
- package/components/QSpreadsheet.vue +624 -73
- package/index.ts +14 -0
- package/lib/cellSize.test.ts +90 -0
- package/lib/cellSize.ts +79 -0
- package/lib/fileList.test.ts +119 -0
- package/lib/fileList.ts +159 -0
- package/lib/pagination.test.ts +77 -0
- package/lib/pagination.ts +50 -0
- package/lib/spreadsheetDisplay.test.ts +49 -0
- package/lib/spreadsheetDisplay.ts +42 -0
- package/package.json +1 -1
- package/styles/main.css +188 -10
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// édition en place (Entrée / F2 / double-clic / frappe directe),
|
|
5
5
|
// lignes & colonnes (ajouter / insérer / supprimer),
|
|
6
6
|
// tri, copier/coller, undo/redo, et cellules typées
|
|
7
|
-
// texte, nombre, entier, booléen, date, datetime, **time (heure)**,
|
|
8
|
-
// badges colorés).
|
|
7
|
+
// texte, nombre, entier, booléen, date, datetime, **time (heure)**, **attachment (fichiers
|
|
8
|
+
// joints)**, select / multiselect avec badges colorés).
|
|
9
|
+
// Slots dynamiques : `#cell-<nom>` / `#cell` (contenu de cellule) et `#head-<nom>`
|
|
10
|
+
// (en-tête de colonne) — pour rendre n'importe quel composant dans la grille.
|
|
9
11
|
// Suivi des modifications : `v-model:dirty` (état) + `v-model:changes` (delta lignes / feuilles
|
|
10
12
|
// ajoutées, modifiées, supprimées), calculé en comparant le document à une **référence**
|
|
11
13
|
// (`lib/spreadsheetChanges.ts`).
|
|
@@ -48,6 +50,25 @@ export type QSpreadsheetCellType =
|
|
|
48
50
|
| "time"
|
|
49
51
|
| "select"
|
|
50
52
|
| "multiselect"
|
|
53
|
+
| "attachment"
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fichier joint stocké dans une cellule `attachment`. **Sans** l'objet `File` (qui n'est
|
|
57
|
+
* pas sérialisable) : le fichier binaire est livré à l'app par l'événement `files`, et
|
|
58
|
+
* `url` permet l'aperçu/le téléchargement (object URL local ou URL d'upload).
|
|
59
|
+
*/
|
|
60
|
+
export interface QSpreadsheetAttachment {
|
|
61
|
+
/** Identifiant local stable (clé de rendu, cible de suppression) */
|
|
62
|
+
id: string
|
|
63
|
+
/** Nom du fichier */
|
|
64
|
+
name: string
|
|
65
|
+
/** Taille en octets */
|
|
66
|
+
size: number
|
|
67
|
+
/** Type MIME (`image/png`, `application/pdf`…) */
|
|
68
|
+
type: string
|
|
69
|
+
/** URL d'aperçu/téléchargement (object URL avant upload, URL serveur ensuite) */
|
|
70
|
+
url?: string
|
|
71
|
+
}
|
|
51
72
|
|
|
52
73
|
// `QSpreadsheetCellOption` (option **normalisée**) et `QSpreadsheetOptionAccessor` vivent dans
|
|
53
74
|
// `lib/spreadsheetOptions.ts` — source unique, testée hors navigateur — et sont ré-exportés en
|
|
@@ -86,6 +107,17 @@ export interface QSpreadsheetColumn {
|
|
|
86
107
|
optionValue?: QSpreadsheetOptionAccessor
|
|
87
108
|
/** Pour type "select" / "multiselect" : rend les options actives en badge coloré (sinon texte) */
|
|
88
109
|
chip?: boolean
|
|
110
|
+
/**
|
|
111
|
+
* Pour type `attachment` : types acceptés — mêmes règles qu'un `<input type="file">`
|
|
112
|
+
* (`"image/*"`, `"application/pdf"`, `".pdf,.docx"`, liste séparée par des virgules).
|
|
113
|
+
*/
|
|
114
|
+
accept?: string
|
|
115
|
+
/** Pour type `attachment` : autorise plusieurs fichiers dans une même cellule */
|
|
116
|
+
multiple?: boolean
|
|
117
|
+
/** Pour type `attachment` : nombre max de fichiers par cellule (0 = illimité) */
|
|
118
|
+
maxFiles?: number
|
|
119
|
+
/** Pour type `attachment` : taille max par fichier, en octets (0 = illimité) */
|
|
120
|
+
maxFileSize?: number
|
|
89
121
|
/** Validation à la saisie : objet `{ min, max, … }` ou expression ArkType (`"number < 4"`) */
|
|
90
122
|
validation?: QSpreadsheetValidationRule
|
|
91
123
|
/** Formatteur de la valeur affichée */
|
|
@@ -174,6 +206,7 @@ export type {
|
|
|
174
206
|
<script setup lang="ts">
|
|
175
207
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
176
208
|
import { Icon } from "@iconify/vue"
|
|
209
|
+
import QInnerLoading from "./QInnerLoading.vue"
|
|
177
210
|
import { cn } from "../lib/utils"
|
|
178
211
|
import { icons } from "../lib/icons"
|
|
179
212
|
import { colorValue, foregroundFor } from "../lib/colors"
|
|
@@ -200,6 +233,12 @@ import {
|
|
|
200
233
|
parseCsv,
|
|
201
234
|
} from "../lib/spreadsheet"
|
|
202
235
|
import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
|
|
236
|
+
import { pageInfo } from "../lib/pagination"
|
|
237
|
+
import { fxTextFor, searchTextFor } from "../lib/spreadsheetDisplay"
|
|
238
|
+
import { cellFontFor, cellSizePx, cellWidthFor, headerMetricsFor } from "../lib/cellSize"
|
|
239
|
+
import type { CellSizePreset } from "../lib/cellSize"
|
|
240
|
+
import { attachmentsOf as fileAttachmentsOf, fileIconKind, formatFileSize, isAttachment, selectFiles } from "../lib/fileList"
|
|
241
|
+
import type { FileIconKind } from "../lib/fileList"
|
|
203
242
|
import { normalizeCellOptions } from "../lib/spreadsheetOptions"
|
|
204
243
|
import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
|
|
205
244
|
import { canAddRows, canRemoveRows, clampRows } from "../lib/spreadsheetRows"
|
|
@@ -213,6 +252,25 @@ import {
|
|
|
213
252
|
interface Props {
|
|
214
253
|
/** Lignes (v-model:rows) — un objet par ligne, clés = noms de colonnes */
|
|
215
254
|
rows?: Record<string, any>[]
|
|
255
|
+
/**
|
|
256
|
+
* Pagination **distante** : lignes par page (0 = désactivée). `rows` doit contenir
|
|
257
|
+
* **uniquement la page courante** — l'app recharge sur `page-change`.
|
|
258
|
+
*/
|
|
259
|
+
pageSize?: number
|
|
260
|
+
/** Page courante (1-based) — `v-model:page`. Sans binding, l'état est interne au composant */
|
|
261
|
+
page?: number
|
|
262
|
+
/**
|
|
263
|
+
* Total de lignes, toutes pages confondues. Active le compteur `page / N` et la plage
|
|
264
|
+
* « lignes 401–800 sur 12 000 ». À défaut : le nombre de lignes fournies.
|
|
265
|
+
*/
|
|
266
|
+
totalRows?: number
|
|
267
|
+
/**
|
|
268
|
+
* Chargement **distant** en cours (pagination, rechargement…) : un voile recouvre la
|
|
269
|
+
* grille — la barre d'état et le pager restent visibles, flèches désactivées.
|
|
270
|
+
*/
|
|
271
|
+
loading?: boolean
|
|
272
|
+
/** Libellé du chargement (défaut : i18n « Loading… ») */
|
|
273
|
+
loadingLabel?: string
|
|
216
274
|
/** Colonnes ({ name, label, width, type, editable, options, chip, format, align, … }) */
|
|
217
275
|
columns?: QSpreadsheetColumn[]
|
|
218
276
|
/** Hauteur du conteneur scrollable (nombre px ou chaîne CSS, défaut "360px") */
|
|
@@ -221,6 +279,13 @@ interface Props {
|
|
|
221
279
|
rowHeight?: number
|
|
222
280
|
/** Largeur de colonne par défaut en px (défaut 160) */
|
|
223
281
|
defaultColWidth?: number
|
|
282
|
+
/**
|
|
283
|
+
* Taille des cellules — preset `sm` (28px) · `md` (32px, défaut) · `lg` (40px), ou une
|
|
284
|
+
* hauteur en px (`:size-cell="48"`, `66`…). Pilote la hauteur de ligne, la largeur de
|
|
285
|
+
* colonne par défaut (ratio 5:1) et la police des cellules. `rowHeight` /
|
|
286
|
+
* `defaultColWidth` restent prioritaires s'ils sont fournis.
|
|
287
|
+
*/
|
|
288
|
+
sizeCell?: CellSizePreset | number
|
|
224
289
|
/** Affiche la barre d'outils */
|
|
225
290
|
showToolbar?: boolean
|
|
226
291
|
/** Affiche la barre de formule (fx) au-dessus de la grille */
|
|
@@ -325,11 +390,15 @@ interface Props {
|
|
|
325
390
|
}
|
|
326
391
|
|
|
327
392
|
const props = withDefaults(defineProps<Props>(), {
|
|
393
|
+
pageSize: 0,
|
|
394
|
+
totalRows: 0,
|
|
395
|
+
loading: false,
|
|
328
396
|
rows: () => [],
|
|
329
397
|
columns: () => [],
|
|
330
398
|
height: "360px",
|
|
331
|
-
rowHeight:
|
|
332
|
-
defaultColWidth:
|
|
399
|
+
rowHeight: undefined,
|
|
400
|
+
defaultColWidth: undefined,
|
|
401
|
+
sizeCell: undefined,
|
|
333
402
|
showToolbar: true,
|
|
334
403
|
showFormulaBar: true,
|
|
335
404
|
frozenRows: 0,
|
|
@@ -435,6 +504,11 @@ const I18N = {
|
|
|
435
504
|
sheetsDeleted: "{n} sheet(s) deleted",
|
|
436
505
|
formatted: "formatting",
|
|
437
506
|
empty: "…",
|
|
507
|
+
pagePrev: "Previous page",
|
|
508
|
+
pageNext: "Next page",
|
|
509
|
+
pageRange: "Rows {from}–{to} of {total}",
|
|
510
|
+
pageLabel: "{page} / {count}",
|
|
511
|
+
loading: "Loading…",
|
|
438
512
|
},
|
|
439
513
|
fr: {
|
|
440
514
|
sheet: "Feuille",
|
|
@@ -516,6 +590,11 @@ const I18N = {
|
|
|
516
590
|
sheetsDeleted: "{n} feuille(s) supprimée(s)",
|
|
517
591
|
formatted: "mise en forme",
|
|
518
592
|
empty: "…",
|
|
593
|
+
pagePrev: "Page précédente",
|
|
594
|
+
pageNext: "Page suivante",
|
|
595
|
+
pageRange: "Lignes {from}–{to} sur {total}",
|
|
596
|
+
pageLabel: "{page} / {count}",
|
|
597
|
+
loading: "Chargement…",
|
|
519
598
|
},
|
|
520
599
|
} as const
|
|
521
600
|
type UiKey = keyof (typeof I18N)["en"]
|
|
@@ -535,13 +614,80 @@ const emit = defineEmits<{
|
|
|
535
614
|
"cell-change": [payload: { row: number; column: string; oldValue: any; newValue: any; rows: Record<string, any>[] }]
|
|
536
615
|
"cell-edit-start": [payload: { row: number; column: string }]
|
|
537
616
|
"cell-edit-end": [payload: { row: number; column: string; canceled: boolean }]
|
|
617
|
+
/**
|
|
618
|
+
* Fichiers choisis dans une cellule `attachment`. `files` = fichiers **ajoutés**
|
|
619
|
+
* (à uploader), `attachments` = état complet de la cellule, `rejected` = refusés
|
|
620
|
+
* (type / taille / quota). Les `QSpreadsheetAttachment` ne portent **pas** le `File`.
|
|
621
|
+
*/
|
|
622
|
+
"files": [payload: {
|
|
623
|
+
row: number
|
|
624
|
+
column: string
|
|
625
|
+
rowIndex: number
|
|
626
|
+
files: File[]
|
|
627
|
+
attachments: QSpreadsheetAttachment[]
|
|
628
|
+
rejected: { file: File; reason: "type" | "size" | "count" }[]
|
|
629
|
+
}]
|
|
630
|
+
/**
|
|
631
|
+
* Clic sur un fichier joint : à l'app d'ouvrir son aperçu (modal, visionneuse PDF…).
|
|
632
|
+
* Rien n'est ouvert par le composant — on fournit la cellule **et** la ligne complète.
|
|
633
|
+
*/
|
|
634
|
+
"attachment-click": [payload: {
|
|
635
|
+
/** Index de la ligne */
|
|
636
|
+
row: number
|
|
637
|
+
/** Nom de la colonne */
|
|
638
|
+
column: string
|
|
639
|
+
/** Fichier cliqué */
|
|
640
|
+
attachment: QSpreadsheetAttachment
|
|
641
|
+
/** Tous les fichiers de la cellule */
|
|
642
|
+
attachments: QSpreadsheetAttachment[]
|
|
643
|
+
/** Valeur brute de la cellule */
|
|
644
|
+
value: any
|
|
645
|
+
/** Toute la ligne (toutes les colonnes) */
|
|
646
|
+
rowData: Record<string, any>
|
|
647
|
+
event: MouseEvent
|
|
648
|
+
}]
|
|
538
649
|
"selection-change": [value: QSpreadsheetSelection | null]
|
|
539
650
|
"structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
|
|
540
651
|
"prefill": [payload: { count: number; mode: "auto" | "manual" }]
|
|
541
652
|
"update:dirty": [value: boolean]
|
|
542
653
|
"update:changes": [value: QSpreadsheetChanges]
|
|
654
|
+
"update:page": [value: number]
|
|
655
|
+
/**
|
|
656
|
+
* Changement de page — c'est ici que l'app **charge** les données de la page demandée.
|
|
657
|
+
* `offset` = index global de la première ligne de la nouvelle page (0-based).
|
|
658
|
+
*/
|
|
659
|
+
"page-change": [payload: {
|
|
660
|
+
page: number
|
|
661
|
+
previousPage: number
|
|
662
|
+
pageSize: number
|
|
663
|
+
offset: number
|
|
664
|
+
totalRows: number
|
|
665
|
+
pageCount: number
|
|
666
|
+
}]
|
|
543
667
|
}>()
|
|
544
668
|
|
|
669
|
+
// ─── Taille des cellules (`sizeCell`) ───
|
|
670
|
+
// Une seule échelle pilote tout : hauteur de ligne → largeur de colonne par défaut
|
|
671
|
+
// (ratio 5:1) → police des cellules. `rowHeight` / `defaultColWidth` restent prioritaires
|
|
672
|
+
// s'ils sont fournis (API historique).
|
|
673
|
+
const cellHeightPx = computed(() => props.rowHeight ?? cellSizePx(props.sizeCell))
|
|
674
|
+
const cellWidthPx = computed(() => props.defaultColWidth ?? cellWidthFor(cellHeightPx.value))
|
|
675
|
+
const cellFontPx = computed(() => cellFontFor(cellHeightPx.value))
|
|
676
|
+
/** En-têtes accordés aux cellules (hauteur, gouttière, polices) */
|
|
677
|
+
const headerMetrics = computed(() => headerMetricsFor(cellHeightPx.value))
|
|
678
|
+
|
|
679
|
+
/** Variables CSS de la grille (police des cellules + hauteur de repli) */
|
|
680
|
+
const cellSizeStyle = computed(() => ({
|
|
681
|
+
"--q-spreadsheet-font": `${cellFontPx.value}px`,
|
|
682
|
+
"--q-spreadsheet-row-height": `${cellHeightPx.value}px`,
|
|
683
|
+
// En-têtes : mêmes proportions que les cellules
|
|
684
|
+
"--q-spreadsheet-head-height": `${headerMetrics.value.height}px`,
|
|
685
|
+
"--q-spreadsheet-head-font": `${headerMetrics.value.labelFont}px`,
|
|
686
|
+
"--q-spreadsheet-head-letter-font": `${headerMetrics.value.letterFont}px`,
|
|
687
|
+
"--q-spreadsheet-row-font": `${headerMetrics.value.rowFont}px`,
|
|
688
|
+
"--q-spreadsheet-gutter-width": `${headerMetrics.value.gutter}px`,
|
|
689
|
+
}))
|
|
690
|
+
|
|
545
691
|
// ─── Clés de ligne (`_key`) ───
|
|
546
692
|
/**
|
|
547
693
|
* Propriété injectée automatiquement sur chaque ligne : une clé stable (uuid) qui
|
|
@@ -605,6 +751,81 @@ const pushRows = (next: Record<string, any>[]) => {
|
|
|
605
751
|
emit("update:rows", next)
|
|
606
752
|
}
|
|
607
753
|
|
|
754
|
+
// ─── Pagination (distante) ───
|
|
755
|
+
// `rows` contient **la page courante** : le tableau ne découpe rien lui-même (les index
|
|
756
|
+
// internes — sélection, édition, historique — restent locaux à la page). La numérotation
|
|
757
|
+
// des lignes et le pager sont décalés de `rowOffset`.
|
|
758
|
+
const pageSizeN = computed(() => Math.max(0, Math.trunc(props.pageSize || 0)))
|
|
759
|
+
const paginationOn = computed(() => pageSizeN.value > 0)
|
|
760
|
+
|
|
761
|
+
/** Total de lignes, toutes pages : `totalRows` (distant) sinon les lignes fournies */
|
|
762
|
+
const totalRowsN = computed(() =>
|
|
763
|
+
props.totalRows && props.totalRows > 0 ? props.totalRows : state.value.length,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
/** Page interne : repli quand `page` n'est pas lié (`v-model:page` absent) */
|
|
767
|
+
const localPage = ref(1)
|
|
768
|
+
|
|
769
|
+
const pageState = computed(() =>
|
|
770
|
+
pageInfo({
|
|
771
|
+
page: props.page ?? localPage.value,
|
|
772
|
+
pageSize: pageSizeN.value,
|
|
773
|
+
total: totalRowsN.value,
|
|
774
|
+
loaded: state.value.length,
|
|
775
|
+
}),
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
/** Page affichée (bornée au nombre de pages) */
|
|
779
|
+
const currentPage = computed(() => pageState.value.page)
|
|
780
|
+
const pageCount = computed(() => pageState.value.pageCount)
|
|
781
|
+
/** Décalage global de la première ligne affichée — sert à la numérotation */
|
|
782
|
+
const rowOffset = computed(() => pageState.value.offset)
|
|
783
|
+
|
|
784
|
+
const numberFormat = computed(() => new Intl.NumberFormat(lang.value))
|
|
785
|
+
const pageRangeLabel = computed(() => {
|
|
786
|
+
if (!paginationOn.value) return ""
|
|
787
|
+
const { from, to } = pageState.value
|
|
788
|
+
const nf = numberFormat.value
|
|
789
|
+
return fmt("pageRange", { from: nf.format(from), to: nf.format(to), total: nf.format(totalRowsN.value) })
|
|
790
|
+
})
|
|
791
|
+
const pageLabel = computed(() =>
|
|
792
|
+
fmt("pageLabel", { page: currentPage.value, count: pageCount.value }),
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
/** Navigation : met à jour l'état interne (repli), émet `update:page` + `page-change` */
|
|
796
|
+
const goPage = (next: number) => {
|
|
797
|
+
if (!paginationOn.value) return
|
|
798
|
+
const target = Math.min(Math.max(1, Math.trunc(next)), pageCount.value)
|
|
799
|
+
const previous = currentPage.value
|
|
800
|
+
localPage.value = target
|
|
801
|
+
if (target === previous) return
|
|
802
|
+
emit("update:page", target)
|
|
803
|
+
emit("page-change", {
|
|
804
|
+
page: target,
|
|
805
|
+
previousPage: previous,
|
|
806
|
+
pageSize: pageSizeN.value,
|
|
807
|
+
offset: (target - 1) * pageSizeN.value,
|
|
808
|
+
totalRows: totalRowsN.value,
|
|
809
|
+
pageCount: pageCount.value,
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// La pagination est **distante** : des lignes au-delà de `pageSize` signifient que l'app
|
|
814
|
+
// n'a pas découpé. On le signale plutôt que de tronquer silencieusement.
|
|
815
|
+
if (typeof document !== "undefined") {
|
|
816
|
+
watch(
|
|
817
|
+
[paginationOn, pageSizeN, () => state.value.length],
|
|
818
|
+
() => {
|
|
819
|
+
if (!paginationOn.value || state.value.length <= pageSizeN.value) return
|
|
820
|
+
console.warn(
|
|
821
|
+
`[dnax/ui] QSpreadsheet : ${state.value.length} lignes pour pageSize=${pageSizeN.value}. ` +
|
|
822
|
+
"La pagination est distante : passez seulement la page courante (`rows`) et `total-rows`.",
|
|
823
|
+
)
|
|
824
|
+
},
|
|
825
|
+
{ immediate: true },
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
|
|
608
829
|
// ─── Colonnes internes ───
|
|
609
830
|
const cols = ref<QSpreadsheetColumn[]>([])
|
|
610
831
|
watch(
|
|
@@ -701,12 +922,14 @@ const choiceText = (col: QSpreadsheetColumn | undefined, raw: any): string => {
|
|
|
701
922
|
|
|
702
923
|
// ─── Sélection (ancre + plage) ───
|
|
703
924
|
const sel = ref<QSpreadsheetSelection | null>(null)
|
|
925
|
+
// `immediate` : une cellule **présélectionnée** (`:selected`) doit être active dès le premier
|
|
926
|
+
// rendu (SSR compris) — sans ça, elle était ignorée jusqu'au premier changement de prop.
|
|
704
927
|
watch(
|
|
705
928
|
() => props.selected,
|
|
706
929
|
(v) => {
|
|
707
930
|
if (v !== sel.value) sel.value = v ? { ...v } : null
|
|
708
931
|
},
|
|
709
|
-
{ deep: true },
|
|
932
|
+
{ deep: true, immediate: true },
|
|
710
933
|
)
|
|
711
934
|
const setSel = (s: QSpreadsheetSelection | null) => {
|
|
712
935
|
sel.value = s
|
|
@@ -881,6 +1104,8 @@ const cellText = (row: number, column: string): string => {
|
|
|
881
1104
|
const ev = evalAt(row, column)
|
|
882
1105
|
if (isError(ev)) return ev.toString()
|
|
883
1106
|
if (col?.type === "select" || col?.type === "multiselect") return "" // rendu via badge / label
|
|
1107
|
+
// Fichiers joints : le texte « utile » (recherche, copie, CSV, formule) = les noms
|
|
1108
|
+
if (col?.type === "attachment") return attachmentsOf(ev).map((att) => att.name).join(", ")
|
|
884
1109
|
let out: any = ev === null || ev === undefined ? "" : ev
|
|
885
1110
|
// Apostrophe littérale (paste values) : affichée sans le préfixe
|
|
886
1111
|
if (typeof out === "string" && out.startsWith("'")) out = out.slice(1)
|
|
@@ -900,6 +1125,10 @@ const cellTitle = (row: number, column: string): string | undefined => {
|
|
|
900
1125
|
const text = choiceText(col, v)
|
|
901
1126
|
return text === "" ? undefined : text
|
|
902
1127
|
}
|
|
1128
|
+
if (col?.type === "attachment") {
|
|
1129
|
+
const list = attachmentsOf(v)
|
|
1130
|
+
return list.length ? list.map((att) => attachmentTitle(att)).join("\n") : undefined
|
|
1131
|
+
}
|
|
903
1132
|
return undefined
|
|
904
1133
|
}
|
|
905
1134
|
|
|
@@ -1018,8 +1247,15 @@ const editorInputMode = computed(() => {
|
|
|
1018
1247
|
})
|
|
1019
1248
|
|
|
1020
1249
|
const startEdit = (row: number, column: string, initial?: string) => {
|
|
1250
|
+
if (props.loading) return
|
|
1021
1251
|
const col = colOf(column)
|
|
1022
1252
|
if (isCellLocked(row, column) || col?.type === "boolean") return
|
|
1253
|
+
// Fichier joint : pas d'éditeur texte — Entrée / double-clic ouvrent le sélecteur
|
|
1254
|
+
// (une simple frappe de caractère ne doit **pas** déclencher de boîte de dialogue)
|
|
1255
|
+
if (col?.type === "attachment") {
|
|
1256
|
+
if (initial === undefined) openAttachments(row, column)
|
|
1257
|
+
return
|
|
1258
|
+
}
|
|
1023
1259
|
const raw = state.value[row]?.[column]
|
|
1024
1260
|
if (col?.type === "select") {
|
|
1025
1261
|
const opt = cellOptionsOf(col).find((o) => o.value === raw)
|
|
@@ -1093,6 +1329,8 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
|
|
|
1093
1329
|
const raw = String(text ?? "")
|
|
1094
1330
|
const t = raw.trim()
|
|
1095
1331
|
if (t === "") return null
|
|
1332
|
+
// Fichier joint : jamais cohérent depuis du texte (saisie, collage, import) → on garde
|
|
1333
|
+
if (col?.type === "attachment") return old
|
|
1096
1334
|
// Formule A1 : conservée brute dans la cellule (booléen/choix exclus) —
|
|
1097
1335
|
// `t` (déjà trimé) évite qu'un `" =…"` ne soit traité comme une formule par
|
|
1098
1336
|
// `isFormulaRaw` tout en échouant au parsing du moteur.
|
|
@@ -1181,6 +1419,155 @@ const toggleBoolean = (row: number, column: string) => {
|
|
|
1181
1419
|
setCellValue(row, column, old, !old)
|
|
1182
1420
|
}
|
|
1183
1421
|
|
|
1422
|
+
// ─── Fichiers joints (type `attachment`) ───
|
|
1423
|
+
/**
|
|
1424
|
+
* Valeur d'une cellule `attachment` → tableau typé. La **normalisation** (tableau,
|
|
1425
|
+
* fichier seul, entrées invalides ignorées) vit dans `lib/fileList.ts`, testée.
|
|
1426
|
+
*/
|
|
1427
|
+
const attachmentsOf = (value: unknown): QSpreadsheetAttachment[] =>
|
|
1428
|
+
fileAttachmentsOf(value) as QSpreadsheetAttachment[]
|
|
1429
|
+
|
|
1430
|
+
const ATTACHMENT_ICONS: Record<FileIconKind, string> = {
|
|
1431
|
+
image: icons.fileImage,
|
|
1432
|
+
video: icons.fileVideo,
|
|
1433
|
+
audio: icons.fileAudio,
|
|
1434
|
+
archive: icons.fileArchive,
|
|
1435
|
+
spreadsheet: icons.fileSpreadsheet,
|
|
1436
|
+
code: icons.fileCode,
|
|
1437
|
+
file: icons.fileText,
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const attachmentIcon = (att: QSpreadsheetAttachment) => ATTACHMENT_ICONS[fileIconKind(att)]
|
|
1441
|
+
|
|
1442
|
+
const attachmentTitle = (att: QSpreadsheetAttachment) =>
|
|
1443
|
+
`${att.name} — ${formatFileSize(att.size)}`
|
|
1444
|
+
|
|
1445
|
+
/** Object URLs créés par le composant (jamais une URL fournie par l'app) */
|
|
1446
|
+
const localAttachmentUrls = new Set<string>()
|
|
1447
|
+
|
|
1448
|
+
const releaseAttachmentUrl = (url?: string) => {
|
|
1449
|
+
if (!url || !localAttachmentUrls.has(url)) return
|
|
1450
|
+
URL.revokeObjectURL(url)
|
|
1451
|
+
localAttachmentUrls.delete(url)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const attachInputEl = ref<HTMLInputElement | null>(null)
|
|
1455
|
+
const attachTarget = ref<{ row: number; column: string } | null>(null)
|
|
1456
|
+
|
|
1457
|
+
/** Ouvre le sélecteur de fichiers d'une cellule (`accept` / `multiple` de la colonne) */
|
|
1458
|
+
const openAttachments = (row: number, column: string) => {
|
|
1459
|
+
const col = colOf(column)
|
|
1460
|
+
if (!col || col.type !== "attachment" || isCellLocked(row, column)) return
|
|
1461
|
+
const input = attachInputEl.value
|
|
1462
|
+
if (!input) return
|
|
1463
|
+
attachTarget.value = { row, column }
|
|
1464
|
+
input.accept = col.accept ?? ""
|
|
1465
|
+
// `multiple` : explicite, ou déduit du quota (`maxFiles > 1`)
|
|
1466
|
+
input.multiple = col.multiple === true || (col.maxFiles ?? 0) > 1
|
|
1467
|
+
input.value = "" // permet de re-sélectionner le même fichier
|
|
1468
|
+
input.click()
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const addAttachments = (row: number, column: string, incoming: File[]) => {
|
|
1472
|
+
const col = colOf(column)
|
|
1473
|
+
if (!col || col.type !== "attachment" || isCellLocked(row, column)) return
|
|
1474
|
+
const old = state.value[row]?.[column]
|
|
1475
|
+
const current = attachmentsOf(old)
|
|
1476
|
+
// Mono-fichier par défaut ; `multiple` (ou un quota > 1, ou un existant multiple)
|
|
1477
|
+
const multiple = col.multiple === true || current.length > 1 || (col.maxFiles ?? 0) > 1
|
|
1478
|
+
const maxFiles = col.maxFiles && col.maxFiles > 0 ? col.maxFiles : multiple ? 0 : 1
|
|
1479
|
+
|
|
1480
|
+
const { accepted, rejected } = selectFiles(incoming, {
|
|
1481
|
+
accept: col.accept,
|
|
1482
|
+
maxSize: col.maxFileSize,
|
|
1483
|
+
maxFiles,
|
|
1484
|
+
// En mono-fichier le nouveau remplace l'ancien : il ne consomme pas de quota
|
|
1485
|
+
current: multiple ? current.length : 0,
|
|
1486
|
+
})
|
|
1487
|
+
|
|
1488
|
+
if (accepted.length === 0) {
|
|
1489
|
+
if (rejected.length) {
|
|
1490
|
+
emit("files", { row, column, rowIndex: row, files: [], attachments: current, rejected })
|
|
1491
|
+
}
|
|
1492
|
+
return
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const added: QSpreadsheetAttachment[] = accepted.map((file) => {
|
|
1496
|
+
const url =
|
|
1497
|
+
typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
|
|
1498
|
+
? URL.createObjectURL(file)
|
|
1499
|
+
: undefined
|
|
1500
|
+
if (url) localAttachmentUrls.add(url)
|
|
1501
|
+
return { id: newRowKey(), name: file.name, size: file.size, type: file.type, url }
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
let next: QSpreadsheetAttachment[]
|
|
1505
|
+
if (multiple) {
|
|
1506
|
+
next = [...current, ...added]
|
|
1507
|
+
} else {
|
|
1508
|
+
// Remplacement : les object URLs de l'ancien fichier sont libérés
|
|
1509
|
+
for (const att of current) releaseAttachmentUrl(att.url)
|
|
1510
|
+
next = added.slice(0, 1)
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
pushHistory()
|
|
1514
|
+
setCellValue(row, column, old, next)
|
|
1515
|
+
emit("files", { row, column, rowIndex: row, files: accepted, attachments: next, rejected })
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const removeAttachment = (row: number, column: string, id: string) => {
|
|
1519
|
+
if (isCellLocked(row, column)) return
|
|
1520
|
+
const old = state.value[row]?.[column]
|
|
1521
|
+
const current = attachmentsOf(old)
|
|
1522
|
+
const target = current.find((att) => att.id === id)
|
|
1523
|
+
if (!target) return
|
|
1524
|
+
releaseAttachmentUrl(target.url)
|
|
1525
|
+
const next = current.filter((att) => att.id !== id)
|
|
1526
|
+
pushHistory()
|
|
1527
|
+
setCellValue(row, column, old, next.length ? next : null)
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const onAttachmentsChange = (e: Event) => {
|
|
1531
|
+
const input = e.target as HTMLInputElement
|
|
1532
|
+
const files = Array.from(input.files ?? [])
|
|
1533
|
+
input.value = "" // permet de re-sélectionner le même fichier
|
|
1534
|
+
const target = attachTarget.value
|
|
1535
|
+
attachTarget.value = null
|
|
1536
|
+
if (!target || files.length === 0) return
|
|
1537
|
+
addAttachments(target.row, target.column, files)
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Clic sur une puce de fichier : le composant n'ouvre **rien** lui-même (il ne connaît pas
|
|
1542
|
+
* la visionneuse de l'app) — il émet `attachment-click` avec la cellule et la ligne
|
|
1543
|
+
* complète. Le clic sur le `×` (retrait) est stoppé et ne passe pas ici.
|
|
1544
|
+
*/
|
|
1545
|
+
const emitAttachmentClick = (
|
|
1546
|
+
row: number,
|
|
1547
|
+
column: string,
|
|
1548
|
+
attachment: QSpreadsheetAttachment,
|
|
1549
|
+
event: MouseEvent,
|
|
1550
|
+
) => {
|
|
1551
|
+
const value = state.value[row]?.[column]
|
|
1552
|
+
emit("attachment-click", {
|
|
1553
|
+
row,
|
|
1554
|
+
column,
|
|
1555
|
+
attachment,
|
|
1556
|
+
attachments: attachmentsOf(value),
|
|
1557
|
+
value,
|
|
1558
|
+
rowData: state.value[row] ?? {},
|
|
1559
|
+
event,
|
|
1560
|
+
})
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** Démontage : libère les object URLs encore en vie (l'app garde les siens) */
|
|
1564
|
+
onBeforeUnmount(() => {
|
|
1565
|
+
for (const url of [...localAttachmentUrls]) {
|
|
1566
|
+
URL.revokeObjectURL(url)
|
|
1567
|
+
localAttachmentUrls.delete(url)
|
|
1568
|
+
}
|
|
1569
|
+
})
|
|
1570
|
+
|
|
1184
1571
|
const clearSelection = () => {
|
|
1185
1572
|
const rect = selRect.value
|
|
1186
1573
|
if (!rect) return
|
|
@@ -1190,8 +1577,13 @@ const clearSelection = () => {
|
|
|
1190
1577
|
const col = cols.value[c]
|
|
1191
1578
|
if (!col || isCellLocked(r, col.name)) continue
|
|
1192
1579
|
const v = state.value[r]?.[col.name]
|
|
1193
|
-
if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0))
|
|
1580
|
+
if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0)) {
|
|
1581
|
+
// Effacer une cellule `attachment` libère ses object URLs locales
|
|
1582
|
+
if (col.type === "attachment") {
|
|
1583
|
+
for (const att of attachmentsOf(v)) releaseAttachmentUrl(att.url)
|
|
1584
|
+
}
|
|
1194
1585
|
changed.push({ row: r, column: col.name })
|
|
1586
|
+
}
|
|
1195
1587
|
}
|
|
1196
1588
|
}
|
|
1197
1589
|
if (!changed.length) return
|
|
@@ -1512,6 +1904,10 @@ const sortByColumn = (column?: string, desc = false) => {
|
|
|
1512
1904
|
// `label` peut être numérique → toString avant comparaison ; liste → libellés joints
|
|
1513
1905
|
return choiceText(col, raw).toLowerCase()
|
|
1514
1906
|
}
|
|
1907
|
+
// Fichier joint : tri sur les noms (jamais `[object Object]`)
|
|
1908
|
+
if (col?.type === "attachment") {
|
|
1909
|
+
return attachmentsOf(raw).map((att) => att.name).join(", ").toLowerCase()
|
|
1910
|
+
}
|
|
1515
1911
|
return String(ev).toLowerCase()
|
|
1516
1912
|
}
|
|
1517
1913
|
const sortedIdx = state.value
|
|
@@ -1606,7 +2002,7 @@ const jumpPage = (dir: 1 | -1, extend = false) => {
|
|
|
1606
2002
|
if (!sel.value || !visibleRows.value.length) return
|
|
1607
2003
|
let idx = visibleRows.value.indexOf(sel.value.row)
|
|
1608
2004
|
if (idx < 0) idx = 0
|
|
1609
|
-
const page = Math.max(1, Math.floor(viewH.value /
|
|
2005
|
+
const page = Math.max(1, Math.floor(viewH.value / cellHeightPx.value))
|
|
1610
2006
|
let target = idx
|
|
1611
2007
|
for (let k = 0; k < page; k++) {
|
|
1612
2008
|
const n = nextVisibleIndex(target, dir)
|
|
@@ -1712,6 +2108,8 @@ const fillRightKey = () => {
|
|
|
1712
2108
|
|
|
1713
2109
|
// ─── Clavier global (navigation / édition) ───
|
|
1714
2110
|
const onKeydown = (e: KeyboardEvent) => {
|
|
2111
|
+
// Données en cours de chargement : on ne navigue ni n'édite une page périmée
|
|
2112
|
+
if (props.loading) return
|
|
1715
2113
|
if (editing.value) return
|
|
1716
2114
|
if (!sel.value || !cols.value.length) return
|
|
1717
2115
|
const mod = e.ctrlKey || e.metaKey
|
|
@@ -1952,13 +2350,17 @@ const fxInputEl = ref<HTMLInputElement | null>(null)
|
|
|
1952
2350
|
|
|
1953
2351
|
const syncFx = () => {
|
|
1954
2352
|
const s = sel.value
|
|
2353
|
+
const col = s ? colOf(s.column) : undefined
|
|
1955
2354
|
const raw =
|
|
1956
2355
|
s && s.row >= 0 && s.row < state.value.length
|
|
1957
2356
|
? state.value[s.row]?.[s.column]
|
|
1958
2357
|
: undefined
|
|
1959
|
-
|
|
2358
|
+
// Fichier joint : rien à montrer en fx (`String(raw)` affichait « [object Object]… »)
|
|
2359
|
+
fxDraft.value = fxTextFor(raw, col?.type)
|
|
1960
2360
|
}
|
|
1961
|
-
|
|
2361
|
+
// `immediate` : avec une cellule présélectionnée (`selected`), la barre fx affiche la
|
|
2362
|
+
// valeur dès le premier rendu (et en SSR) au lieu de rester vide jusqu'au premier clic.
|
|
2363
|
+
watch([sel, state], syncFx, { immediate: true })
|
|
1962
2364
|
|
|
1963
2365
|
const fxCanEdit = computed(() => {
|
|
1964
2366
|
if (!sel.value) return false
|
|
@@ -1969,10 +2371,17 @@ const fxCanEdit = computed(() => {
|
|
|
1969
2371
|
!!col &&
|
|
1970
2372
|
col.type !== "boolean" &&
|
|
1971
2373
|
col.type !== "select" &&
|
|
1972
|
-
col.type !== "multiselect"
|
|
2374
|
+
col.type !== "multiselect" &&
|
|
2375
|
+
col.type !== "attachment"
|
|
1973
2376
|
)
|
|
1974
2377
|
})
|
|
1975
2378
|
|
|
2379
|
+
/** Placeholder de la barre fx : vide pour un fichier joint (rien à éditer ni à montrer) */
|
|
2380
|
+
const fxPlaceholderText = computed(() => {
|
|
2381
|
+
if (sel.value && colOf(sel.value.column)?.type === "attachment") return ""
|
|
2382
|
+
return fxCanEdit.value ? t("fxPlaceholder") : t("fxNoSel")
|
|
2383
|
+
})
|
|
2384
|
+
|
|
1976
2385
|
/**
|
|
1977
2386
|
* Aperçu **live** du résultat de la formule en cours de saisie dans la barre fx.
|
|
1978
2387
|
* Évaluée dans le contexte de la ligne sélectionnée (références par nom de colonne
|
|
@@ -2294,7 +2703,7 @@ const acceptFxAt = (i: number) => {
|
|
|
2294
2703
|
|
|
2295
2704
|
// ════════ Hauteurs de lignes (resize au drag sur le numéro) ════════
|
|
2296
2705
|
const rowHeights = ref<Record<number, number>>({})
|
|
2297
|
-
const rowH = (r: number) => rowHeights.value[r] ??
|
|
2706
|
+
const rowH = (r: number) => rowHeights.value[r] ?? cellHeightPx.value
|
|
2298
2707
|
|
|
2299
2708
|
const rowResizing = ref<{ r: number; y: number; h: number } | null>(null)
|
|
2300
2709
|
const startRowResize = (e: PointerEvent, r: number) => {
|
|
@@ -2446,13 +2855,13 @@ const clearFormatSelection = () => {
|
|
|
2446
2855
|
// ════════ Freeze panes (gel lignes / colonnes) ════════
|
|
2447
2856
|
const frozenRowsN = computed(() => Math.max(0, Math.min(props.frozenRows ?? 0, state.value.length)))
|
|
2448
2857
|
const frozenColsN = computed(() => Math.max(0, Math.min(props.frozenCols ?? 0, cols.value.length)))
|
|
2449
|
-
const gutterW = computed(() => (props.showRowNumbers ?
|
|
2450
|
-
const headH = computed(() => (props.showColumnHeaders ?
|
|
2858
|
+
const gutterW = computed(() => (props.showRowNumbers ? headerMetrics.value.gutter : 0))
|
|
2859
|
+
const headH = computed(() => (props.showColumnHeaders ? headerMetrics.value.height : 0))
|
|
2451
2860
|
const colWNum = (ci: number) => {
|
|
2452
2861
|
const c = cols.value[ci]
|
|
2453
|
-
if (!c) return
|
|
2454
|
-
const w = colWidths.value[c.name] ?? c.width ??
|
|
2455
|
-
return typeof w === "number" ? w : parseFloat(w) ||
|
|
2862
|
+
if (!c) return cellWidthPx.value
|
|
2863
|
+
const w = colWidths.value[c.name] ?? c.width ?? cellWidthPx.value
|
|
2864
|
+
return typeof w === "number" ? w : parseFloat(w) || cellWidthPx.value
|
|
2456
2865
|
}
|
|
2457
2866
|
const frozenLeftOf = (ci: number) => {
|
|
2458
2867
|
let l = gutterW.value
|
|
@@ -2505,7 +2914,9 @@ const filters = ref<Record<string, string[] | null>>({})
|
|
|
2505
2914
|
const filterValuesOf = (raw: any): any[] => {
|
|
2506
2915
|
if (Array.isArray(raw)) {
|
|
2507
2916
|
const values = raw.filter((v) => !isBlankValue(v))
|
|
2508
|
-
|
|
2917
|
+
// Fichier joint : la valeur filtrante est le **nom** du fichier, pas l'objet
|
|
2918
|
+
const mapped = values.map((v) => (isAttachment(v) ? v.name : v))
|
|
2919
|
+
return mapped.length ? mapped : [null]
|
|
2509
2920
|
}
|
|
2510
2921
|
return isBlankValue(raw) ? [null] : [raw]
|
|
2511
2922
|
}
|
|
@@ -2668,7 +3079,7 @@ const updateRange = () => {
|
|
|
2668
3079
|
}
|
|
2669
3080
|
const start = Math.max(0, lo - VIRTUAL_OVERSCAN)
|
|
2670
3081
|
let end = start
|
|
2671
|
-
while (end < n && info.rows[end]!.top < bottom + VIRTUAL_OVERSCAN *
|
|
3082
|
+
while (end < n && info.rows[end]!.top < bottom + VIRTUAL_OVERSCAN * cellHeightPx.value) end++
|
|
2672
3083
|
end = Math.min(n - 1, end + VIRTUAL_OVERSCAN)
|
|
2673
3084
|
renderRange.value = { start, end }
|
|
2674
3085
|
topPad.value = Math.max(0, info.rows[start]!.top - headH.value)
|
|
@@ -2692,7 +3103,7 @@ const ensureRowVisible = (realRow: number) => {
|
|
|
2692
3103
|
// scroll-behavior: smooth (CSS) anime la remontée / descente ; le focus est
|
|
2693
3104
|
// différé jusqu'à ce que la ligne soit effectivement rendue par la fenêtre
|
|
2694
3105
|
pendingFocus.value = { row: realRow, col: cols.value[sel.value?.column ? colIndex(sel.value.column) : 0]?.name ?? "" }
|
|
2695
|
-
el.scrollTop = Math.max(0, info.top - headH.value - VIRTUAL_OVERSCAN *
|
|
3106
|
+
el.scrollTop = Math.max(0, info.top - headH.value - VIRTUAL_OVERSCAN * cellHeightPx.value)
|
|
2696
3107
|
updateRange()
|
|
2697
3108
|
}
|
|
2698
3109
|
|
|
@@ -2902,7 +3313,7 @@ const insertColumnAt = (pos: "left" | "right") => {
|
|
|
2902
3313
|
const nextCols = [...cols.value]
|
|
2903
3314
|
nextCols.splice(idx, 0, col)
|
|
2904
3315
|
cols.value = nextCols
|
|
2905
|
-
colWidths.value = { ...colWidths.value, [name]:
|
|
3316
|
+
colWidths.value = { ...colWidths.value, [name]: cellWidthPx.value }
|
|
2906
3317
|
const nextRows = state.value.map((r) => ({ ...r, [name]: "" }))
|
|
2907
3318
|
state.value = nextRows
|
|
2908
3319
|
emit("update:columns", nextCols)
|
|
@@ -3173,13 +3584,13 @@ const isActiveRow = (r: number) => {
|
|
|
3173
3584
|
const colWidths = ref<Record<string, string | number>>({})
|
|
3174
3585
|
const syncColWidths = () => {
|
|
3175
3586
|
const next: Record<string, string | number> = {}
|
|
3176
|
-
for (const c of cols.value) next[c.name] = c.width ??
|
|
3587
|
+
for (const c of cols.value) next[c.name] = c.width ?? cellWidthPx.value
|
|
3177
3588
|
colWidths.value = next
|
|
3178
3589
|
}
|
|
3179
3590
|
watch(cols, syncColWidths, { immediate: true, deep: true })
|
|
3180
3591
|
|
|
3181
3592
|
const widthStyle = (col: QSpreadsheetColumn) => {
|
|
3182
|
-
const w = colWidths.value[col.name] ?? col.width ??
|
|
3593
|
+
const w = colWidths.value[col.name] ?? col.width ?? cellWidthPx.value
|
|
3183
3594
|
return typeof w === "number" ? w + "px" : w
|
|
3184
3595
|
}
|
|
3185
3596
|
const minW = (col: QSpreadsheetColumn) => (col.minWidth ?? 60) + "px"
|
|
@@ -3195,7 +3606,7 @@ const startResize = (e: PointerEvent, col: QSpreadsheetColumn) => {
|
|
|
3195
3606
|
resizing.value = {
|
|
3196
3607
|
col,
|
|
3197
3608
|
x: e.clientX,
|
|
3198
|
-
w: typeof current === "number" ? current :
|
|
3609
|
+
w: typeof current === "number" ? current : cellWidthPx.value,
|
|
3199
3610
|
}
|
|
3200
3611
|
window.addEventListener("pointermove", onResizeMove)
|
|
3201
3612
|
window.addEventListener("pointerup", endResize)
|
|
@@ -3335,13 +3746,28 @@ const cellContent = (row: number, col: QSpreadsheetColumn) => {
|
|
|
3335
3746
|
return cellText(row, col.name)
|
|
3336
3747
|
}
|
|
3337
3748
|
|
|
3749
|
+
/**
|
|
3750
|
+
* Props des slots de cellule (`#cell-<nom>`, `#cell`) : tout ce qu'un composant
|
|
3751
|
+
* personnalisé peut vouloir, sans que le template ait à connaître l'API interne.
|
|
3752
|
+
* `setValue` passe par l'historique et les événements (`cell-change`, `update:rows`).
|
|
3753
|
+
*/
|
|
3754
|
+
const cellSlotProps = (row: number, col: QSpreadsheetColumn, columnIndex: number) => ({
|
|
3755
|
+
row: state.value[row] ?? {},
|
|
3756
|
+
rowIndex: row,
|
|
3757
|
+
column: col,
|
|
3758
|
+
columnIndex,
|
|
3759
|
+
value: cellValue(row, col.name),
|
|
3760
|
+
text: cellText(row, col.name),
|
|
3761
|
+
setValue: (next: any) => setCellValue(row, col.name, state.value[row]?.[col.name], next),
|
|
3762
|
+
})
|
|
3763
|
+
|
|
3338
3764
|
const editingCol = computed(() => (editing.value ? colOf(editing.value.column) : undefined))
|
|
3339
3765
|
|
|
3340
3766
|
const selLabel = computed(() => {
|
|
3341
3767
|
if (!sel.value) return ""
|
|
3342
3768
|
const ci = colIndex(sel.value.column)
|
|
3343
3769
|
if (ci === -1) return ""
|
|
3344
|
-
return colLetter(ci) + (sel.value.row + 1)
|
|
3770
|
+
return colLetter(ci) + (rowOffset.value + sel.value.row + 1)
|
|
3345
3771
|
})
|
|
3346
3772
|
|
|
3347
3773
|
const optionIsActive = (opt: QSpreadsheetCellOption) => activeOption.value === opt
|
|
@@ -3369,7 +3795,7 @@ const switching = ref(false)
|
|
|
3369
3795
|
|
|
3370
3796
|
const makeExtras = (cols: QSpreadsheetColumn[]): SheetExtras => {
|
|
3371
3797
|
const widths: Record<string, number | string> = {}
|
|
3372
|
-
for (const c of cols) widths[c.name] = c.width ??
|
|
3798
|
+
for (const c of cols) widths[c.name] = c.width ?? cellWidthPx.value
|
|
3373
3799
|
return {
|
|
3374
3800
|
cols: cols.map((c) => ({ ...c })),
|
|
3375
3801
|
widths,
|
|
@@ -3442,7 +3868,7 @@ const canonicalColumns = (columns: QSpreadsheetColumn[] | undefined): QSpreadshe
|
|
|
3442
3868
|
...c,
|
|
3443
3869
|
type: c.type ?? "text",
|
|
3444
3870
|
options: c.options ?? [],
|
|
3445
|
-
width: c.width ??
|
|
3871
|
+
width: c.width ?? cellWidthPx.value,
|
|
3446
3872
|
}))
|
|
3447
3873
|
|
|
3448
3874
|
watch(
|
|
@@ -3788,7 +4214,8 @@ const findTextOf = (r: number, ci: number): string => {
|
|
|
3788
4214
|
const raw = state.value[r]?.[col.name]
|
|
3789
4215
|
// `label` peut être un nombre : findScan fait `.toLowerCase()` sur le retour
|
|
3790
4216
|
if (col.type === "select" || col.type === "multiselect") return choiceText(col, raw)
|
|
3791
|
-
|
|
4217
|
+
// Fichier joint → noms de fichiers ; jamais « [object Object] » (cf. lib/spreadsheetDisplay)
|
|
4218
|
+
return searchTextFor(raw, col.type)
|
|
3792
4219
|
}
|
|
3793
4220
|
const findScan = () => {
|
|
3794
4221
|
const q = findQuery.value
|
|
@@ -4630,7 +5057,12 @@ defineExpose({
|
|
|
4630
5057
|
</script>
|
|
4631
5058
|
|
|
4632
5059
|
<template>
|
|
4633
|
-
<div
|
|
5060
|
+
<div
|
|
5061
|
+
ref="rootRef"
|
|
5062
|
+
class="q-spreadsheet"
|
|
5063
|
+
:class="rootClasses"
|
|
5064
|
+
:style="[radiusStyle(effectiveRadius), cellSizeStyle]"
|
|
5065
|
+
>
|
|
4634
5066
|
<!-- ═══════ Onglets de feuilles (classeur, haut) ═══════ -->
|
|
4635
5067
|
<div
|
|
4636
5068
|
v-if="sheetsMode && sheetsPosition === 'top'"
|
|
@@ -4762,7 +5194,7 @@ defineExpose({
|
|
|
4762
5194
|
class="q-spreadsheet__fx-input"
|
|
4763
5195
|
:value="fxDraft"
|
|
4764
5196
|
:disabled="readonly || disable"
|
|
4765
|
-
:placeholder="
|
|
5197
|
+
:placeholder="fxPlaceholderText"
|
|
4766
5198
|
spellcheck="false"
|
|
4767
5199
|
@input="onFxInput"
|
|
4768
5200
|
@keyup="syncFxCaret"
|
|
@@ -4936,6 +5368,7 @@ defineExpose({
|
|
|
4936
5368
|
class="q-spreadsheet__viewport"
|
|
4937
5369
|
:style="{ height: heightStyle }"
|
|
4938
5370
|
tabindex="0"
|
|
5371
|
+
:aria-busy="loading || undefined"
|
|
4939
5372
|
@keydown="onKeydown"
|
|
4940
5373
|
@scroll="onViewportScroll"
|
|
4941
5374
|
@contextmenu.prevent
|
|
@@ -4977,10 +5410,18 @@ defineExpose({
|
|
|
4977
5410
|
@click="selectCol(ci, $event.shiftKey)"
|
|
4978
5411
|
@contextmenu="onColContext(ci, $event)"
|
|
4979
5412
|
>
|
|
4980
|
-
<
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
5413
|
+
<slot
|
|
5414
|
+
:name="'head-' + col.name"
|
|
5415
|
+
:column="col"
|
|
5416
|
+
:column-index="ci"
|
|
5417
|
+
:letter="colLetter(ci)"
|
|
5418
|
+
:label="col.label || col.name"
|
|
5419
|
+
>
|
|
5420
|
+
<span class="q-spreadsheet__colhead-inner">
|
|
5421
|
+
<span class="q-spreadsheet__colhead-letter">{{ colLetter(ci) }}</span>
|
|
5422
|
+
<span class="q-spreadsheet__colhead-label">{{ col.label || col.name }}</span>
|
|
5423
|
+
</span>
|
|
5424
|
+
</slot>
|
|
4984
5425
|
<button
|
|
4985
5426
|
class="q-spreadsheet__fbtn"
|
|
4986
5427
|
:class="{ 'q-spreadsheet__fbtn--on': hasFilterFor(col.name) }"
|
|
@@ -5034,7 +5475,7 @@ defineExpose({
|
|
|
5034
5475
|
@click="selectRow(ri, $event.shiftKey)"
|
|
5035
5476
|
@contextmenu="onRowContext(ri, $event)"
|
|
5036
5477
|
>
|
|
5037
|
-
{{ ri + 1 }}
|
|
5478
|
+
{{ rowOffset + ri + 1 }}
|
|
5038
5479
|
<span
|
|
5039
5480
|
class="q-spreadsheet__row-resize"
|
|
5040
5481
|
title="Resize row"
|
|
@@ -5070,47 +5511,111 @@ defineExpose({
|
|
|
5070
5511
|
@dblclick="startEdit(ri, col.name)"
|
|
5071
5512
|
@contextmenu="onCellContext(ri, col.name, $event)"
|
|
5072
5513
|
>
|
|
5073
|
-
<!--
|
|
5074
|
-
<
|
|
5075
|
-
v-if="col.
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5514
|
+
<!-- contenu personnalisé : `#cell-<nom>` (prioritaire), puis `#cell` -->
|
|
5515
|
+
<slot
|
|
5516
|
+
v-if="$slots['cell-' + col.name]"
|
|
5517
|
+
:name="'cell-' + col.name"
|
|
5518
|
+
v-bind="cellSlotProps(ri, col, ci)"
|
|
5519
|
+
/>
|
|
5520
|
+
<slot
|
|
5521
|
+
v-else-if="$slots.cell"
|
|
5522
|
+
name="cell"
|
|
5523
|
+
v-bind="cellSlotProps(ri, col, ci)"
|
|
5524
|
+
/>
|
|
5525
|
+
<template v-else>
|
|
5526
|
+
<!-- booléen -->
|
|
5527
|
+
<button
|
|
5528
|
+
v-if="col.type === 'boolean'"
|
|
5529
|
+
type="button"
|
|
5530
|
+
class="q-spreadsheet__checkbox"
|
|
5531
|
+
:class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
|
|
5532
|
+
:disabled="isCellLocked(ri, col.name)"
|
|
5533
|
+
aria-label="Toggle"
|
|
5534
|
+
@click.stop="toggleBoolean(ri, col.name)"
|
|
5535
|
+
>
|
|
5536
|
+
<Icon v-if="cellValue(ri, col.name)" :icon="icons.check" aria-hidden="true" />
|
|
5537
|
+
</button>
|
|
5538
|
+
|
|
5539
|
+
<!-- fichier joint : vignette (images) ou icône typée + nom + retrait -->
|
|
5540
|
+
<span
|
|
5541
|
+
v-else-if="col.type === 'attachment'"
|
|
5542
|
+
class="q-spreadsheet__attachments"
|
|
5543
|
+
>
|
|
5544
|
+
<span
|
|
5545
|
+
v-for="att in attachmentsOf(cellValue(ri, col.name))"
|
|
5546
|
+
:key="att.id"
|
|
5547
|
+
class="q-spreadsheet__attachment"
|
|
5548
|
+
:title="attachmentTitle(att)"
|
|
5549
|
+
role="button"
|
|
5550
|
+
tabindex="-1"
|
|
5551
|
+
@click="emitAttachmentClick(ri, col.name, att, $event)"
|
|
5552
|
+
@dblclick.stop
|
|
5553
|
+
>
|
|
5554
|
+
<img
|
|
5555
|
+
v-if="att.url && att.type.startsWith('image/')"
|
|
5556
|
+
:src="att.url"
|
|
5557
|
+
class="q-spreadsheet__attachment-thumb"
|
|
5558
|
+
:alt="att.name"
|
|
5559
|
+
/>
|
|
5560
|
+
<Icon
|
|
5561
|
+
v-else
|
|
5562
|
+
:icon="attachmentIcon(att)"
|
|
5563
|
+
class="q-spreadsheet__attachment-icon"
|
|
5564
|
+
aria-hidden="true"
|
|
5565
|
+
/>
|
|
5566
|
+
<span class="q-spreadsheet__attachment-name">{{ att.name }}</span>
|
|
5567
|
+
<button
|
|
5568
|
+
v-if="!isCellLocked(ri, col.name)"
|
|
5569
|
+
type="button"
|
|
5570
|
+
class="q-spreadsheet__attachment-x"
|
|
5571
|
+
:aria-label="`Remove ${att.name}`"
|
|
5572
|
+
@click.stop="removeAttachment(ri, col.name, att.id)"
|
|
5573
|
+
@dblclick.stop
|
|
5574
|
+
>
|
|
5575
|
+
<Icon :icon="icons.x" aria-hidden="true" />
|
|
5576
|
+
</button>
|
|
5577
|
+
</span>
|
|
5578
|
+
<button
|
|
5579
|
+
v-if="!isCellLocked(ri, col.name)"
|
|
5580
|
+
type="button"
|
|
5581
|
+
class="q-spreadsheet__attachment-add"
|
|
5582
|
+
:aria-label="col.multiple ? 'Add files' : 'Add a file'"
|
|
5583
|
+
@click.stop="openAttachments(ri, col.name)"
|
|
5584
|
+
@dblclick.stop
|
|
5585
|
+
>
|
|
5586
|
+
<Icon :icon="icons.plus" aria-hidden="true" />
|
|
5587
|
+
</button>
|
|
5588
|
+
</span>
|
|
5589
|
+
|
|
5590
|
+
<!-- select : badge coloré -->
|
|
5100
5591
|
<span
|
|
5101
|
-
v-
|
|
5102
|
-
:key="bi"
|
|
5592
|
+
v-else-if="col.type === 'select' && badgeOf(col, cellValue(ri, col.name))"
|
|
5103
5593
|
class="q-spreadsheet__badge"
|
|
5104
|
-
:style="
|
|
5105
|
-
>
|
|
5106
|
-
|
|
5594
|
+
:style="badgeOf(col, cellValue(ri, col.name))!.style"
|
|
5595
|
+
>
|
|
5596
|
+
{{ badgeOf(col, cellValue(ri, col.name))!.label }}
|
|
5597
|
+
</span>
|
|
5107
5598
|
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5599
|
+
<!-- multiselect : une série de badges colorés -->
|
|
5600
|
+
<span
|
|
5601
|
+
v-else-if="col.type === 'multiselect' && multiBadges(col, cellValue(ri, col.name)).length"
|
|
5602
|
+
class="q-spreadsheet__badges"
|
|
5603
|
+
>
|
|
5604
|
+
<span
|
|
5605
|
+
v-for="(badge, bi) in multiBadges(col, cellValue(ri, col.name))"
|
|
5606
|
+
:key="bi"
|
|
5607
|
+
class="q-spreadsheet__badge"
|
|
5608
|
+
:style="badge.style"
|
|
5609
|
+
>{{ badge.label }}</span>
|
|
5610
|
+
</span>
|
|
5611
|
+
|
|
5612
|
+
<!-- valeur -->
|
|
5613
|
+
<span
|
|
5614
|
+
v-else
|
|
5615
|
+
class="q-spreadsheet__cell-text"
|
|
5616
|
+
:class="cellTextWrapClass(ri, col.name)"
|
|
5617
|
+
>{{ cellContent(ri, col) }}</span>
|
|
5618
|
+
</template>
|
|
5114
5619
|
</td>
|
|
5115
5620
|
</tr>
|
|
5116
5621
|
<template v-if="virtualOn && bottomPad > 0">
|
|
@@ -5142,6 +5647,16 @@ defineExpose({
|
|
|
5142
5647
|
@dblclick.stop.prevent="fillHandleDbl"
|
|
5143
5648
|
/>
|
|
5144
5649
|
|
|
5650
|
+
<!-- Sélecteur de fichiers des cellules `attachment` (input natif, masqué) -->
|
|
5651
|
+
<input
|
|
5652
|
+
ref="attachInputEl"
|
|
5653
|
+
type="file"
|
|
5654
|
+
class="q-spreadsheet__attach-input"
|
|
5655
|
+
tabindex="-1"
|
|
5656
|
+
aria-hidden="true"
|
|
5657
|
+
@change="onAttachmentsChange"
|
|
5658
|
+
/>
|
|
5659
|
+
|
|
5145
5660
|
<!-- ═══════ Éditeur overlay ═══════ -->
|
|
5146
5661
|
<div
|
|
5147
5662
|
v-if="editing && editingCol"
|
|
@@ -5297,6 +5812,30 @@ defineExpose({
|
|
|
5297
5812
|
{{ changesLabel }}
|
|
5298
5813
|
</span>
|
|
5299
5814
|
<span class="q-spreadsheet__status-spacer" />
|
|
5815
|
+
<span v-if="paginationOn" class="q-spreadsheet__pager">
|
|
5816
|
+
<button
|
|
5817
|
+
class="q-spreadsheet__tool"
|
|
5818
|
+
type="button"
|
|
5819
|
+
:disabled="loading || currentPage <= 1"
|
|
5820
|
+
:title="t('pagePrev')"
|
|
5821
|
+
:aria-label="t('pagePrev')"
|
|
5822
|
+
@click="goPage(currentPage - 1)"
|
|
5823
|
+
>
|
|
5824
|
+
<Icon :icon="icons.chevronLeft" aria-hidden="true" />
|
|
5825
|
+
</button>
|
|
5826
|
+
<span class="q-spreadsheet__pager-range">{{ pageRangeLabel }}</span>
|
|
5827
|
+
<span class="q-spreadsheet__pager-page">{{ pageLabel }}</span>
|
|
5828
|
+
<button
|
|
5829
|
+
class="q-spreadsheet__tool"
|
|
5830
|
+
type="button"
|
|
5831
|
+
:disabled="loading || currentPage >= pageCount"
|
|
5832
|
+
:title="t('pageNext')"
|
|
5833
|
+
:aria-label="t('pageNext')"
|
|
5834
|
+
@click="goPage(currentPage + 1)"
|
|
5835
|
+
>
|
|
5836
|
+
<Icon :icon="icons.chevronRight" aria-hidden="true" />
|
|
5837
|
+
</button>
|
|
5838
|
+
</span>
|
|
5300
5839
|
<span v-if="multiMode" class="q-spreadsheet__status-item">{{ statusSheets }}</span>
|
|
5301
5840
|
<span class="q-spreadsheet__status-zoom">
|
|
5302
5841
|
<button class="q-spreadsheet__tool" type="button" title="Zoom out" aria-label="Zoom out" @click="zoomOut">
|
|
@@ -5311,6 +5850,18 @@ defineExpose({
|
|
|
5311
5850
|
</span>
|
|
5312
5851
|
</div>
|
|
5313
5852
|
|
|
5853
|
+
<!--
|
|
5854
|
+
Chargement distant (pagination, rechargement…) : voile sur **toute** la carte.
|
|
5855
|
+
Posé ici (et non dans `.q-spreadsheet__viewport`) : un `absolute` dans le conteneur
|
|
5856
|
+
scrollable défilerait avec le contenu — le voile sortirait du cadre visible.
|
|
5857
|
+
-->
|
|
5858
|
+
<QInnerLoading :showing="loading">
|
|
5859
|
+
<slot name="loading">
|
|
5860
|
+
<span class="q-spinner-ring q-spreadsheet__loader" aria-hidden="true" />
|
|
5861
|
+
<span class="q-inner-loading__label">{{ loadingLabel || t('loading') }}</span>
|
|
5862
|
+
</slot>
|
|
5863
|
+
</QInnerLoading>
|
|
5864
|
+
|
|
5314
5865
|
<!-- ═══════ Menu contextuel (clic droit) ═══════ -->
|
|
5315
5866
|
<Teleport to="body">
|
|
5316
5867
|
<div
|
|
@@ -5321,7 +5872,7 @@ defineExpose({
|
|
|
5321
5872
|
@contextmenu.prevent
|
|
5322
5873
|
>
|
|
5323
5874
|
<div class="q-spreadsheet__ctx-title">
|
|
5324
|
-
<span v-if="ctxMenu.kind === 'row' && sel">{{ t('rowLbl') }} {{ sel.row + 1 }}</span>
|
|
5875
|
+
<span v-if="ctxMenu.kind === 'row' && sel">{{ t('rowLbl') }} {{ rowOffset + sel.row + 1 }}</span>
|
|
5325
5876
|
<span v-else-if="ctxMenu.kind === 'col' && sel">{{ t('colLbl') }} {{ colLetter(colIndex(sel.column)) }}</span>
|
|
5326
5877
|
<span v-else>{{ selLabel || t('cellLbl') }}</span>
|
|
5327
5878
|
</div>
|