@dnax/ui 0.0.67 → 0.0.69

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.
@@ -13,16 +13,31 @@
13
13
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
14
14
  import { chartToECharts, COLOR_TOKENS } from "../lib/chart"
15
15
  import { normalizeCssColor } from "../lib/color"
16
- import { legendPickOf, nextLegendPick, pickFromEvent, sameSelection, tableModel } from "../lib/chart"
17
- import type { QChartAxis, QChartColumn, QChartLegend, QChartMark, QChartPick } from "../lib/chart"
16
+ import { legendPickOf, nextLegendPick, pickFromEvent, sameSelection, tableCsv, tableModel } from "../lib/chart"
17
+ import { useConfigLang } from "../lib/useComponentProps"
18
+ import type {
19
+ QChartAxis,
20
+ QChartColumn,
21
+ QChartLegend,
22
+ QChartMark,
23
+ QChartPick,
24
+ QChartTooltip,
25
+ } from "../lib/chart"
18
26
 
19
27
  interface Props {
20
28
  /** Marques à rendre : `{ type: 'line', data, x, y }`, `{ type: 'bar', … }`… */
21
29
  marks?: QChartMark[]
22
- /** Axe X (type, libellé, bornes, grille) */
30
+ /** Axe X (type, libellé, bornes, grille, format, granularité temporelle) */
23
31
  x?: QChartAxis
24
- /** Axe Y (type, libellé, bornes, grille) */
32
+ /** Axe Y (type, libellé, bornes, grille, format) */
25
33
  y?: QChartAxis
34
+ /** Axe Y **secondaire**, à droite — pour un combo volume (barres) + taux (ligne) :
35
+ * la marque visée déclare `yAxis: 'right'`. */
36
+ yRight?: QChartAxis
37
+ /** Locale des nombres / dates (`fr-FR` par défaut, sinon celle du provider) */
38
+ locale?: string
39
+ /** Mode **compact** (mini-widget) : marges réduites, axes et légende masqués par défaut */
40
+ compact?: boolean
26
41
  /** Hauteur du conteneur (nombre = px, sinon valeur CSS) */
27
42
  height?: number | string
28
43
  /** Titre du graphique */
@@ -32,8 +47,9 @@ interface Props {
32
47
  /** Légende : `false` pour la masquer, `true` pour la forcer (défaut : dès qu'une série est
33
48
  * nommée), ou `{ position: 'top' | 'bottom' | 'left' | 'right', offset, align }` */
34
49
  legend?: boolean | QChartLegend
35
- /** Info-bulle au survol (défaut : true) */
36
- tooltip?: boolean
50
+ /** Info-bulle au survol (défaut : true) — `false` l'éteint, un objet la personnalise
51
+ * (`formatter`, `valueFormatter`, `sortDesc`) */
52
+ tooltip?: boolean | QChartTooltip
37
53
  /** Réaction des marques liées à la sélection : `filter` (défaut) **retire** les données non
38
54
  * liées, `dim` les **estompe** (`dimOpacity`) en gardant tout le contexte visible. */
39
55
  linkMode?: "filter" | "dim"
@@ -47,6 +63,18 @@ interface Props {
47
63
  * les éléments qui portent ce `name` sont mis en évidence (`highlight`) dans toutes les
48
64
  * séries. `null` efface la sélection. */
49
65
  selected?: QChartPick | null
66
+ /** État **chargement** : superpose un message (slot `loading`) */
67
+ loading?: boolean
68
+ /** État **erreur** : `true` (message localisé) ou un texte à afficher (slot `error`) */
69
+ error?: string | boolean
70
+ /** État **vide** forcé (sinon détecté : aucune donnée) — slot `empty` */
71
+ empty?: boolean
72
+ /** Textes des états et de la table (prioritaires sur l'i18n du provider) */
73
+ texts?: Partial<
74
+ Record<"loading" | "empty" | "error" | "exportCsv" | "total" | "page" | "sortAscending" | "sortDescending", string>
75
+ >
76
+ /** Marque `table` : bouton d'**export CSV** */
77
+ exportable?: boolean
50
78
  /** Options ECharts brutes, fusionnées en dernier (échappatoire) */
51
79
  options?: Record<string, any>
52
80
  }
@@ -76,6 +104,37 @@ const emit = defineEmits<{
76
104
  unpick: [pick: QChartPick]
77
105
  }>()
78
106
 
107
+ // ─── i18n des états et de la table ────────────────────────────────────────────
108
+ const I18N = {
109
+ en: {
110
+ loading: "Loading…",
111
+ empty: "No data",
112
+ error: "Unable to display the chart",
113
+ exportCsv: "Export CSV",
114
+ total: "Total",
115
+ page: "Page {n} / {m}",
116
+ sortAscending: "Sort ascending",
117
+ sortDescending: "Sort descending",
118
+ },
119
+ fr: {
120
+ loading: "Chargement…",
121
+ empty: "Aucune donnée",
122
+ error: "Impossible d'afficher le graphique",
123
+ exportCsv: "Exporter en CSV",
124
+ total: "Total",
125
+ page: "Page {n} / {m}",
126
+ sortAscending: "Tri croissant",
127
+ sortDescending: "Tri décroissant",
128
+ },
129
+ } as const
130
+ type UiText = keyof (typeof I18N)["en"]
131
+ const configLang = useConfigLang()
132
+ const t = (key: UiText): string => props.texts?.[key] ?? I18N[configLang.value === "fr" ? "fr" : "en"][key]
133
+ const fmtPage = (n: number, total: number) =>
134
+ t("page").replace("{n}", String(n)).replace("{m}", String(total))
135
+ /** Locale des nombres / dates : prop, sinon celle du provider (`fr` → `fr-FR`) */
136
+ const locale = computed(() => props.locale ?? (configLang.value === "fr" ? "fr-FR" : "en-US"))
137
+
79
138
  const el = ref<HTMLElement | null>(null)
80
139
  const chart = shallowRef<any>(null)
81
140
  let modulePromise: Promise<any> | null = null
@@ -85,11 +144,12 @@ let themeObserver: MutationObserver | undefined
85
144
  /** Charge ECharts (modules enregistrés une seule fois, arbre secoué). */
86
145
  const loadECharts = () => {
87
146
  modulePromise ??= (async () => {
88
- const [core, charts, components, renderers] = await Promise.all([
147
+ const [core, charts, components, renderers, features] = await Promise.all([
89
148
  import("echarts/core"),
90
149
  import("echarts/charts"),
91
150
  import("echarts/components"),
92
151
  import("echarts/renderers"),
152
+ import("echarts/features"),
93
153
  ])
94
154
  core.use([
95
155
  charts.BarChart,
@@ -97,13 +157,25 @@ const loadECharts = () => {
97
157
  charts.ScatterChart,
98
158
  charts.PieChart, // marque `pie` (camembert / anneau)
99
159
  charts.HeatmapChart, // marque `heatmap` (carte de chaleur)
160
+ charts.BoxplotChart, // marque `boxplot` (boîte à moustaches)
161
+ charts.GaugeChart, // marque `gauge` (jauge)
162
+ charts.FunnelChart, // marque `funnel` (entonnoir)
163
+ charts.RadarChart, // marque `radar` (multicritères)
164
+ charts.TreemapChart, // marque `treemap`
165
+ charts.SunburstChart, // marque `sunburst`
166
+ charts.SankeyChart, // marque `sankey` (flux)
100
167
  components.GridComponent,
168
+ components.RadarComponent, // coordonnée polaire de la marque `radar`
101
169
  components.TooltipComponent,
102
170
  components.LegendComponent,
103
171
  components.TitleComponent, // titre (`title` prop)
104
172
  components.MarkLineComponent,
105
- components.VisualMapComponent, // échelle de couleurs des cartes de chaleur
173
+ components.MarkAreaComponent, // marque `band` (zones de seuil)
174
+ components.VisualMapComponent, // échelles de couleurs (heatmap, `fill` numérique d'un `dot`)
106
175
  renderers.CanvasRenderer,
176
+ // ECharts 6 : `grid.containLabel` vit désormais dans un module de compatibilité —
177
+ // sans lui l'option est ignorée **silencieusement** (les libellés débordent du cadre).
178
+ features.LegacyGridContainLabel,
107
179
  ])
108
180
  return core
109
181
  })()
@@ -170,6 +242,9 @@ const build = () => {
170
242
  marks: props.marks,
171
243
  x: props.x,
172
244
  y: props.y,
245
+ yRight: props.yRight,
246
+ locale: locale.value,
247
+ compact: props.compact,
173
248
  colors: props.colors,
174
249
  title: props.title,
175
250
  legend: props.legend,
@@ -197,12 +272,88 @@ const linkChart = (group: string, instance: any, api: any) => {
197
272
  }
198
273
 
199
274
  const tableMark = computed(() => (props.marks ?? []).find((mark) => mark.type === "table"))
275
+
276
+ /** État de vue **interactif** de la table (tri, page) — prioritaire sur les options de la marque */
277
+ const sortState = ref<{ field: string; desc: boolean } | null>(null)
278
+ const pageState = ref(0)
279
+
200
280
  const table = computed(() =>
201
281
  tableMark.value
202
- ? tableModel(tableMark.value, props.selected ?? null, (props.linkMode as "filter" | "dim") ?? "filter")
282
+ ? tableModel(tableMark.value, props.selected ?? null, (props.linkMode as "filter" | "dim") ?? "filter", {
283
+ ...(sortState.value ? { sortBy: sortState.value.field, sortDesc: sortState.value.desc } : {}),
284
+ ...(pageState.value ? { page: pageState.value } : {}),
285
+ })
203
286
  : null,
204
287
  )
205
288
 
289
+ /** Style d'une colonne (largeur fixe, alignement) */
290
+ const columnStyle = (column: QChartColumn) => ({
291
+ textAlign: column.align,
292
+ ...(column.width !== undefined
293
+ ? { width: typeof column.width === "number" ? `${column.width}px` : column.width }
294
+ : {}),
295
+ })
296
+
297
+ /** Tri suivant : `asc` → `desc` → aucun (l'ordre d'origine revient) */
298
+ const toggleSort = (column: QChartColumn) => {
299
+ if (column.sortable === false) return
300
+ const base =
301
+ sortState.value ??
302
+ (tableMark.value?.sortBy
303
+ ? { field: tableMark.value.sortBy, desc: !!tableMark.value.sortDesc }
304
+ : null)
305
+ if (!base || base.field !== column.field) sortState.value = { field: column.field, desc: false }
306
+ else if (!base.desc) sortState.value = { field: column.field, desc: true }
307
+ else sortState.value = null
308
+ pageState.value = 0
309
+ }
310
+
311
+ const sortIndicator = (column: QChartColumn) => {
312
+ const current = sortState.value ?? (tableMark.value?.sortBy ? { field: tableMark.value.sortBy, desc: !!tableMark.value.sortDesc } : null)
313
+ if (!current || current.field !== column.field) return ""
314
+ return current.desc ? "\u2193" : "\u2191"
315
+ }
316
+
317
+ const sortTitle = (column: QChartColumn) => {
318
+ if (column.sortable === false) return ""
319
+ return sortIndicator(column) === "\u2191" ? t("sortDescending") : t("sortAscending")
320
+ }
321
+
322
+ const goPage = (delta: number) => {
323
+ const model = table.value
324
+ if (!model?.pages) return
325
+ pageState.value = Math.max(0, Math.min(model.pages - 1, (model.page ?? 0) + delta))
326
+ }
327
+
328
+ /** Export CSV de **toutes** les lignes visibles (pas seulement la page) */
329
+ const exportCsv = () => {
330
+ const model = table.value
331
+ if (!model || typeof window === "undefined" || typeof document === "undefined") return
332
+ const csv = tableCsv(model, { label: t("total") })
333
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" })
334
+ const url = URL.createObjectURL(blob)
335
+ const slug =
336
+ (props.title ?? "chart")
337
+ .toLowerCase()
338
+ .replace(/[^a-z0-9]+/g, "-")
339
+ .replace(/^-|-$/g, "") || "chart"
340
+ const anchor = document.createElement("a")
341
+ anchor.href = url
342
+ anchor.download = `${slug}.csv`
343
+ anchor.click()
344
+ URL.revokeObjectURL(url)
345
+ }
346
+
347
+ // Les données changent → le tri et la page repartent de zéro (sinon une page vide peut rester)
348
+ watch(
349
+ () => props.marks,
350
+ () => {
351
+ sortState.value = null
352
+ pageState.value = 0
353
+ },
354
+ { deep: true },
355
+ )
356
+
206
357
  /** Champ qui porte la clé partagée d'une ligne : le `localField` de `link`, sinon le premier
207
358
  * champ de la première colonne. C'est ce qui alimente `@pick`, la sélection et la mise en
208
359
  * évidence — une ligne se désigne par la même clé que les autres marques. */
@@ -247,16 +398,37 @@ const emitPick = (pick: QChartPick) => {
247
398
  const pickRow = (row: any, dataIndex: number) => {
248
399
  const markIndex = (props.marks ?? []).findIndex((mark) => mark.type === "table")
249
400
  const name = rowKey(row)
401
+ // Index **absolu** (toutes les lignes visibles), pas celui de la page courante
402
+ const all = table.value?.allRows
403
+ const absolute = all ? all.indexOf(row) : -1
250
404
  emitPick({
251
405
  ...(name !== undefined ? { name } : {}),
252
406
  markIndex,
253
407
  markType: "table",
254
- dataIndex,
408
+ dataIndex: absolute >= 0 ? absolute : dataIndex,
255
409
  origin: "mark",
256
410
  data: row,
257
411
  })
258
412
  }
259
413
 
414
+ /** Une donnée est-elle présente ? (marque `table` : lignes visibles ; sinon : données déclarées) */
415
+ const hasData = computed(() => {
416
+ const marks = props.marks ?? []
417
+ return marks.some((mark) => (mark.data?.length ?? 0) > 0)
418
+ })
419
+
420
+ /** État affiché en surimpression (`loading` > `error` > `empty`) */
421
+ const stateKind = computed<"loading" | "error" | "empty" | null>(() => {
422
+ if (props.loading) return "loading"
423
+ if (props.error) return "error"
424
+ if (props.empty ?? !hasData.value) return "empty"
425
+ return null
426
+ })
427
+
428
+ const errorMessage = computed(() =>
429
+ typeof props.error === "string" ? props.error : t("error"),
430
+ )
431
+
260
432
  const render = async () => {
261
433
  // Une marque `table` remplace le tracé : on libère l'instance si elle existait
262
434
  if (tableMark.value) {
@@ -348,6 +520,9 @@ watch(
348
520
  props.selected,
349
521
  props.x,
350
522
  props.y,
523
+ props.yRight,
524
+ props.locale,
525
+ props.compact,
351
526
  props.title,
352
527
  props.colors,
353
528
  props.legend,
@@ -414,7 +589,7 @@ defineExpose({
414
589
  </script>
415
590
 
416
591
  <template>
417
- <!-- Marque `table` : les données en HTML (sélection, jointure et `@pick` comme le reste) -->
592
+ <!-- Marque `table` : les données en HTML (sélection, jointure, tri, pagination et `@pick` comme le reste) -->
418
593
  <div
419
594
  v-if="table"
420
595
  class="q-chart q-chart--table"
@@ -422,11 +597,25 @@ defineExpose({
422
597
  role="table"
423
598
  :aria-label="title || 'Table'"
424
599
  >
600
+ <div v-if="exportable" class="q-chart__table-bar">
601
+ <button type="button" class="q-chart__table-action" @click="exportCsv">
602
+ {{ t("exportCsv") }}
603
+ </button>
604
+ </div>
425
605
  <table :class="`is-${table.separator}`">
426
606
  <thead>
427
607
  <tr>
428
- <th v-for="column in table.columns" :key="column.field" :style="{ textAlign: column.align }">
608
+ <th
609
+ v-for="column in table.columns"
610
+ :key="column.field"
611
+ :style="columnStyle(column)"
612
+ :title="sortTitle(column)"
613
+ :class="{ 'is-sortable': column.sortable !== false }"
614
+ :aria-sort="sortIndicator(column) === '↑' ? 'ascending' : sortIndicator(column) === '↓' ? 'descending' : 'none'"
615
+ @click="toggleSort(column)"
616
+ >
429
617
  {{ column.label }}
618
+ <span v-if="sortIndicator(column)" class="q-chart__sort">{{ sortIndicator(column) }}</span>
430
619
  </th>
431
620
  </tr>
432
621
  </thead>
@@ -438,23 +627,59 @@ defineExpose({
438
627
  :style="isDimmed(row) ? { opacity: dimOpacity } : undefined"
439
628
  @click="pickRow(row, index)"
440
629
  >
441
- <td v-for="column in table.columns" :key="column.field" :style="{ textAlign: column.align }">
630
+ <td v-for="column in table.columns" :key="column.field" :style="columnStyle(column)">
442
631
  {{ cellValue(column, row) }}
443
632
  </td>
444
633
  </tr>
445
634
  </tbody>
635
+ <tfoot v-if="table.totals">
636
+ <tr>
637
+ <td v-for="column in table.columns" :key="column.field" :style="columnStyle(column)">
638
+ {{ table.totals[column.field] ?? "" }}
639
+ </td>
640
+ </tr>
641
+ </tfoot>
446
642
  </table>
447
- <p v-if="table.rows.length === 0" class="q-chart__empty">No rows</p>
643
+ <p v-if="table.rows.length === 0" class="q-chart__empty">{{ t("empty") }}</p>
644
+ <div v-if="table.pages && table.pages > 1" class="q-chart__pager">
645
+ <button
646
+ type="button"
647
+ class="q-chart__table-action"
648
+ :disabled="(table.page ?? 0) <= 0"
649
+ @click="goPage(-1)"
650
+ >
651
+ ‹
652
+ </button>
653
+ <span class="q-chart__pager-label">{{ fmtPage((table.page ?? 0) + 1, table.pages) }}</span>
654
+ <button
655
+ type="button"
656
+ class="q-chart__table-action"
657
+ :disabled="(table.page ?? 0) >= table.pages - 1"
658
+ @click="goPage(1)"
659
+ >
660
+ ›
661
+ </button>
662
+ </div>
448
663
  </div>
449
664
 
450
665
  <div
451
666
  v-else
452
- ref="el"
453
- class="q-chart"
667
+ class="q-chart q-chart--plot"
454
668
  :style="{ height: typeof height === 'number' ? `${height}px` : height }"
455
- role="img"
456
- :aria-label="title || 'Chart'"
457
- ></div>
669
+ >
670
+ <div
671
+ ref="el"
672
+ class="q-chart__canvas"
673
+ role="img"
674
+ :aria-label="title || 'Chart'"
675
+ :aria-busy="stateKind === 'loading' || undefined"
676
+ ></div>
677
+ <div v-if="stateKind" class="q-chart__state" :class="`q-chart__state--${stateKind}`" role="status">
678
+ <slot v-if="stateKind === 'loading'" name="loading">{{ t("loading") }}</slot>
679
+ <slot v-else-if="stateKind === 'error'" name="error">{{ errorMessage }}</slot>
680
+ <slot v-else name="empty">{{ t("empty") }}</slot>
681
+ </div>
682
+ </div>
458
683
  </template>
459
684
 
460
685
  <style scoped>
@@ -531,4 +756,89 @@ defineExpose({
531
756
  .q-chart {
532
757
  width: 100%;
533
758
  }
759
+
760
+ /* ─── Tracé (canvas + surimpression d'état) ─── */
761
+ .q-chart--plot {
762
+ position: relative;
763
+ width: 100%;
764
+ }
765
+
766
+ .q-chart__canvas {
767
+ width: 100%;
768
+ height: 100%;
769
+ }
770
+
771
+ /* États `loading` / `empty` / `error` : une surimpression lisible sur le thème hôte */
772
+ .q-chart__state {
773
+ position: absolute;
774
+ inset: 0;
775
+ display: flex;
776
+ align-items: center;
777
+ justify-content: center;
778
+ padding: 12px;
779
+ font-size: 13px;
780
+ text-align: center;
781
+ color: var(--muted-foreground, #71717a);
782
+ background: color-mix(in srgb, var(--card, #ffffff) 76%, transparent);
783
+ backdrop-filter: blur(2px);
784
+ -webkit-backdrop-filter: blur(2px);
785
+ }
786
+
787
+ .q-chart__state--error {
788
+ color: var(--negative, #c10015);
789
+ }
790
+
791
+ /* ─── Table BI (barre d'outils, tri, totaux, pagination) ─── */
792
+ .q-chart__table-bar {
793
+ display: flex;
794
+ justify-content: flex-end;
795
+ padding: 4px 10px;
796
+ }
797
+
798
+ .q-chart__table-action {
799
+ padding: 2px 8px;
800
+ font: inherit;
801
+ font-size: 12px;
802
+ color: var(--muted-foreground, #71717a);
803
+ cursor: pointer;
804
+ background: transparent;
805
+ border: 1px solid var(--border, #e4e4e7);
806
+ border-radius: 4px;
807
+ }
808
+
809
+ .q-chart__table-action:hover:not(:disabled) {
810
+ color: var(--foreground, #18181b);
811
+ background: var(--accent, #f4f4f5);
812
+ }
813
+
814
+ .q-chart__table-action:disabled {
815
+ cursor: default;
816
+ opacity: 0.45;
817
+ }
818
+
819
+ .q-chart--table th.is-sortable {
820
+ cursor: pointer;
821
+ user-select: none;
822
+ }
823
+
824
+ .q-chart__sort {
825
+ margin-left: 4px;
826
+ font-size: 10px;
827
+ opacity: 0.75;
828
+ }
829
+
830
+ .q-chart--table tfoot td {
831
+ font-weight: 600;
832
+ border-top: 1px solid var(--border, #e4e4e7);
833
+ }
834
+
835
+ .q-chart__pager {
836
+ display: flex;
837
+ align-items: center;
838
+ justify-content: flex-end;
839
+ gap: 8px;
840
+ padding: 6px 10px;
841
+ font-size: 12px;
842
+ color: var(--muted-foreground, #71717a);
843
+ }
534
844
  </style>
@@ -198,6 +198,7 @@ import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
198
198
  import { normalizeCellOptions } from "../lib/spreadsheetOptions"
199
199
  import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
200
200
  import { canAddRows, canRemoveRows, clampRows } from "../lib/spreadsheetRows"
201
+ import { createHistory } from "../lib/history"
201
202
  import {
202
203
  DEFAULT_ROW_KEY,
203
204
  diffDocuments,
@@ -1148,12 +1149,14 @@ const clearCell = (row: number, column: string) => {
1148
1149
  if (isCellLocked(row, column)) return
1149
1150
  const old = state.value[row]?.[column]
1150
1151
  if (isBlankValue(old) || (Array.isArray(old) && old.length === 0)) return
1152
+ pushHistory()
1151
1153
  setCellValue(row, column, old, null)
1152
1154
  }
1153
1155
 
1154
1156
  const toggleBoolean = (row: number, column: string) => {
1155
1157
  if (isCellLocked(row, column)) return
1156
1158
  const old = !!state.value[row]?.[column]
1159
+ pushHistory()
1157
1160
  setCellValue(row, column, old, !old)
1158
1161
  }
1159
1162
 
@@ -1258,9 +1261,7 @@ interface Snapshot {
1258
1261
  merges: MergeRange[]
1259
1262
  rules: CondRule[]
1260
1263
  }
1261
- const undoStack: Snapshot[] = []
1262
- const redoStack: Snapshot[] = []
1263
-
1264
+ const history = createHistory<Snapshot>(100)
1264
1265
  const takeSnapshot = (): Snapshot => ({
1265
1266
  rows: state.value.map((r) => ({ ...r })),
1266
1267
  cols: cols.value.map((c) => ({ ...c })),
@@ -1273,13 +1274,9 @@ const takeSnapshot = (): Snapshot => ({
1273
1274
  merges: merges.value.map((m) => ({ ...m })),
1274
1275
  rules: condRules.value.map((r) => ({ ...r })),
1275
1276
  })
1276
- const pushHistory = () => {
1277
- undoStack.push(takeSnapshot())
1278
- if (undoStack.length > 100) undoStack.shift()
1279
- redoStack.length = 0
1280
- }
1281
- const canUndo = computed(() => undoStack.length > 0 && !props.readonly && !props.disable)
1282
- const canRedo = computed(() => redoStack.length > 0 && !props.readonly && !props.disable)
1277
+ const pushHistory = () => history.push(takeSnapshot())
1278
+ const canUndo = computed(() => history.canUndo.value && !props.readonly && !props.disable)
1279
+ const canRedo = computed(() => history.canRedo.value && !props.readonly && !props.disable)
1283
1280
 
1284
1281
  const restoreSnapshot = (s: Snapshot) => {
1285
1282
  cols.value = s.cols.map((c) => ({ ...c }))
@@ -1294,17 +1291,35 @@ const restoreSnapshot = (s: Snapshot) => {
1294
1291
  condRules.value = s.rules.map((r) => ({ ...r }))
1295
1292
  emit("update:columns", cols.value)
1296
1293
  pushRows(state.value)
1294
+ // La sélection peut pointer sur une ligne/colonne qui n'existe plus (redo d'une
1295
+ // suppression) : on la ramène sur la première cellule plutôt que de laisser un
1296
+ // curseur fantôme hors grille.
1297
+ const current = sel.value
1298
+ if (current) {
1299
+ const maxRow = state.value.length - 1
1300
+ const bad =
1301
+ colIndex(current.column) === -1 ||
1302
+ (current.endColumn !== undefined && colIndex(current.endColumn) === -1) ||
1303
+ current.row > maxRow ||
1304
+ (current.endRow !== undefined && current.endRow > maxRow)
1305
+ if (bad) {
1306
+ if (state.value.length && cols.value.length) select(0, cols.value[0]!.name)
1307
+ else setSel(null)
1308
+ }
1309
+ }
1297
1310
  }
1298
1311
  const undo = () => {
1299
1312
  if (!canUndo.value) return
1300
- redoStack.push(takeSnapshot())
1301
- restoreSnapshot(undoStack.pop()!)
1313
+ const snapshot = history.undo(takeSnapshot())
1314
+ if (!snapshot) return
1315
+ restoreSnapshot(snapshot)
1302
1316
  emit("structure-change", { rows: state.value, columns: cols.value, reason: "undo" })
1303
1317
  }
1304
1318
  const redo = () => {
1305
1319
  if (!canRedo.value) return
1306
- undoStack.push(takeSnapshot())
1307
- restoreSnapshot(redoStack.pop()!)
1320
+ const snapshot = history.redo(takeSnapshot())
1321
+ if (!snapshot) return
1322
+ restoreSnapshot(snapshot)
1308
1323
  emit("structure-change", { rows: state.value, columns: cols.value, reason: "redo" })
1309
1324
  }
1310
1325
 
@@ -3353,8 +3368,7 @@ const loadSheetIntoEngine = (idx: number) => {
3353
3368
  colWidths.value = { ...ext.widths }
3354
3369
  setSel(null)
3355
3370
  editing.value = null
3356
- undoStack.length = 0
3357
- redoStack.length = 0
3371
+ history.clear()
3358
3372
  if (scrollEl.value) scrollEl.value.scrollTop = 0
3359
3373
  }
3360
3374
  /**
@@ -3766,7 +3780,10 @@ const findReplaceCurrent = () => {
3766
3780
  const next = findCase.value
3767
3781
  ? raw.split(findQuery.value).join(findReplaceText.value)
3768
3782
  : raw.replace(new RegExp(escapeRegExp(findQuery.value), "gi"), findReplaceText.value)
3769
- if (next !== raw) setCellValue(m.r, m.name, raw, next)
3783
+ if (next !== raw) {
3784
+ pushHistory()
3785
+ setCellValue(m.r, m.name, raw, next)
3786
+ }
3770
3787
  findScan()
3771
3788
  findNext(1)
3772
3789
  }
@@ -4306,6 +4323,7 @@ const validateAndSet = async (r: number, name: string, old: any, next: any): Pro
4306
4323
  return false
4307
4324
  }
4308
4325
  clearValError(r, name)
4326
+ pushHistory()
4309
4327
  setCellValue(r, name, old, next)
4310
4328
  return true
4311
4329
  }
package/index.ts CHANGED
@@ -203,19 +203,29 @@ export { DEFAULT_LAYOUT_VIEW, parseView, qLayoutKey } from "./lib/layout"
203
203
  export type { LayoutView, LayoutZone, LayoutZonePlacement, QLayoutContext } from "./lib/layout"
204
204
  export { vRipple } from "./lib/ripple"
205
205
  export type { RippleOptions, RippleValue } from "./lib/ripple"
206
- export { chartToECharts, channelValue, pickFromEvent, tableModel, textValue } from "./lib/chart"
206
+ export { chartToECharts, channelValue, pickFromEvent, tableCsv, tableModel, textValue } from "./lib/chart"
207
207
  export type {
208
208
  QChartAxis,
209
209
  QChartChannel,
210
210
  QChartColumn,
211
211
  QChartConfig,
212
+ QChartFormat,
213
+ QChartFormatKind,
214
+ QChartFormatOptions,
215
+ QChartGaugeBand,
212
216
  QChartLegend,
213
217
  QChartMark,
214
218
  QChartMarkType,
215
219
  QChartOrientation,
216
220
  QChartTable,
221
+ QChartTableTotals,
222
+ QChartTableView,
223
+ QChartThreshold,
224
+ QChartTooltip,
217
225
  QChartPick,
218
226
  } from "./lib/chart"
227
+ export { DEFAULT_CURRENCY, DEFAULT_LOCALE, formatValue } from "./lib/chartFormat"
228
+ export { boxStats, histogram, linearFit, waterfallSteps } from "./lib/chartStats"
219
229
  export { vTouchPan } from "./lib/touchPan"
220
230
  export type { PanDetails, TouchPanHandler, PanDirection } from "./lib/touchPan"
221
231
  export { vTouchHold } from "./lib/touchHold"
@@ -28,7 +28,7 @@ function renderSvg(config: Record<string, any>, width: number, height: number):
28
28
  width,
29
29
  height,
30
30
  })
31
- chart.setOption(chartToECharts({ theme: THEME, ...config }))
31
+ chart.setOption(chartToECharts({ marks: [], theme: THEME, ...config }))
32
32
  const svg = chart.renderToSVGString()
33
33
  chart.dispose()
34
34
  return svg