@dnax/ui 0.0.66 → 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.
@@ -0,0 +1,222 @@
1
+ // Formatage des valeurs de `<q-chart>` (axes, info-bulles, étiquettes, tableaux).
2
+ //
3
+ // Le traducteur (`chartToECharts`) reste **pur** : ce module ne lit ni le DOM ni la locale
4
+ // du navigateur. La locale vient de la prop `locale` (défaut : `fr-FR`, cohérent avec le
5
+ // reste du design system), éventuellement surchargée par un format.
6
+ //
7
+ // Rien n'est formaté tant que l'appelant ne demande rien : sans `format`, une valeur sort
8
+ // telle quelle (`String(value)`), donc aucun changement de comportement rétroactif.
9
+
10
+ /** Format « court » : une chaîne suffit (`format: "compact"`) */
11
+ export type QChartFormatKind =
12
+ | "number"
13
+ | "decimal"
14
+ | "percent"
15
+ | "compact"
16
+ | "currency"
17
+ | "date"
18
+ | "time"
19
+ | "datetime"
20
+
21
+ /** Contexte passé aux formateurs (fonction ou objet) */
22
+ export interface QChartFormatContext {
23
+ /** Locale de repli (celle du graphique) */
24
+ locale?: string
25
+ /** Index de la donnée dans sa marque */
26
+ index?: number
27
+ /** Ligne d'origine */
28
+ row?: any
29
+ /** Nom de la série / de la catégorie, si connu */
30
+ name?: string
31
+ }
32
+
33
+ /** Format complet (objet) */
34
+ export interface QChartFormatOptions {
35
+ /** Famille de formatage */
36
+ style?: QChartFormatKind
37
+ /** Locale BCP 47 (`fr-FR`, `en-US`…) — prioritaire sur celle du graphique */
38
+ locale?: string
39
+ /** Devise ISO 4217 pour `currency` (`XOF` par défaut → « F CFA ») */
40
+ currency?: string
41
+ /** Fuseau horaire IANA (`Africa/Dakar`…) pour les formats de date */
42
+ timeZone?: string
43
+ minimumFractionDigits?: number
44
+ maximumFractionDigits?: number
45
+ /** `date` / `datetime` : style de la partie date */
46
+ dateStyle?: "full" | "long" | "medium" | "short"
47
+ /** `time` / `datetime` : style de la partie heure */
48
+ timeStyle?: "full" | "long" | "medium" | "short"
49
+ /** Texte ajouté avant / après la valeur formatée */
50
+ prefix?: string
51
+ suffix?: string
52
+ }
53
+
54
+ /** Un format : chaîne courte, objet d'options, ou fonction `(value, ctx) => string` */
55
+ export type QChartFormat =
56
+ | QChartFormatKind
57
+ | QChartFormatOptions
58
+ | ((value: any, ctx?: QChartFormatContext) => string)
59
+
60
+ /** Locale par défaut de tous les formats (l'application peut la changer par prop) */
61
+ export const DEFAULT_LOCALE = "fr-FR"
62
+
63
+ /** Devise par défaut : franc CFA (lisible en `fr-FR` : « 1 234 F CFA ») */
64
+ export const DEFAULT_CURRENCY = "XOF"
65
+
66
+ const isObject = (v: any): v is QChartFormatOptions => v !== null && typeof v === "object"
67
+
68
+ /** Normalise un format en options exploitables (`undefined` si aucun format demandé) */
69
+ export function normalizeFormat(
70
+ format: QChartFormat | undefined,
71
+ ): QChartFormatOptions | undefined {
72
+ if (format === undefined || format === null) return undefined
73
+ if (typeof format === "string") return { style: format }
74
+ if (typeof format === "function") return undefined
75
+ if (isObject(format)) return format
76
+ return undefined
77
+ }
78
+
79
+ /** Nombre fini ? (`null`, `""`, `NaN` sont écartés) */
80
+ const finite = (v: any): v is number => typeof v === "number" && Number.isFinite(v)
81
+
82
+ /** `value` → `Date` (nombre = timestamp, chaîne = ISO / date lisible) */
83
+ function toDate(value: any): Date | undefined {
84
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value
85
+ if (finite(value)) {
86
+ // Un timestamp en **secondes** (API usuelles) est ramené en millisecondes.
87
+ const ms = Math.abs(value) < 1e11 ? value * 1000 : value
88
+ const d = new Date(ms)
89
+ return Number.isNaN(d.getTime()) ? undefined : d
90
+ }
91
+ if (typeof value === "string" && value.trim() !== "") {
92
+ const d = new Date(value)
93
+ return Number.isNaN(d.getTime()) ? undefined : d
94
+ }
95
+ return undefined
96
+ }
97
+
98
+ const dateFormatted = (value: any, options: QChartFormatOptions, locale: string): string | undefined => {
99
+ const date = toDate(value)
100
+ if (!date) return undefined
101
+ const style = options.style
102
+ const opts: Intl.DateTimeFormatOptions = { timeZone: options.timeZone }
103
+ if (style === "date" || style === "datetime") {
104
+ opts.dateStyle = options.dateStyle ?? (style === "datetime" ? "short" : "medium")
105
+ }
106
+ if (style === "time" || style === "datetime") {
107
+ opts.timeStyle = options.timeStyle ?? "short"
108
+ }
109
+ return new Intl.DateTimeFormat(locale, opts).format(date)
110
+ }
111
+
112
+ const numberFormatted = (value: any, options: QChartFormatOptions, locale: string): string | undefined => {
113
+ const style = options.style ?? "number"
114
+ if (typeof value === "boolean") return String(value)
115
+ if (!finite(value)) return undefined
116
+ const digits: Intl.NumberFormatOptions = {}
117
+ if (options.minimumFractionDigits !== undefined) digits.minimumFractionDigits = options.minimumFractionDigits
118
+ if (options.maximumFractionDigits !== undefined) digits.maximumFractionDigits = options.maximumFractionDigits
119
+ if (style === "compact") {
120
+ return new Intl.NumberFormat(locale, {
121
+ notation: "compact",
122
+ compactDisplay: "short",
123
+ maximumFractionDigits: options.maximumFractionDigits ?? 1,
124
+ }).format(value)
125
+ }
126
+ if (style === "currency") {
127
+ return new Intl.NumberFormat(locale, {
128
+ style: "currency",
129
+ currency: options.currency ?? DEFAULT_CURRENCY,
130
+ ...digits,
131
+ }).format(value)
132
+ }
133
+ if (style === "percent") {
134
+ // La valeur est **déjà en pourcentage** (42 → « 42 % ») : `Intl` multiplie par 100,
135
+ // on divise donc avant (et non l'inverse, qui donnerait 4200 %).
136
+ return new Intl.NumberFormat(locale, {
137
+ style: "percent",
138
+ maximumFractionDigits: options.maximumFractionDigits ?? 1,
139
+ ...digits,
140
+ }).format(value / 100)
141
+ }
142
+ // `number` / `decimal` / style absent : nombre localisé
143
+ return new Intl.NumberFormat(locale, {
144
+ maximumFractionDigits: options.maximumFractionDigits ?? 2,
145
+ ...digits,
146
+ }).format(value)
147
+ }
148
+
149
+ /** Formate une valeur selon `format`.
150
+ *
151
+ * - `undefined` / `null` → chaîne vide ;
152
+ * - sans `format` → `String(value)` (aucun formatage implicite) ;
153
+ * - `percent` attend une valeur **en pourcentage** (`42` → « 42 % ») ;
154
+ * - `currency` utilise `XOF` (F CFA) par défaut ;
155
+ * - une date peut être un `Date`, un timestamp (s/ms) ou une chaîne ISO. */
156
+ export function formatValue(
157
+ value: any,
158
+ format?: QChartFormat,
159
+ ctx: QChartFormatContext = {},
160
+ ): string {
161
+ if (typeof format === "function") {
162
+ const out = format(value, ctx)
163
+ return out === undefined || out === null ? "" : String(out)
164
+ }
165
+ if (value === undefined || value === null || value === "") return ""
166
+ const options = normalizeFormat(format)
167
+ if (!options) return String(value)
168
+ const locale = options.locale ?? ctx.locale ?? DEFAULT_LOCALE
169
+ const temporal =
170
+ options.style === "date" || options.style === "time" || options.style === "datetime"
171
+ const text = temporal
172
+ ? (dateFormatted(value, options, locale) ?? String(value))
173
+ : (numberFormatted(value, options, locale) ?? String(value))
174
+ return `${options.prefix ?? ""}${text}${options.suffix ?? ""}`
175
+ }
176
+
177
+ /** Un format de pourcentage sans `%` (pour composer soi-même un libellé) */
178
+ export function formatPercent(value: any, format?: QChartFormat, ctx?: QChartFormatContext): string {
179
+ const options = normalizeFormat(format)
180
+ const merged: QChartFormat = { ...(options ?? {}), style: "percent" }
181
+ return formatValue(value, merged, ctx)
182
+ }
183
+
184
+
185
+ /** Formateur ECharts pour un axe (`axisLabel.formatter`), ou `undefined` si rien n'est demandé. */
186
+ export function axisFormatter(
187
+ format: QChartFormat | undefined,
188
+ ctx: QChartFormatContext = {},
189
+ ): ((value: any) => string) | undefined {
190
+ if (format === undefined || format === null) return undefined
191
+ return (value: any) => formatValue(value, format, ctx)
192
+ }
193
+
194
+ /** Graduation d'un axe **temporel** selon sa granularité.
195
+ * `auto` (défaut) laisse le moteur choisir : aucune fonction n'est renvoyée. */
196
+ export function timeTickFormatter(
197
+ unit: "auto" | "day" | "week" | "month" | "quarter" | "year" | undefined,
198
+ locale = DEFAULT_LOCALE,
199
+ timeZone?: string,
200
+ ): ((value: any) => string) | undefined {
201
+ if (!unit || unit === "auto") return undefined
202
+ return (value: any) => {
203
+ const date = toDate(value)
204
+ if (!date) return String(value ?? "")
205
+ if (unit === "quarter") {
206
+ const quarter = Math.floor(date.getUTCMonth() / 3) + 1
207
+ const year = String(date.getUTCFullYear()).slice(2)
208
+ return `T${quarter} ${year}`
209
+ }
210
+ const opts: Intl.DateTimeFormatOptions = { timeZone }
211
+ if (unit === "day" || unit === "week") {
212
+ opts.day = "numeric"
213
+ opts.month = "short"
214
+ } else if (unit === "month") {
215
+ opts.month = "short"
216
+ opts.year = "2-digit"
217
+ } else {
218
+ opts.year = "numeric"
219
+ }
220
+ return new Intl.DateTimeFormat(locale, opts).format(date)
221
+ }
222
+ }
@@ -0,0 +1,178 @@
1
+ // Calculs statistiques du traducteur de graphiques — **purs**, testables sans DOM.
2
+ //
3
+ // Utilisés par les marques `histogram`, `boxplot` et `waterfall` (P1 de `QChart`).
4
+ // Aucune notion de couleur ni de rendu : uniquement des nombres, pour que les tests
5
+ // portent sur la sémantique (tranches, quartiles, cumul) et non sur ECharts.
6
+
7
+ /** Une tranche d'histogramme */
8
+ export interface QChartBin {
9
+ /** Borne basse (incluse) */
10
+ x0: number
11
+ /** Borne haute (exclue, sauf la dernière tranche) */
12
+ x1: number
13
+ /** Nombre de valeurs dans la tranche */
14
+ count: number
15
+ /** Libellé par défaut (`x0 – x1`) — l'appelant peut le reformater */
16
+ label: string
17
+ }
18
+
19
+ /** Arrondit un nombre de borne pour les libellés (évite `0.30000000000000004`) */
20
+ const clean = (v: number) => Number(v.toPrecision(12))
21
+
22
+ /** Histogramme : répartit des valeurs numériques en `bins` tranches.
23
+ *
24
+ * - `bins` par défaut : `ceil(sqrt(n))` (règle de la racine), borné à `[1, 40]` ;
25
+ * - `domain` force les bornes ; sinon `[min, max]` des valeurs ;
26
+ * - une valeur `null` / non finie est ignorée ;
27
+ * - la borne haute de la **dernière** tranche est inclusive (la valeur maximale compte). */
28
+ export function histogram(
29
+ values: readonly any[],
30
+ options: { bins?: number; domain?: [number, number] } = {},
31
+ ): QChartBin[] {
32
+ const nums = values.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
33
+ if (nums.length === 0) return []
34
+ let lo = options.domain?.[0] ?? Math.min(...nums)
35
+ let hi = options.domain?.[1] ?? Math.max(...nums)
36
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return []
37
+ if (hi < lo) [lo, hi] = [hi, lo]
38
+ const requested = Math.max(1, Math.min(40, Math.round(options.bins ?? Math.ceil(Math.sqrt(nums.length)))))
39
+ // Domaine dégénéré (une seule valeur) : une tranche autour de la valeur
40
+ if (hi === lo) {
41
+ return [{ x0: lo, x1: hi, count: nums.length, label: `${clean(lo)}` }]
42
+ }
43
+ // Le nombre de tranches demandé est **respecté** : pas d'arrondi « joli » qui le changerait.
44
+ const step = (hi - lo) / requested
45
+ const bins: QChartBin[] = Array.from({ length: requested }, (_, i) => ({
46
+ x0: clean(lo + i * step),
47
+ x1: clean(lo + (i + 1) * step),
48
+ count: 0,
49
+ label: "",
50
+ }))
51
+ for (const v of nums) {
52
+ // La valeur maximale tombe dans la dernière tranche (borne haute inclusive)
53
+ let i = Math.floor((v - lo) / step)
54
+ if (i < 0) i = 0
55
+ if (i >= requested) i = requested - 1
56
+ const bin = bins[i]
57
+ if (bin) bin.count++
58
+ }
59
+ for (const bin of bins) bin.label = `${clean(bin.x0)} – ${clean(bin.x1)}`
60
+ return bins
61
+ }
62
+
63
+ /** Quantile (interpolation linéaire, méthode R-7 — celle d'Excel `QUARTILE.INC`) */
64
+ export function quantile(sorted: readonly number[], p: number): number {
65
+ if (sorted.length === 0) return Number.NaN
66
+ const pos = (sorted.length - 1) * Math.max(0, Math.min(1, p))
67
+ const lower = Math.floor(pos)
68
+ const upper = Math.ceil(pos)
69
+ if (lower === upper) return sorted[lower]!
70
+ return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (pos - lower)
71
+ }
72
+
73
+ /** Boîte à moustaches (cinq nombres) */
74
+ export interface QChartBox {
75
+ min: number
76
+ q1: number
77
+ median: number
78
+ q3: number
79
+ max: number
80
+ }
81
+
82
+ /** Statistiques d'une série de valeurs (`undefined` si aucune valeur numérique) */
83
+ export function boxStats(values: readonly any[]): QChartBox | undefined {
84
+ const nums = values
85
+ .filter((v): v is number => typeof v === "number" && Number.isFinite(v))
86
+ .slice()
87
+ .sort((a, b) => a - b)
88
+ if (nums.length === 0) return undefined
89
+ return {
90
+ min: nums[0]!,
91
+ q1: quantile(nums, 0.25),
92
+ median: quantile(nums, 0.5),
93
+ q3: quantile(nums, 0.75),
94
+ max: nums[nums.length - 1]!,
95
+ }
96
+ }
97
+
98
+ /** Ajustement affine par moindres carrés — `undefined` si les abscisses sont toutes égales */
99
+ export function linearFit(
100
+ points: readonly (readonly [number, number])[],
101
+ ): { slope: number; intercept: number } | undefined {
102
+ const pts = points.filter(
103
+ ([x, y]) => typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y),
104
+ )
105
+ if (pts.length < 2) return undefined
106
+ const n = pts.length
107
+ const sumX = pts.reduce((s, [x]) => s + x, 0)
108
+ const sumY = pts.reduce((s, [, y]) => s + y, 0)
109
+ const meanX = sumX / n
110
+ const meanY = sumY / n
111
+ let num = 0
112
+ let den = 0
113
+ for (const [x, y] of pts) {
114
+ num += (x - meanX) * (y - meanY)
115
+ den += (x - meanX) ** 2
116
+ }
117
+ if (den === 0) return undefined
118
+ const slope = num / den
119
+ return { slope, intercept: meanY - slope * meanX }
120
+ }
121
+
122
+ /** Étape d'un graphique en cascade (waterfall) */
123
+ export interface QChartWaterfallStep {
124
+ /** Valeur de départ de la barre (le cumul précédent) */
125
+ base: number
126
+ /** Hauteur signée de la barre (le delta) */
127
+ delta: number
128
+ /** Cumul après l'étape */
129
+ total: number
130
+ /** Ligne de **total** (barre posée depuis zéro) ? */
131
+ totalRow: boolean
132
+ }
133
+
134
+ /** Décompose des deltas en barres flottantes cumulées.
135
+ *
136
+ * `flags[i] === true` marque une ligne de **total** : la barre part de zéro et sa hauteur
137
+ * est le cumul courant (elle ne s'ajoute pas une seconde fois au cumul). */
138
+ export function waterfallSteps(
139
+ values: readonly any[],
140
+ flags: readonly any[] = [],
141
+ ): QChartWaterfallStep[] {
142
+ const steps: QChartWaterfallStep[] = []
143
+ let running = 0
144
+ for (let i = 0; i < values.length; i++) {
145
+ const raw = values[i]
146
+ const value = typeof raw === "number" && Number.isFinite(raw) ? raw : 0
147
+ const totalRow = flags[i] === true || flags[i] === "total"
148
+ if (totalRow) {
149
+ steps.push({ base: 0, delta: running, total: running, totalRow: true })
150
+ continue
151
+ }
152
+ steps.push({ base: running, delta: value, total: running + value, totalRow: false })
153
+ running += value
154
+ }
155
+ return steps
156
+ }
157
+
158
+ /** Répartition en pourcentage (somme des valeurs absolues, `0` évité).
159
+ * Une valeur nulle ou non finie vaut `0` : la part reste calculable. */
160
+ export function percentShares(values: readonly any[]): number[] {
161
+ const nums = values.map((v) => (typeof v === "number" && Number.isFinite(v) ? v : 0))
162
+ const total = nums.reduce((s, v) => s + Math.abs(v), 0)
163
+ return total === 0 ? nums.map(() => 0) : nums.map((v) => (Math.abs(v) / total) * 100)
164
+ }
165
+
166
+ /** Somme par catégorie — sert au calcul des **empilements 100 %**. */
167
+ export function categoryTotals(
168
+ entries: readonly { category: any; value: any }[],
169
+ ): Map<string, number> {
170
+ const totals = new Map<string, number>()
171
+ for (const { category, value } of entries) {
172
+ if (category === undefined || category === null) continue
173
+ if (typeof value !== "number" || !Number.isFinite(value)) continue
174
+ const key = String(category)
175
+ totals.set(key, (totals.get(key) ?? 0) + value)
176
+ }
177
+ return totals
178
+ }
@@ -184,6 +184,14 @@ describe("fonctions dates", () => {
184
184
  })
