@stonecrop/stonecrop 0.4.37 → 0.6.0

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.
Files changed (78) hide show
  1. package/README.md +92 -3
  2. package/dist/src/composable.d.ts +74 -8
  3. package/dist/src/composable.d.ts.map +1 -1
  4. package/dist/src/composable.js +348 -0
  5. package/dist/src/composables/operation-log.d.ts +136 -0
  6. package/dist/src/composables/operation-log.d.ts.map +1 -0
  7. package/dist/src/composables/operation-log.js +221 -0
  8. package/dist/src/doctype.d.ts +9 -1
  9. package/dist/src/doctype.d.ts.map +1 -1
  10. package/dist/{doctype.js → src/doctype.js} +9 -3
  11. package/dist/src/field-triggers.d.ts +178 -0
  12. package/dist/src/field-triggers.d.ts.map +1 -0
  13. package/dist/src/field-triggers.js +564 -0
  14. package/dist/src/index.d.ts +12 -4
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +18 -0
  17. package/dist/src/plugins/index.d.ts +11 -13
  18. package/dist/src/plugins/index.d.ts.map +1 -1
  19. package/dist/src/plugins/index.js +90 -0
  20. package/dist/src/registry.d.ts +9 -3
  21. package/dist/src/registry.d.ts.map +1 -1
  22. package/dist/{registry.js → src/registry.js} +14 -1
  23. package/dist/src/stonecrop.d.ts +350 -114
  24. package/dist/src/stonecrop.d.ts.map +1 -1
  25. package/dist/src/stonecrop.js +251 -0
  26. package/dist/src/stores/hst.d.ts +157 -0
  27. package/dist/src/stores/hst.d.ts.map +1 -0
  28. package/dist/src/stores/hst.js +483 -0
  29. package/dist/src/stores/index.d.ts +5 -1
  30. package/dist/src/stores/index.d.ts.map +1 -1
  31. package/dist/{stores → src/stores}/index.js +4 -1
  32. package/dist/src/stores/operation-log.d.ts +268 -0
  33. package/dist/src/stores/operation-log.d.ts.map +1 -0
  34. package/dist/src/stores/operation-log.js +571 -0
  35. package/dist/src/types/field-triggers.d.ts +186 -0
  36. package/dist/src/types/field-triggers.d.ts.map +1 -0
  37. package/dist/src/types/field-triggers.js +4 -0
  38. package/dist/src/types/index.d.ts +13 -2
  39. package/dist/src/types/index.d.ts.map +1 -1
  40. package/dist/src/types/index.js +4 -0
  41. package/dist/src/types/operation-log.d.ts +165 -0
  42. package/dist/src/types/operation-log.d.ts.map +1 -0
  43. package/dist/src/types/registry.d.ts +11 -0
  44. package/dist/src/types/registry.d.ts.map +1 -0
  45. package/dist/src/types/registry.js +0 -0
  46. package/dist/stonecrop.d.ts +1555 -159
  47. package/dist/stonecrop.js +1974 -7028
  48. package/dist/stonecrop.js.map +1 -1
  49. package/dist/stonecrop.umd.cjs +4 -8
  50. package/dist/stonecrop.umd.cjs.map +1 -1
  51. package/dist/tests/setup.d.ts +5 -0
  52. package/dist/tests/setup.d.ts.map +1 -0
  53. package/dist/tests/setup.js +15 -0
  54. package/package.json +6 -5
  55. package/src/composable.ts +481 -31
  56. package/src/composables/operation-log.ts +254 -0
  57. package/src/doctype.ts +9 -3
  58. package/src/field-triggers.ts +671 -0
  59. package/src/index.ts +50 -4
  60. package/src/plugins/index.ts +70 -22
  61. package/src/registry.ts +18 -3
  62. package/src/stonecrop.ts +246 -155
  63. package/src/stores/hst.ts +703 -0
  64. package/src/stores/index.ts +6 -1
  65. package/src/stores/operation-log.ts +671 -0
  66. package/src/types/field-triggers.ts +201 -0
  67. package/src/types/index.ts +17 -6
  68. package/src/types/operation-log.ts +205 -0
  69. package/src/types/registry.ts +10 -0
  70. package/dist/composable.js +0 -50
  71. package/dist/index.js +0 -6
  72. package/dist/plugins/index.js +0 -49
  73. package/dist/src/stores/data.d.ts +0 -11
  74. package/dist/src/stores/data.d.ts.map +0 -1
  75. package/dist/stores/data.js +0 -7
  76. package/src/stores/data.ts +0 -8
  77. /package/dist/{exceptions.js → src/exceptions.js} +0 -0
  78. /package/dist/{types/index.js → src/types/operation-log.js} +0 -0
@@ -0,0 +1,671 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref, computed, watch } from 'vue'
3
+ import { useLocalStorage } from '@vueuse/core'
4
+ import type {
5
+ HSTOperation,
6
+ HSTOperationInput,
7
+ OperationLogConfig,
8
+ UndoRedoState,
9
+ OperationLogSnapshot,
10
+ CrossTabMessage,
11
+ OperationSource,
12
+ } from '../types/operation-log'
13
+ import type { HSTNode } from './hst'
14
+
15
+ /**
16
+ * Generate a UUID using crypto API or fallback
17
+ */
18
+ function generateId(): string {
19
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
20
+ return crypto.randomUUID()
21
+ }
22
+ // Fallback for environments without crypto.randomUUID
23
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
24
+ }
25
+
26
+ /**
27
+ * Serialize a message for BroadcastChannel
28
+ * Converts Date objects to ISO strings for structured clone compatibility
29
+ */
30
+ type SerializedOperation = Omit<HSTOperation, 'timestamp'> & { timestamp: string }
31
+ type SerializedCrossTabMessage = Omit<CrossTabMessage, 'timestamp' | 'operation' | 'operations'> & {
32
+ timestamp: string
33
+ operation?: SerializedOperation
34
+ operations?: SerializedOperation[]
35
+ }
36
+
37
+ function serializeForBroadcast(message: CrossTabMessage): SerializedCrossTabMessage {
38
+ const serialized: SerializedCrossTabMessage = {
39
+ type: message.type,
40
+ clientId: message.clientId,
41
+ timestamp: message.timestamp.toISOString(),
42
+ }
43
+
44
+ if (message.operation) {
45
+ serialized.operation = {
46
+ ...message.operation,
47
+ timestamp: message.operation.timestamp.toISOString(),
48
+ }
49
+ }
50
+
51
+ if (message.operations) {
52
+ serialized.operations = message.operations.map(op => ({
53
+ ...op,
54
+ timestamp: op.timestamp.toISOString(),
55
+ }))
56
+ }
57
+
58
+ return serialized
59
+ }
60
+
61
+ /**
62
+ * Deserialize a message from BroadcastChannel
63
+ * Converts ISO strings back to Date objects
64
+ */
65
+ function deserializeFromBroadcast(serialized: SerializedCrossTabMessage): CrossTabMessage {
66
+ const message: CrossTabMessage = {
67
+ type: serialized.type,
68
+ clientId: serialized.clientId,
69
+ timestamp: new Date(serialized.timestamp),
70
+ }
71
+
72
+ if (serialized.operation) {
73
+ message.operation = {
74
+ ...serialized.operation,
75
+ timestamp: new Date(serialized.operation.timestamp),
76
+ }
77
+ }
78
+
79
+ if (serialized.operations) {
80
+ message.operations = serialized.operations.map(op => ({
81
+ ...op,
82
+ timestamp: new Date(op.timestamp),
83
+ }))
84
+ }
85
+
86
+ return message
87
+ }
88
+
89
+ /**
90
+ * Global HST Operation Log Store
91
+ * Tracks all mutations with full metadata for undo/redo, sync, and audit
92
+ *
93
+ * @public
94
+ */
95
+ export const useOperationLogStore = defineStore('hst-operation-log', () => {
96
+ // Configuration
97
+ const config = ref<OperationLogConfig>({
98
+ maxOperations: 100,
99
+ enableCrossTabSync: true,
100
+ autoSyncInterval: 30000,
101
+ enablePersistence: false,
102
+ persistenceKeyPrefix: 'stonecrop-ops',
103
+ })
104
+
105
+ // State
106
+ const operations = ref<HSTOperation[]>([])
107
+ const currentIndex = ref(-1) // Points to the last applied operation
108
+ const clientId = ref(generateId())
109
+ const batchMode = ref(false)
110
+ const currentBatch = ref<HSTOperation[]>([])
111
+ const currentBatchId = ref<string | null>(null)
112
+
113
+ // Computed
114
+ const canUndo = computed(() => {
115
+ // Can undo if there are operations and we're not at the beginning
116
+ if (currentIndex.value < 0) return false
117
+
118
+ // Check if the operation at currentIndex is reversible
119
+ const operation = operations.value[currentIndex.value]
120
+ return operation?.reversible ?? false
121
+ })
122
+
123
+ const canRedo = computed(() => {
124
+ // Can redo if there are operations ahead of current index
125
+ return currentIndex.value < operations.value.length - 1
126
+ })
127
+
128
+ const undoCount = computed(() => {
129
+ let count = 0
130
+ for (let i = currentIndex.value; i >= 0; i--) {
131
+ if (operations.value[i]?.reversible) count++
132
+ else break
133
+ }
134
+ return count
135
+ })
136
+
137
+ const redoCount = computed(() => {
138
+ return operations.value.length - 1 - currentIndex.value
139
+ })
140
+
141
+ const undoRedoState = computed<UndoRedoState>(() => ({
142
+ canUndo: canUndo.value,
143
+ canRedo: canRedo.value,
144
+ undoCount: undoCount.value,
145
+ redoCount: redoCount.value,
146
+ currentIndex: currentIndex.value,
147
+ }))
148
+
149
+ // Core Methods
150
+
151
+ /**
152
+ * Configure the operation log
153
+ */
154
+ function configure(options: Partial<OperationLogConfig>) {
155
+ config.value = { ...config.value, ...options }
156
+
157
+ // Set up persistence if enabled
158
+ if (config.value.enablePersistence) {
159
+ loadFromPersistence()
160
+ setupPersistenceWatcher()
161
+ }
162
+
163
+ // Set up cross-tab sync if enabled
164
+ if (config.value.enableCrossTabSync) {
165
+ setupCrossTabSync()
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Add an operation to the log
171
+ */
172
+ function addOperation(operation: HSTOperationInput, source: OperationSource = 'user') {
173
+ const fullOperation: HSTOperation = {
174
+ ...operation,
175
+ id: generateId(),
176
+ timestamp: new Date(),
177
+ source: source,
178
+ userId: config.value.userId,
179
+ }
180
+
181
+ // Apply filter if configured
182
+ if (config.value.operationFilter && !config.value.operationFilter(fullOperation)) {
183
+ return fullOperation.id
184
+ }
185
+
186
+ // If in batch mode, collect operations
187
+ if (batchMode.value) {
188
+ currentBatch.value.push(fullOperation)
189
+ return fullOperation.id
190
+ }
191
+
192
+ // Remove any operations after current index (they become invalid after new operation)
193
+ if (currentIndex.value < operations.value.length - 1) {
194
+ operations.value = operations.value.slice(0, currentIndex.value + 1)
195
+ }
196
+
197
+ // Add new operation
198
+ operations.value.push(fullOperation)
199
+ currentIndex.value++
200
+
201
+ // Enforce max operations limit
202
+ if (config.value.maxOperations && operations.value.length > config.value.maxOperations) {
203
+ const overflow = operations.value.length - config.value.maxOperations
204
+ operations.value = operations.value.slice(overflow)
205
+ currentIndex.value -= overflow
206
+ }
207
+
208
+ // Broadcast to other tabs
209
+ if (config.value.enableCrossTabSync) {
210
+ broadcastOperation(fullOperation)
211
+ }
212
+
213
+ return fullOperation.id
214
+ }
215
+
216
+ /**
217
+ * Start batch mode - collect multiple operations
218
+ */
219
+ function startBatch() {
220
+ batchMode.value = true
221
+ currentBatch.value = []
222
+ currentBatchId.value = generateId()
223
+ }
224
+
225
+ /**
226
+ * Commit batch - create a single batch operation
227
+ */
228
+ function commitBatch(description?: string): string | null {
229
+ if (!batchMode.value || currentBatch.value.length === 0) {
230
+ batchMode.value = false
231
+ currentBatch.value = []
232
+ currentBatchId.value = null
233
+ return null
234
+ }
235
+
236
+ const batchId = currentBatchId.value!
237
+ const allReversible = currentBatch.value.every(op => op.reversible)
238
+
239
+ // Create parent batch operation
240
+ const batchOperation: HSTOperation = {
241
+ id: batchId,
242
+ type: 'batch',
243
+ path: '', // Batch doesn't have a single path
244
+ fieldname: '',
245
+ beforeValue: null,
246
+ afterValue: null,
247
+ doctype: currentBatch.value[0]?.doctype || '',
248
+ timestamp: new Date(),
249
+ source: 'user',
250
+ reversible: allReversible,
251
+ irreversibleReason: allReversible ? undefined : 'Contains irreversible operations',
252
+ childOperationIds: currentBatch.value.map(op => op.id),
253
+ metadata: { description },
254
+ }
255
+
256
+ // Add parent operation ID to all children
257
+ currentBatch.value.forEach(op => {
258
+ op.parentOperationId = batchId
259
+ })
260
+
261
+ // Add all operations to the log
262
+ operations.value.push(...currentBatch.value, batchOperation)
263
+ currentIndex.value = operations.value.length - 1
264
+
265
+ // Broadcast batch
266
+ if (config.value.enableCrossTabSync) {
267
+ broadcastBatch(currentBatch.value, batchOperation)
268
+ }
269
+
270
+ // Reset batch state
271
+ const result = batchId
272
+ batchMode.value = false
273
+ currentBatch.value = []
274
+ currentBatchId.value = null
275
+
276
+ return result
277
+ }
278
+
279
+ /**
280
+ * Cancel batch mode without committing
281
+ */
282
+ function cancelBatch() {
283
+ batchMode.value = false
284
+ currentBatch.value = []
285
+ currentBatchId.value = null
286
+ }
287
+
288
+ /**
289
+ * Undo the last operation
290
+ */
291
+ function undo(store: HSTNode): boolean {
292
+ if (!canUndo.value) return false
293
+
294
+ const operation = operations.value[currentIndex.value]
295
+
296
+ if (!operation.reversible) {
297
+ // Warn about irreversible operation
298
+ if (typeof console !== 'undefined' && operation.irreversibleReason) {
299
+ // eslint-disable-next-line no-console
300
+ console.warn('Cannot undo irreversible operation:', operation.irreversibleReason)
301
+ }
302
+ return false
303
+ }
304
+
305
+ try {
306
+ // Handle batch operations
307
+ if (operation.type === 'batch' && operation.childOperationIds) {
308
+ // Undo all child operations in reverse order
309
+ for (let i = operation.childOperationIds.length - 1; i >= 0; i--) {
310
+ const childId = operation.childOperationIds[i]
311
+ const childOp = operations.value.find(op => op.id === childId)
312
+ if (childOp) {
313
+ revertOperation(childOp, store)
314
+ }
315
+ }
316
+ } else {
317
+ // Undo single operation
318
+ revertOperation(operation, store)
319
+ }
320
+
321
+ currentIndex.value--
322
+
323
+ // Broadcast undo to other tabs
324
+ if (config.value.enableCrossTabSync) {
325
+ broadcastUndo(operation)
326
+ }
327
+
328
+ return true
329
+ } catch (error) {
330
+ // Log error in development
331
+ if (typeof console !== 'undefined') {
332
+ // eslint-disable-next-line no-console
333
+ console.error('Undo failed:', error)
334
+ }
335
+ return false
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Redo the next operation
341
+ */
342
+ function redo(store: HSTNode): boolean {
343
+ if (!canRedo.value) return false
344
+
345
+ const operation = operations.value[currentIndex.value + 1]
346
+
347
+ try {
348
+ // Handle batch operations
349
+ if (operation.type === 'batch' && operation.childOperationIds) {
350
+ // Redo all child operations in order
351
+ for (const childId of operation.childOperationIds) {
352
+ const childOp = operations.value.find(op => op.id === childId)
353
+ if (childOp) {
354
+ applyOperation(childOp, store)
355
+ }
356
+ }
357
+ } else {
358
+ // Redo single operation
359
+ applyOperation(operation, store)
360
+ }
361
+
362
+ currentIndex.value++
363
+
364
+ // Broadcast redo to other tabs
365
+ if (config.value.enableCrossTabSync) {
366
+ broadcastRedo(operation)
367
+ }
368
+
369
+ return true
370
+ } catch (error) {
371
+ // Log error in development
372
+ if (typeof console !== 'undefined') {
373
+ // eslint-disable-next-line no-console
374
+ console.error('Redo failed:', error)
375
+ }
376
+ return false
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Revert an operation (apply beforeValue)
382
+ */
383
+ function revertOperation(operation: HSTOperation, store: HSTNode) {
384
+ // Both 'set' and 'delete' operations can be reverted by setting to beforeValue
385
+ if ((operation.type === 'set' || operation.type === 'delete') && store && typeof store.set === 'function') {
386
+ store.set(operation.path, operation.beforeValue, 'undo')
387
+ }
388
+ // Note: 'transition' operations are marked as non-reversible, so they won't reach here
389
+ // Note: 'batch' operations are handled separately in the undo function
390
+ }
391
+
392
+ /**
393
+ * Apply an operation (apply afterValue)
394
+ */
395
+ function applyOperation(operation: HSTOperation, store: HSTNode) {
396
+ // Both 'set' and 'delete' operations can be applied by setting to afterValue
397
+ if ((operation.type === 'set' || operation.type === 'delete') && store && typeof store.set === 'function') {
398
+ store.set(operation.path, operation.afterValue, 'redo')
399
+ }
400
+ // Note: 'transition' operations are marked as non-reversible, so they won't reach here
401
+ // Note: 'batch' operations are handled separately in the redo function
402
+ }
403
+
404
+ /**
405
+ * Get operation log snapshot for debugging
406
+ */
407
+ function getSnapshot(): OperationLogSnapshot {
408
+ const reversibleOps = operations.value.filter(op => op.reversible).length
409
+ const timestamps = operations.value.map(op => op.timestamp)
410
+
411
+ return {
412
+ operations: [...operations.value],
413
+ currentIndex: currentIndex.value,
414
+ totalOperations: operations.value.length,
415
+ reversibleOperations: reversibleOps,
416
+ irreversibleOperations: operations.value.length - reversibleOps,
417
+ oldestOperation: timestamps.length > 0 ? new Date(Math.min(...timestamps.map(t => t.getTime()))) : undefined,
418
+ newestOperation: timestamps.length > 0 ? new Date(Math.max(...timestamps.map(t => t.getTime()))) : undefined,
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Clear all operations
424
+ */
425
+ function clear() {
426
+ operations.value = []
427
+ currentIndex.value = -1
428
+ }
429
+
430
+ /**
431
+ * Get operations for a specific doctype and recordId
432
+ */
433
+ function getOperationsFor(doctype: string, recordId?: string): HSTOperation[] {
434
+ return operations.value.filter(op => op.doctype === doctype && (recordId === undefined || op.recordId === recordId))
435
+ }
436
+
437
+ /**
438
+ * Mark an operation as irreversible
439
+ * @param operationId - The ID of the operation to mark
440
+ * @param reason - The reason why the operation is irreversible
441
+ */
442
+ function markIrreversible(operationId: string, reason: string) {
443
+ const operation = operations.value.find(op => op.id === operationId)
444
+ if (operation) {
445
+ operation.reversible = false
446
+ operation.irreversibleReason = reason
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Log an action execution (stateless actions like print, email, etc.)
452
+ * These operations are tracked but typically not reversible
453
+ * @param doctype - The doctype the action was executed on
454
+ * @param actionName - The name of the action that was executed
455
+ * @param recordIds - Optional array of record IDs the action was executed on
456
+ * @param result - The result of the action execution
457
+ * @param error - Optional error message if action failed
458
+ * @returns The operation ID
459
+ */
460
+ function logAction(
461
+ doctype: string,
462
+ actionName: string,
463
+ recordIds?: string[],
464
+ result: 'success' | 'failure' | 'pending' = 'success',
465
+ error?: string
466
+ ): string {
467
+ const operation: HSTOperationInput = {
468
+ type: 'action',
469
+ path: recordIds && recordIds.length > 0 ? `${doctype}.${recordIds[0]}` : doctype,
470
+ fieldname: '',
471
+ beforeValue: null,
472
+ afterValue: null,
473
+ doctype,
474
+ recordId: recordIds && recordIds.length > 0 ? recordIds[0] : undefined,
475
+ reversible: false, // Actions are typically not reversible
476
+ actionName,
477
+ actionRecordIds: recordIds,
478
+ actionResult: result,
479
+ actionError: error,
480
+ }
481
+
482
+ return addOperation(operation)
483
+ }
484
+
485
+ // Cross-tab synchronization
486
+ let broadcastChannel: BroadcastChannel | null = null
487
+
488
+ function setupCrossTabSync() {
489
+ if (typeof window === 'undefined' || !window.BroadcastChannel) return
490
+
491
+ broadcastChannel = new BroadcastChannel('stonecrop-operation-log')
492
+
493
+ broadcastChannel.addEventListener('message', (event: MessageEvent) => {
494
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
495
+ const rawMessage = event.data
496
+
497
+ if (!rawMessage || typeof rawMessage !== 'object') return
498
+
499
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
500
+ const message = deserializeFromBroadcast(rawMessage)
501
+
502
+ // Ignore messages from this tab
503
+ if (message.clientId === clientId.value) return
504
+
505
+ if (message.type === 'operation' && message.operation) {
506
+ // Add operation from another tab
507
+ operations.value.push({ ...message.operation, source: 'sync' as OperationSource })
508
+ currentIndex.value = operations.value.length - 1
509
+ } else if (message.type === 'operation' && message.operations) {
510
+ // Add batch operations from another tab
511
+ operations.value.push(...message.operations.map(op => ({ ...op, source: 'sync' as OperationSource })))
512
+ currentIndex.value = operations.value.length - 1
513
+ }
514
+ })
515
+ }
516
+
517
+ function broadcastOperation(operation: HSTOperation) {
518
+ if (!broadcastChannel) return
519
+
520
+ const message: CrossTabMessage = {
521
+ type: 'operation',
522
+ operation,
523
+ clientId: clientId.value,
524
+ timestamp: new Date(),
525
+ }
526
+ broadcastChannel.postMessage(serializeForBroadcast(message))
527
+ }
528
+
529
+ function broadcastBatch(childOps: HSTOperation[], batchOp: HSTOperation) {
530
+ if (!broadcastChannel) return
531
+
532
+ const message: CrossTabMessage = {
533
+ type: 'operation',
534
+ operations: [...childOps, batchOp],
535
+ clientId: clientId.value,
536
+ timestamp: new Date(),
537
+ }
538
+ broadcastChannel.postMessage(serializeForBroadcast(message))
539
+ }
540
+
541
+ function broadcastUndo(operation: HSTOperation) {
542
+ if (!broadcastChannel) return
543
+
544
+ const message: CrossTabMessage = {
545
+ type: 'undo',
546
+ operation,
547
+ clientId: clientId.value,
548
+ timestamp: new Date(),
549
+ }
550
+ broadcastChannel.postMessage(serializeForBroadcast(message))
551
+ }
552
+
553
+ function broadcastRedo(operation: HSTOperation) {
554
+ if (!broadcastChannel) return
555
+
556
+ const message: CrossTabMessage = {
557
+ type: 'redo',
558
+ operation,
559
+ clientId: clientId.value,
560
+ timestamp: new Date(),
561
+ }
562
+ broadcastChannel.postMessage(serializeForBroadcast(message))
563
+ }
564
+
565
+ // Persistence using VueUse
566
+ type PersistedData = {
567
+ operations: Array<Omit<HSTOperation, 'timestamp'> & { timestamp: string }>
568
+ currentIndex: number
569
+ }
570
+
571
+ const persistedData = useLocalStorage<PersistedData | null>('stonecrop-ops-operations', null, {
572
+ serializer: {
573
+ read: (v: string) => {
574
+ try {
575
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
576
+ const data = JSON.parse(v)
577
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
578
+ return data
579
+ } catch {
580
+ return null
581
+ }
582
+ },
583
+ write: (v: PersistedData | null) => {
584
+ if (!v) return ''
585
+ return JSON.stringify(v)
586
+ },
587
+ },
588
+ })
589
+
590
+ function loadFromPersistence() {
591
+ if (typeof window === 'undefined') return
592
+
593
+ try {
594
+ const data = persistedData.value
595
+ if (data && Array.isArray(data.operations)) {
596
+ operations.value = data.operations.map(op => ({
597
+ ...op,
598
+ timestamp: new Date(op.timestamp),
599
+ }))
600
+ currentIndex.value = data.currentIndex ?? -1
601
+ }
602
+ } catch (error) {
603
+ // Log error in development
604
+ if (typeof console !== 'undefined') {
605
+ // eslint-disable-next-line no-console
606
+ console.error('Failed to load operations from persistence:', error)
607
+ }
608
+ }
609
+ }
610
+
611
+ function saveToPersistence() {
612
+ if (typeof window === 'undefined') return
613
+
614
+ try {
615
+ persistedData.value = {
616
+ operations: operations.value.map(op => ({
617
+ ...op,
618
+ timestamp: op.timestamp.toISOString(),
619
+ })),
620
+ currentIndex: currentIndex.value,
621
+ }
622
+ } catch (error) {
623
+ // Log error in development
624
+ if (typeof console !== 'undefined') {
625
+ // eslint-disable-next-line no-console
626
+ console.error('Failed to save operations to persistence:', error)
627
+ }
628
+ }
629
+ }
630
+
631
+ function setupPersistenceWatcher() {
632
+ watch(
633
+ [operations, currentIndex],
634
+ () => {
635
+ if (config.value.enablePersistence) {
636
+ saveToPersistence()
637
+ }
638
+ },
639
+ { deep: true }
640
+ )
641
+ }
642
+
643
+ return {
644
+ // State
645
+ operations,
646
+ currentIndex,
647
+ config,
648
+ clientId,
649
+ undoRedoState,
650
+
651
+ // Computed
652
+ canUndo,
653
+ canRedo,
654
+ undoCount,
655
+ redoCount,
656
+
657
+ // Methods
658
+ configure,
659
+ addOperation,
660
+ startBatch,
661
+ commitBatch,
662
+ cancelBatch,
663
+ undo,
664
+ redo,
665
+ clear,
666
+ getOperationsFor,
667
+ getSnapshot,
668
+ markIrreversible,
669
+ logAction,
670
+ }
671
+ })