185
185
  })
186
186
 
187
+ describe("espaces autour de la formule", () => {
188
+ const e = harness([[]])
189
+ it("accepte des espaces avant le `=` (isFormulaRaw les tolère)", () => {
190
+ expect(e(" =1+2")).toBe(3)
191
+ expect(e(" =SUM(1,2) ")).toBe(3)
192
+ })
193
+ })
194
+
187
195
  describe("math & erreurs de type", () => {
188
196
  const e = harness([[]])
189
197
  it("ABS / ROUND / INT / MOD / SQRT / POWER", () => {
package/lib/formula.ts CHANGED
@@ -1349,9 +1349,11 @@ export const FORMULA_FUNCTION_NAMES: string[] = [
1349
1349
  ]),
1350
1350
  ].sort()
1351
1351
 
1352
- /** Évalue une formule ("=…" optionnel). Retourne une valeur ou une FormulaError. */
1352
+ /** Évalue une formule ("=…" optionnel, espaces ignorés). Retourne une valeur ou une FormulaError. */
1353
1353
  export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
1354
- const src = source.startsWith("=") ? source.slice(1) : source
1354
+ // `isFormulaRaw` accepte des espaces avant le `=` : le moteur doit en faire autant.
1355
+ const trimmed = source.trim()
1356
+ const src = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed
1355
1357
  try {
1356
1358
  const parser = new Parser(src)
1357
1359
  const node = parser.parseExpr()
@@ -0,0 +1,78 @@
1
+ // Historique undo/redo — le point critique est la **réactivité** : des piles en tableau
2
+ // simple laissent `canUndo` figé à `false` (boutons inertes, Ctrl+Z sans effet).
3
+ import { describe, expect, it } from "bun:test"
4
+ import { effect, nextTick } from "vue"
5
+ import { createHistory } from "./history"
6
+
7
+ describe("createHistory", () => {
8
+ it("canUndo / canRedo sont réactifs (piles remplacées, pas mutées)", async () => {
9
+ const h = createHistory<string>(10)
10
+ const seen: boolean[] = []
11
+ effect(() => {
12
+ seen.push(h.canUndo.value)
13
+ })
14
+ expect(seen).toEqual([false])
15
+
16
+ h.push("a")
17
+ await nextTick()
18
+ expect(seen.at(-1)).toBe(true)
19
+
20
+ h.undo("b")
21
+ await nextTick()
22
+ expect(h.canRedo.value).toBe(true)
23
+ expect(h.canUndo.value).toBe(false)
24
+
25
+ h.redo("c")
26
+ await nextTick()
27
+ expect(h.canUndo.value).toBe(true)
28
+ expect(seen).toContain(true)
29
+ })
30
+
31
+ it("dépile dans le bon ordre et empile l'état courant dans la pile opposée", () => {
32
+ const h = createHistory<number>()
33
+ h.push(1)
34
+ h.push(2)
35
+
36
+ expect(h.undo(99)).toBe(2)
37
+ expect(h.undo(98)).toBe(1)
38
+ expect(h.undo(97)).toBeNull()
39
+ expect(h.canUndo.value).toBe(false)
40
+ expect(h.canRedo.value).toBe(true)
41
+
42
+ expect(h.redo(50)).toBe(98)
43
+ expect(h.redo(51)).toBe(99)
44
+ expect(h.redo(52)).toBeNull()
45
+ expect(h.canUndo.value).toBe(true)
46
+ })
47
+
48
+ it("une nouvelle action vide la pile redo", () => {
49
+ const h = createHistory<number>()
50
+ h.push(1)
51
+ h.undo(2)
52
+ expect(h.canRedo.value).toBe(true)
53
+
54
+ h.push(3)
55
+ expect(h.canRedo.value).toBe(false)
56
+ expect(h.redo(4)).toBeNull()
57
+ })
58
+
59
+ it("borne la pile à `limit` (la plus ancienne entrée est oubliée)", () => {
60
+ const h = createHistory<number>(2)
61
+ h.push(1)
62
+ h.push(2)
63
+ h.push(3)
64
+ expect(h.undoStack.value).toEqual([2, 3])
65
+ expect(h.undo(0)).toBe(3)
66
+ expect(h.undo(0)).toBe(2)
67
+ expect(h.undo(0)).toBeNull()
68
+ })
69
+
70
+ it("clear vide les deux piles", () => {
71
+ const h = createHistory<number>()
72
+ h.push(1)
73
+ h.undo(2)
74
+ h.clear()
75
+ expect(h.canUndo.value).toBe(false)
76
+ expect(h.canRedo.value).toBe(false)
77
+ })
78
+ })
package/lib/history.ts ADDED
@@ -0,0 +1,72 @@
1
+ // Historique undo/redo réactif (QSpreadsheet).
2
+ //
3
+ // Les piles doivent être des `shallowRef` **remplacées** à chaque opération : un `push`
4
+ // sur un tableau simple ne déclenche aucune réactivité. C'est exactement le bug qui
5
+ // figeait `canUndo`/`canRedo` à `false` (boutons désactivés et Ctrl+Z inerte), même
6
+ // quand `pushHistory()` avait bien été appelé (suppression de ligne, tri, remplissage…).
7
+ import { computed, shallowRef } from "vue"
8
+ import type { ComputedRef, ShallowRef } from "vue"
9
+
10
+ export interface History<T> {
11
+ readonly undoStack: ShallowRef<T[]>
12
+ readonly redoStack: ShallowRef<T[]>
13
+ readonly canUndo: ComputedRef<boolean>
14
+ readonly canRedo: ComputedRef<boolean>
15
+ /** Enregistre un état **avant** mutation et vide la pile redo. */
16
+ push: (snapshot: T) => void
17
+ /** Dépile undo (et empile `current` dans redo) → instantané à restaurer. */
18
+ undo: (current: T) => T | null
19
+ /** Dépile redo (et empile `current` dans undo) → instantané à restaurer. */
20
+ redo: (current: T) => T | null
21
+ /** Vide les deux piles. */
22
+ clear: () => void
23
+ }
24
+
25
+ /**
26
+ * Piles d'historique bornées (`limit`, la plus ancienne entrée est oubliée).
27
+ * Toutes les opérations renvoient/réaffectent des tableaux **neufs** pour rester réactives.
28
+ */
29
+ export function createHistory<T>(limit = 100): History<T> {
30
+ const undoStack = shallowRef<T[]>([])
31
+ const redoStack = shallowRef<T[]>([])
32
+
33
+ const push = (snapshot: T) => {
34
+ const next = [...undoStack.value, snapshot]
35
+ undoStack.value = next.length > limit ? next.slice(next.length - limit) : next
36
+ redoStack.value = []
37
+ }
38
+
39
+ const undo = (current: T): T | null => {
40
+ const stack = undoStack.value
41
+ if (!stack.length) return null
42
+ const snapshot = stack[stack.length - 1]!
43
+ undoStack.value = stack.slice(0, -1)
44
+ redoStack.value = [...redoStack.value, current]
45
+ return snapshot
46
+ }
47
+
48
+ const redo = (current: T): T | null => {
49
+ const stack = redoStack.value
50
+ if (!stack.length) return null
51
+ const snapshot = stack[stack.length - 1]!
52
+ redoStack.value = stack.slice(0, -1)
53
+ undoStack.value = [...undoStack.value, current]
54
+ return snapshot
55
+ }
56
+
57
+ const clear = () => {
58
+ undoStack.value = []
59
+ redoStack.value = []
60
+ }
61
+
62
+ return {
63
+ undoStack,
64
+ redoStack,
65
+ canUndo: computed(() => undoStack.value.length > 0),
66
+ canRedo: computed(() => redoStack.value.length > 0),
67
+ push,
68
+ undo,
69
+ redo,
70
+ clear,
71
+ }
72
+ }