vue2-excel-grid 1.8.0 → 2.0.1

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.
@@ -820,13 +820,16 @@
820
820
  :visible="localSyncModal.visible"
821
821
  :grid-options="_selfComponentDef"
822
822
  :columns="_localSyncPreviewColumns"
823
- :snapshot="localSyncModal.snapshot"
823
+ :versions="localSyncModal.versions"
824
+ :selected-id="localSyncModal.selectedId"
825
+ :selected-version="localSyncModal.selectedVersion"
824
826
  :locale="locale"
825
827
  :theme="internalTheme"
826
828
  @close="closeLocalSyncModal"
829
+ @select="_selectLocalSyncModalVersion"
827
830
  @save="_onLocalSyncModalSave"
828
- @restore="restoreLocalSnapshot"
829
- @clear="clearLocalSnapshot"
831
+ @restore="restoreLocalVersion"
832
+ @clear="_onLocalSyncModalDelete"
830
833
  />
831
834
 
832
835
  <!-- Lock/unlock state feedback — brief centered icon flash -->
@@ -944,7 +947,8 @@ import EgLocalSyncModal from './EgLocalSyncModal.vue'
944
947
  import { parseDateValue, formatDate } from '../utils/dateUtils.js'
945
948
  import { formatNumber as _formatNumber } from '../utils/numberFormat.js'
946
949
  import { Mask, MaskInput } from '../utils/mask.js'
947
- import { saveSnapshot, loadSnapshot, deleteSnapshot } from '../utils/idbStore.js'
950
+ import { listVersions, getVersion, deleteVersion, saveOrTouchVersion, putBuffer, getBuffer, clearBuffer } from '../utils/idbStore.js'
951
+ import { hashRows, hashRowsAsync } from '../utils/dataHash.js'
948
952
 
949
953
  const SIZE_CONFIG = {
950
954
  small: { row: 22, header: 24, footer: 24, filter: 26 },
@@ -1409,34 +1413,48 @@ const ExcelGridComponent = {
1409
1413
  sort: { type: Object, default: null },
1410
1414
 
1411
1415
  /**
1412
- * Unique key that opts the grid into local autosave: whenever `data`
1413
- * changes, a debounced (~800ms) copy is written to the browser's
1414
- * IndexedDB, keyed by this string — so a page reload/crash/tab close
1415
- * doesn't lose in-progress work. Also adds a toolbar button ('localSync'
1416
- * — see the `toolbar` prop's `list`/`disabled`) that opens a modal
1417
- * showing the last saved copy as a read-only table, with three actions:
1418
- * **Save** (pushes the current `v-model` into the saved copy right now,
1419
- * bypassing the debounce), **Restore** (the other direction — pulls the
1420
- * saved copy back into `v-model`), and **Delete** (clears the saved
1421
- * copy).
1416
+ * Unique key that opts the grid into local version history: when the page
1417
+ * is being left (tab hidden/closed, or this component unmounted — NOT on
1418
+ * every edit), the current `v-model` is content-hashed (via a Web Worker
1419
+ * when available, so the main thread stays responsive right as the page
1420
+ * may be unloading) and saved to the browser's IndexedDB as a **version**,
1421
+ * keyed by this string. If the hash matches an already-saved version for
1422
+ * this key, that version's timestamp is refreshed instead of creating a
1423
+ * duplicate — only genuinely different content produces a new version.
1424
+ * Also adds a toolbar button ('localSync' — see the `toolbar` prop's
1425
+ * `list`/`disabled`) that opens a modal listing saved versions, each with
1426
+ * a read-only preview and **Restore** / **Delete** actions, plus a
1427
+ * **Save** button for an explicit checkpoint.
1422
1428
  *
1423
1429
  * Two different grid instances must use different keys — the same key
1424
- * means "the same saved copy" (last writer wins). Omit this prop
1425
- * entirely to opt out completely: no IndexedDB access happens at all,
1426
- * and the toolbar button never renders.
1430
+ * means "the same version history" (concurrent tabs interleave). Omit
1431
+ * this prop entirely to opt out completely: no IndexedDB access happens
1432
+ * at all, and the toolbar button never renders.
1427
1433
  *
1428
- * See also the ref-callable methods `saveLocalSnapshot()`,
1429
- * `restoreLocalSnapshot()`, `clearLocalSnapshot()`, `getLocalSnapshot()`,
1430
- * `getLocalSnapshotInfo()`, `openLocalSyncModal()`/`closeLocalSyncModal()`,
1431
- * and the
1432
- * `local-sync-save`/`local-sync-restore`/`local-sync-clear`/`local-sync-error`
1434
+ * See also `localSyncMaxVersions`, the ref-callable methods
1435
+ * `listLocalVersions()`, `getLocalVersion(id)`, `restoreLocalVersion(id)`,
1436
+ * `deleteLocalVersion(id)`, `saveLocalVersionNow()`,
1437
+ * `openLocalSyncModal()`/`closeLocalSyncModal()`, and the
1438
+ * `local-sync-save`/`local-sync-restore`/`local-sync-clear`/`local-sync-error`/`local-sync-available`
1433
1439
  * events — [details](/local-sync).
1434
1440
  * @type {String}
1435
1441
  * @default null
1436
1442
  * @example
1437
1443
  * <excel-grid :columns="columns" v-model="data" local-sync-key="invoice-42" toolbar />
1438
1444
  */
1439
- localSyncKey: { type: String, default: null }
1445
+ localSyncKey: { type: String, default: null },
1446
+
1447
+ /**
1448
+ * Maximum number of local versions kept per `localSyncKey`. Once a new
1449
+ * version pushes the count past this, the oldest version(s) (by
1450
+ * `createdAt`) are deleted automatically (FIFO). Has no effect without
1451
+ * `localSyncKey`.
1452
+ * @type {Number}
1453
+ * @default 10
1454
+ * @example
1455
+ * <excel-grid :columns="columns" v-model="data" local-sync-key="invoice-42" :local-sync-max-versions="20" />
1456
+ */
1457
+ localSyncMaxVersions: { type: Number, default: 10 }
1440
1458
  },
1441
1459
 
1442
1460
  data() {
@@ -1506,12 +1524,14 @@ const ExcelGridComponent = {
1506
1524
  importState: { active: false, percent: 0, message: '' },
1507
1525
  exportState: { active: false },
1508
1526
 
1509
- // localSyncKey feature — modal state + pending debounce timer (the
1510
- // timer itself lives outside `data()`'s reactive tracking, see created()).
1511
- localSyncModal: { visible: false, snapshot: null },
1512
- // True when a snapshot already existed at mount and the user hasn't
1513
- // dealt with it yet — drives the toolbar button's "unseen draft" dot.
1514
- // See _checkLocalSnapshotOnMount() for the full lifecycle.
1527
+ // localSyncKey feature — modal state: the version list (metadata only)
1528
+ // plus which one is selected/previewed. `selectedVersion` holds that
1529
+ // version's full { id, data, createdAt, updatedAt } for the nested-grid
1530
+ // preview (see EgLocalSyncModal.vue).
1531
+ localSyncModal: { visible: false, versions: [], selectedId: null, selectedVersion: null },
1532
+ // True when one or more versions already existed at mount and the user
1533
+ // hasn't opened the modal yet — drives the toolbar button's "unseen
1534
+ // local versions" dot. See _loadLocalVersionsOnMount() for the full lifecycle.
1515
1535
  localSyncAvailable: false,
1516
1536
 
1517
1537
  resizing: {
@@ -2270,13 +2290,6 @@ const ExcelGridComponent = {
2270
2290
  this.$nextTick(() => this.updateVirtualScroll())
2271
2291
  },
2272
2292
 
2273
- // Guarded by _lockFlashArmed (set once mounted()) so an initial `locked:
2274
- // true` prop doesn't flash the icon before the grid has even appeared.
2275
- internalLocked(v) {
2276
- if (!this._lockFlashArmed) return
2277
- this._flashLock(v)
2278
- },
2279
-
2280
2293
  autoWidth(v) {
2281
2294
  if (v) this._applyAutoWidth()
2282
2295
  },
@@ -2347,9 +2360,21 @@ const ExcelGridComponent = {
2347
2360
  immediate: true,
2348
2361
  handler(v) { this.internalCollapsedGroups = v ? [...v] : [] }
2349
2362
  },
2363
+ // `immediate: true` guarantees this fires once, synchronously, right at
2364
+ // setup regardless of `v` — that's the one reliable signal that THIS
2365
+ // particular call is the initial sync, not a real host-driven change
2366
+ // (e.g. via `:locked.sync`). Every call after the first is a genuine
2367
+ // change, so only those flash. (Watching internalLocked itself for this
2368
+ // instead — the previous approach — can't tell "initial value happened
2369
+ // to be true" from "the user's first-ever real toggle": both look like
2370
+ // "the first time internalLocked actually changed value".)
2350
2371
  locked: {
2351
2372
  immediate: true,
2352
- handler(v) { this.internalLocked = !!v }
2373
+ handler(v) {
2374
+ this.internalLocked = !!v
2375
+ if (this._lockPropSynced) this._flashLock(this.internalLocked)
2376
+ this._lockPropSynced = true
2377
+ }
2353
2378
  },
2354
2379
  data: {
2355
2380
  immediate: true,
@@ -2419,6 +2444,12 @@ const ExcelGridComponent = {
2419
2444
  this._dragScrollRAF = null
2420
2445
  this._dragCleanupTimer = null
2421
2446
 
2447
+ // localSyncKey — the last `data` reference a version was actually saved
2448
+ // for. Initialized to the current (mount-time) value so an untouched grid
2449
+ // that's merely tab-switched away from doesn't spam a "nothing happened"
2450
+ // version on every hide — only a REAL edit (which replaces this array
2451
+ // reference, see _emitDataChange) makes the next hide event flush.
2452
+ this._lastFlushedDataRef = this.data
2422
2453
  },
2423
2454
 
2424
2455
  mounted() {
@@ -2450,8 +2481,17 @@ const ExcelGridComponent = {
2450
2481
  // in both modes) fresh when the host's layout changes.
2451
2482
  if (this.$el.parentElement) this._scrollBodyResizeObserver.observe(this.$el.parentElement)
2452
2483
  }
2453
- this._lockFlashArmed = true
2454
- this._checkLocalSnapshotOnMount()
2484
+ this._loadLocalVersionsOnMount()
2485
+ // localSyncKey — save on page-hide, not per-edit (see _flushLocalVersionOnHide).
2486
+ // 'visibilitychange'->hidden is the primary trigger: it fires for tab
2487
+ // switches, minimizing, AND reliably before actual tab close/navigation on
2488
+ // both desktop and mobile, while the page is still very likely alive —
2489
+ // giving the async worker-hash + IndexedDB write a real chance to finish.
2490
+ // 'pagehide' is a backstop for the cases visibilitychange misses; it's
2491
+ // idempotent with visibilitychange (same content -> same hash -> no-op
2492
+ // beyond a timestamp touch) so firing both is harmless.
2493
+ document.addEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)
2494
+ window.addEventListener('pagehide', this._onPageHideForLocalSync)
2455
2495
  },
2456
2496
 
2457
2497
  updated() {
@@ -2486,7 +2526,7 @@ const ExcelGridComponent = {
2486
2526
  clearTimeout(this._onInputTimer)
2487
2527
  clearTimeout(this._dragCleanupTimer)
2488
2528
  clearTimeout(this._widthSyncChainResetTimer)
2489
- clearTimeout(this._localSyncTimer)
2529
+ clearTimeout(this._bufferWriteTimer)
2490
2530
  if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)
2491
2531
  document.removeEventListener('mousemove', this.onGlobalMouseMove)
2492
2532
  document.removeEventListener('mouseup', this.onGlobalMouseUp)
@@ -2494,6 +2534,14 @@ const ExcelGridComponent = {
2494
2534
  document.removeEventListener('mousedown', this._onDocMouseDownForDropdown, true)
2495
2535
  window.removeEventListener('resize', this.syncHeaderViewportWidth)
2496
2536
  window.removeEventListener('scroll', this._onWindowScrollForDropdown, true)
2537
+ document.removeEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)
2538
+ window.removeEventListener('pagehide', this._onPageHideForLocalSync)
2539
+ // Best-effort — the component may be torn down (SPA route change) without
2540
+ // the page itself unloading, which visibilitychange/pagehide won't catch.
2541
+ // sync: true — see _flushLocalVersionOnHide()'s comment; a component
2542
+ // being destroyed is exactly the same "might not survive an async hop"
2543
+ // situation as the other two triggers.
2544
+ this._flushLocalVersionOnHide(false, true)
2497
2545
  if (this.isFullscreen) {
2498
2546
  document.body.style.overflow = this._prevBodyOverflow || ''
2499
2547
  document.removeEventListener('keydown', this._onFullscreenKeyDown)
@@ -6011,7 +6059,7 @@ const ExcelGridComponent = {
6011
6059
  // All edit paths already use spread ({ ...row }) so frozen rows are safe.
6012
6060
  const data = result.data.map(row => Object.freeze(row))
6013
6061
  this.$emit('input', data)
6014
- this._scheduleLocalAutosave()
6062
+ this._scheduleBufferWrite(data)
6015
6063
  this.$emit('update:columns', columns)
6016
6064
 
6017
6065
  const hasMerges = merges && merges.length > 0
@@ -6127,7 +6175,7 @@ const ExcelGridComponent = {
6127
6175
  }
6128
6176
  }
6129
6177
  this.$emit('input', newData)
6130
- this._scheduleLocalAutosave()
6178
+ this._scheduleBufferWrite(newData)
6131
6179
  },
6132
6180
 
6133
6181
  // Emit 'update:merges' and record a merges diff entry.
@@ -6161,7 +6209,7 @@ const ExcelGridComponent = {
6161
6209
  newData.length = entry.before.rowCount
6162
6210
  }
6163
6211
  this.$emit('input', newData)
6164
- this._scheduleLocalAutosave()
6212
+ this._scheduleBufferWrite(newData)
6165
6213
  } else if (entry.type === 'merges') {
6166
6214
  this.internalMerges = entry.before.slice()
6167
6215
  this.$emit('update:merges', entry.before.slice())
@@ -6184,7 +6232,7 @@ const ExcelGridComponent = {
6184
6232
  newData[entry.after.indices[i]] = entry.after.rows[i]
6185
6233
  }
6186
6234
  this.$emit('input', newData)
6187
- this._scheduleLocalAutosave()
6235
+ this._scheduleBufferWrite(newData)
6188
6236
  } else if (entry.type === 'merges') {
6189
6237
  this.internalMerges = entry.after.slice()
6190
6238
  this.$emit('update:merges', entry.after.slice())
@@ -6218,147 +6266,277 @@ const ExcelGridComponent = {
6218
6266
  },
6219
6267
 
6220
6268
  // =========================================================
6221
- // LOCAL SYNC — autosave `data` to IndexedDB, keyed by localSyncKey
6269
+ // LOCAL SYNC — save `data` as a local IndexedDB *version*, keyed by
6270
+ // localSyncKey, only when the page is being left (not per-edit).
6222
6271
  // =========================================================
6223
6272
 
6224
- // Internal: debounced (800ms) autosave. No-op when localSyncKey isn't
6225
- // set — zero IndexedDB access at all in that case.
6226
- //
6227
- // Deliberately called from the grid's OWN data-change points only
6228
- // (_emitDataChange, undo, redo, import) — NOT from a $watch on `data`.
6229
- // A watcher can't tell "the user edited a cell" from "the host replaced
6230
- // `data` wholesale", and the second one is the common case right after
6231
- // mount: a host that loads rows from an API assigns them ~100-2000ms in,
6232
- // which a watcher would treat as a real change and autosave over the
6233
- // user's previously saved draft — destroying exactly the work this
6234
- // feature exists to protect, before they can open the modal to restore
6235
- // it. Autosave must mean "the user worked", not "the data object
6236
- // changed". restoreLocalSnapshot() likewise does NOT call this: the
6237
- // snapshot it just applied is already what's stored.
6238
- _scheduleLocalAutosave() {
6273
+ // Internal, called once from mounted(): populates the toolbar "unseen
6274
+ // local versions" dot and emits local-sync-available if any versions
6275
+ // already exist for this key (e.g. saved in an earlier visit).
6276
+ async _loadLocalVersionsOnMount() {
6239
6277
  if (!this.localSyncKey) return
6240
- clearTimeout(this._localSyncTimer)
6241
- this._localSyncTimer = setTimeout(() => {
6242
- this.saveLocalSnapshot()
6243
- }, 800)
6244
- },
6245
-
6246
- // Internal, called once from mounted(): a snapshot that already exists at
6247
- // mount is a draft from an EARLIER session, which the user has no way of
6248
- // knowing about unless they happen to click the toolbar button. Flags it
6249
- // so the button shows a dot, and emits local-sync-available so a host can
6250
- // surface its own prompt ("restore unsaved changes?") instead.
6251
- async _checkLocalSnapshotOnMount() {
6278
+ await this._promoteBufferIfAny()
6279
+ try {
6280
+ const versions = await listVersions(this.localSyncKey)
6281
+ if (!versions.length) return
6282
+ this.localSyncAvailable = true
6283
+ this.$emit('local-sync-available', {
6284
+ key: this.localSyncKey,
6285
+ versions: versions.length,
6286
+ mostRecentUpdatedAt: versions[0].updatedAt
6287
+ })
6288
+ } catch (error) {
6289
+ this.$emit('local-sync-error', { action: 'load', error })
6290
+ }
6291
+ },
6292
+
6293
+ // Debounced (800ms), called from the grid's own real-change points
6294
+ // (_emitDataChange, import, undo, redo — the exact same "user worked"
6295
+ // signal _flushLocalVersionOnHide() relies on, never a $watch on `data`,
6296
+ // for the same reason spelled out on the localSyncKey prop's JSDoc: a
6297
+ // host loading rows from an API must never look like a user edit).
6298
+ //
6299
+ // Writes to IndexedDB's 'buffer' store (idbStore.js — one row per key, no
6300
+ // hash, no history) at a moment with no teardown race at all — the user
6301
+ // is actively still on the page. This is what actually fixes reload/
6302
+ // close data loss: a version-store write ATTEMPTED at the leave-signal
6303
+ // has no guarantee of finishing before an actual page teardown (verified
6304
+ // directly — even a trivial, unrelated IndexedDB write failed to survive
6305
+ // a real page.reload() in Chromium), but a buffer write that already
6306
+ // completed minutes or seconds earlier, during ordinary editing, needs no
6307
+ // such guarantee — it's already durably there. _promoteBufferIfAny()
6308
+ // turns it into a real version next time the grid mounts.
6309
+ _scheduleBufferWrite(data) {
6252
6310
  if (!this.localSyncKey) return
6253
- const snapshot = await this.getLocalSnapshot()
6254
- if (!snapshot) return
6255
- this.localSyncAvailable = true
6256
- this.$emit('local-sync-available', {
6257
- key: this.localSyncKey,
6258
- rows: snapshot.data.length,
6259
- updatedAt: snapshot.updatedAt
6260
- })
6311
+ clearTimeout(this._bufferWriteTimer)
6312
+ this._bufferWriteTimer = setTimeout(() => { this._writeBufferNow(data) }, 800)
6261
6313
  },
6262
6314
 
6263
- // Public, ref-callable: saves the current `data` to IndexedDB right now,
6264
- // bypassing the debounce (e.g. a host wants to force a checkpoint before
6265
- // navigating away). No-op (resolves to null) when localSyncKey isn't set.
6266
- async saveLocalSnapshot() {
6267
- if (!this.localSyncKey) return null
6268
- clearTimeout(this._localSyncTimer)
6315
+ async _writeBufferNow(data) {
6316
+ if (!this.localSyncKey) return
6269
6317
  try {
6270
- const { updatedAt } = await saveSnapshot(this.localSyncKey, this.data)
6271
- // What's stored is now exactly what's on screen — there's no longer an
6272
- // older draft to go back to, so the "unseen draft" dot would mislead.
6273
- this.localSyncAvailable = false
6274
- this.$emit('local-sync-save', { key: this.localSyncKey, rows: this.data.length, updatedAt })
6275
- return updatedAt
6318
+ await putBuffer(this.localSyncKey, data)
6276
6319
  } catch (error) {
6277
6320
  this.$emit('local-sync-error', { action: 'save', error })
6278
- return null
6279
6321
  }
6280
6322
  },
6281
6323
 
6282
- // Modal's "Save" button (push v-model -> IndexedDB — the other direction
6283
- // from "Restore", which pulls IndexedDB -> v-model). Unlike
6284
- // restoreLocalSnapshot(), this deliberately does NOT close the modal —
6285
- // the freshly-saved snapshot is reflected in place (updated timestamp/
6286
- // row count/preview) so the user sees the save actually happened.
6287
- async _onLocalSyncModalSave() {
6288
- const updatedAt = await this.saveLocalSnapshot()
6289
- if (updatedAt != null) {
6290
- this.localSyncModal.snapshot = { data: this.data, updatedAt }
6324
+ // Called once from _loadLocalVersionsOnMount(), before this key's version
6325
+ // list is read — folds a leftover buffer (from an edit that was saved
6326
+ // during the PREVIOUS visit but never made it into 'versions' — the
6327
+ // reload/close case this whole mechanism exists for) into a real version.
6328
+ // No time pressure here, so this uses the Worker-backed hashRowsAsync()
6329
+ // like any other non-leave-signal path.
6330
+ async _promoteBufferIfAny() {
6331
+ try {
6332
+ const buffered = await getBuffer(this.localSyncKey)
6333
+ if (!buffered) return
6334
+ const hash = await hashRowsAsync(buffered.data)
6335
+ const { version, overwritten } = await saveOrTouchVersion(
6336
+ this.localSyncKey, buffered.data, hash, this.localSyncMaxVersions
6337
+ )
6338
+ await clearBuffer(this.localSyncKey)
6339
+ this.$emit('local-sync-save', {
6340
+ key: this.localSyncKey,
6341
+ id: version.id,
6342
+ rows: buffered.data.length,
6343
+ updatedAt: version.updatedAt,
6344
+ overwritten
6345
+ })
6346
+ } catch (error) {
6347
+ this.$emit('local-sync-error', { action: 'save', error })
6291
6348
  }
6292
6349
  },
6293
6350
 
6294
- // Public, ref-callable: loads the IndexedDB snapshot and writes it back
6295
- // into v-model (input/update:data) — the modal's "Restore" button calls
6296
- // this too. No-op if localSyncKey isn't set or nothing is saved yet.
6297
- async restoreLocalSnapshot() {
6351
+ // `sync: true` — see _flushLocalVersionOnHide()'s comment on why the
6352
+ // passive leave-triggers can't afford a Worker round-trip.
6353
+ _onVisibilityChangeForLocalSync() {
6354
+ if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide(false, true)
6355
+ },
6356
+
6357
+ _onPageHideForLocalSync() {
6358
+ this._flushLocalVersionOnHide(false, true)
6359
+ },
6360
+
6361
+ // The core save-on-leave path — replaces a per-edit autosave (see the
6362
+ // localSyncKey prop's JSDoc for why: an autosave tied to "data changed"
6363
+ // can't distinguish a user's edit from a host loading rows from an API,
6364
+ // and would silently overwrite a user's saved work the moment a host
6365
+ // does the latter). Triggered from mounted()'s visibilitychange/pagehide
6366
+ // listeners and beforeDestroy() — see those for the three trigger points.
6367
+ //
6368
+ // Content-hashes the current `data` and either touches an existing local
6369
+ // version with the same hash (just refreshes its timestamp — "ustiga
6370
+ // yoziladi, alohida versiya qilinmaydi") or creates a brand-new one,
6371
+ // evicting the oldest past localSyncMaxVersions (FIFO — see idbStore.js's
6372
+ // saveOrTouchVersion()). Never throws — IndexedDB failures become
6373
+ // local-sync-error.
6374
+ //
6375
+ // `force` skips the "nothing changed since the last flush" short-circuit
6376
+ // below. The passive triggers (visibilitychange/pagehide/beforeDestroy)
6377
+ // fire often on an idle tab and must NOT wake IndexedDB for no reason, so
6378
+ // they leave it in place. saveLocalVersionNow() — an explicit, infrequent,
6379
+ // host/modal-initiated "checkpoint now" action — passes force so it never
6380
+ // silently does nothing: even unchanged data still gets its existing
6381
+ // version's timestamp refreshed via the same hash-dedup path, rather than
6382
+ // the caller seeing no effect at all.
6383
+ //
6384
+ // `sync` picks hashRows() (main thread) over the Worker-based
6385
+ // hashRowsAsync(). This was a real, observed bug, not a theoretical one:
6386
+ // a Worker round-trip (postMessage out, worker computes, postMessage
6387
+ // back) is one more hop through the event loop than a page that's
6388
+ // actually being torn down (as opposed to merely hidden — e.g. a tab
6389
+ // switch, where the page stays alive indefinitely and the extra hop
6390
+ // costs nothing) can be relied on to survive. visibilitychange/pagehide/
6391
+ // beforeDestroy all fire in exactly that "might be torn down any moment"
6392
+ // window, so they hash synchronously to remove that hop — the whole
6393
+ // flush then only has the unavoidable async part (the IndexedDB write
6394
+ // itself) left, giving it the best realistic chance to land before
6395
+ // teardown. saveLocalVersionNow() (an explicit host/modal action with no
6396
+ // teardown racing it) keeps the Worker, so hashing a large `data` array
6397
+ // doesn't block the main thread during ordinary use.
6398
+ async _flushLocalVersionOnHide(force = false, sync = false) {
6298
6399
  if (!this.localSyncKey) return
6400
+ if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush
6401
+ const data = this.data
6402
+ // Fires independently of the version-save attempt below, started as
6403
+ // early as possible — see _scheduleBufferWrite()'s comment. This is
6404
+ // the save that's actually likely to survive an imminent teardown;
6405
+ // the version-store write below is best-effort on top of it, reliable
6406
+ // for the common case (tab switch/background, page stays alive).
6407
+ clearTimeout(this._bufferWriteTimer)
6408
+ this._writeBufferNow(data)
6299
6409
  try {
6300
- const snapshot = await loadSnapshot(this.localSyncKey)
6301
- if (!snapshot) return
6302
- this.$emit('input', snapshot.data)
6303
- this.$emit('update:data', snapshot.data)
6304
- this.localSyncAvailable = false
6305
- this.$emit('local-sync-restore', { key: this.localSyncKey, rows: snapshot.data.length })
6306
- this.closeLocalSyncModal()
6410
+ const hash = sync ? hashRows(data) : await hashRowsAsync(data)
6411
+ if (this.data !== data) return // data changed again mid-hash — a later flush will catch it
6412
+ const { version, overwritten } = await saveOrTouchVersion(
6413
+ this.localSyncKey, data, hash, this.localSyncMaxVersions
6414
+ )
6415
+ this._lastFlushedDataRef = data
6416
+ // The version store now has this content durably — the buffer's job
6417
+ // for it is done. Best-effort like everything else here: if this
6418
+ // doesn't complete before teardown, _promoteBufferIfAny() finds the
6419
+ // (now-redundant but harmless, dedup makes it a no-op touch) buffer
6420
+ // on next mount anyway.
6421
+ clearBuffer(this.localSyncKey).catch(() => {})
6422
+ this.$emit('local-sync-save', {
6423
+ key: this.localSyncKey,
6424
+ id: version.id,
6425
+ rows: data.length,
6426
+ updatedAt: version.updatedAt,
6427
+ overwritten
6428
+ })
6307
6429
  } catch (error) {
6308
- this.$emit('local-sync-error', { action: 'restore', error })
6430
+ this.$emit('local-sync-error', { action: 'save', error })
6309
6431
  }
6310
6432
  },
6311
6433
 
6312
- // Public, ref-callable: deletes the IndexedDB snapshot — the modal's
6313
- // "Delete" button calls this too (after its own inline confirm step).
6314
- async clearLocalSnapshot() {
6315
- if (!this.localSyncKey) return
6434
+ // Public, ref-callable: runs the save-on-leave flow immediately, for a
6435
+ // host that wants its own explicit "save checkpoint" button instead of
6436
+ // waiting for a page-hide. Always goes through IndexedDB (force — see
6437
+ // _flushLocalVersionOnHide()), but still through the same hash-dedup
6438
+ // logic, so repeated calls with unchanged data don't spam duplicate
6439
+ // versions — they just refresh the existing matching version.
6440
+ async saveLocalVersionNow() {
6441
+ await this._flushLocalVersionOnHide(true)
6442
+ },
6443
+
6444
+ // Public, ref-callable: lightweight metadata for every saved version of
6445
+ // this localSyncKey, newest first — no row data (use getLocalVersion(id)
6446
+ // for that). Powers the built-in modal's list; also usable to build a
6447
+ // custom UI.
6448
+ async listLocalVersions() {
6449
+ if (!this.localSyncKey) return []
6316
6450
  try {
6317
- await deleteSnapshot(this.localSyncKey)
6318
- if (this.localSyncModal.snapshot) this.localSyncModal.snapshot = null
6319
- this.localSyncAvailable = false
6320
- this.$emit('local-sync-clear', { key: this.localSyncKey })
6451
+ const versions = await listVersions(this.localSyncKey)
6452
+ return versions.map(v => ({ id: v.id, createdAt: v.createdAt, updatedAt: v.updatedAt, rowCount: v.data.length }))
6321
6453
  } catch (error) {
6322
- this.$emit('local-sync-error', { action: 'clear', error })
6454
+ this.$emit('local-sync-error', { action: 'load', error })
6455
+ return []
6323
6456
  }
6324
6457
  },
6325
6458
 
6326
- // Public, ref-callable: returns the saved copy's full { data, updatedAt }
6327
- // (or null) WITHOUT applying it to v-model and without opening the
6328
- // built-in modal — the pure "Read" a host needs to build its own custom
6329
- // UI (preview/inspect the saved rows, decide what to do, etc.), as
6330
- // opposed to restoreLocalSnapshot() which reads AND immediately commits
6331
- // the result into v-model as one step.
6332
- async getLocalSnapshot() {
6333
- if (!this.localSyncKey) return null
6459
+ // Public, ref-callable: the full { id, data, createdAt, updatedAt } for
6460
+ // one saved version, WITHOUT applying it to v-model — the pure "Read"
6461
+ // for a custom UI (as opposed to restoreLocalVersion(), which reads AND
6462
+ // commits in one step).
6463
+ async getLocalVersion(id) {
6334
6464
  try {
6335
- return await loadSnapshot(this.localSyncKey)
6465
+ return await getVersion(id)
6336
6466
  } catch (error) {
6337
6467
  this.$emit('local-sync-error', { action: 'load', error })
6338
6468
  return null
6339
6469
  }
6340
6470
  },
6341
6471
 
6342
- // Public, ref-callable: lightweight { updatedAt, rowCount } | null variant
6343
- // of getLocalSnapshot() — for a host that only wants to know whether/when
6344
- // something is saved (e.g. an "unsaved local changes" prompt on mount)
6345
- // without pulling the full row data across.
6346
- async getLocalSnapshotInfo() {
6347
- const snapshot = await this.getLocalSnapshot()
6348
- return snapshot ? { updatedAt: snapshot.updatedAt, rowCount: snapshot.data.length } : null
6472
+ // Public, ref-callable: writes one saved version back into v-model
6473
+ // (input/update:data) — the modal's "Restore" button calls this too.
6474
+ async restoreLocalVersion(id) {
6475
+ try {
6476
+ const version = await this.getLocalVersion(id)
6477
+ if (!version) return
6478
+ this.$emit('input', version.data)
6479
+ this.$emit('update:data', version.data)
6480
+ this._lastFlushedDataRef = version.data // what's now on screen is already what's stored
6481
+ this.localSyncAvailable = false
6482
+ this.$emit('local-sync-restore', { key: this.localSyncKey, id, rows: version.data.length })
6483
+ this.closeLocalSyncModal()
6484
+ } catch (error) {
6485
+ this.$emit('local-sync-error', { action: 'restore', error })
6486
+ }
6487
+ },
6488
+
6489
+ // Public, ref-callable: deletes one saved version — the modal's "Delete"
6490
+ // button calls this too (after its own inline confirm step).
6491
+ async deleteLocalVersion(id) {
6492
+ try {
6493
+ await deleteVersion(id)
6494
+ if (this.localSyncModal.selectedId === id) {
6495
+ this.localSyncModal.selectedId = null
6496
+ this.localSyncModal.selectedVersion = null
6497
+ }
6498
+ this.localSyncModal.versions = this.localSyncModal.versions.filter(v => v.id !== id)
6499
+ this.$emit('local-sync-clear', { key: this.localSyncKey, id })
6500
+ } catch (error) {
6501
+ this.$emit('local-sync-error', { action: 'clear', error })
6502
+ }
6349
6503
  },
6350
6504
 
6351
6505
  // Public, ref-callable: opens the local-sync modal (also reachable via
6352
- // the toolbar button), loading the latest snapshot fresh each time.
6506
+ // the toolbar button), loading the version list fresh each time.
6353
6507
  async openLocalSyncModal() {
6354
6508
  if (!this.localSyncKey) return
6355
6509
  this.localSyncModal.visible = true
6356
- this.localSyncAvailable = false // the draft has now been seen
6357
- try {
6358
- this.localSyncModal.snapshot = await loadSnapshot(this.localSyncKey)
6359
- } catch (error) {
6360
- this.localSyncModal.snapshot = null
6361
- this.$emit('local-sync-error', { action: 'load', error })
6510
+ this.localSyncAvailable = false // the version(s) have now been seen
6511
+ const versions = await this.listLocalVersions()
6512
+ this.localSyncModal.versions = versions
6513
+ await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)
6514
+ },
6515
+
6516
+ // Internal: modal's list-selection handler — loads the selected
6517
+ // version's full data for the nested-grid preview (EgLocalSyncModal.vue).
6518
+ async _selectLocalSyncModalVersion(id) {
6519
+ this.localSyncModal.selectedId = id
6520
+ this.localSyncModal.selectedVersion = id ? await this.getLocalVersion(id) : null
6521
+ },
6522
+
6523
+ // Modal's "Save" button — same as saveLocalVersionNow(), then refreshes
6524
+ // the modal's own list/selection in place so the user sees it happened.
6525
+ async _onLocalSyncModalSave() {
6526
+ await this.saveLocalVersionNow()
6527
+ const versions = await this.listLocalVersions()
6528
+ this.localSyncModal.versions = versions
6529
+ await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)
6530
+ },
6531
+
6532
+ // Modal's "Delete" button (after its own inline confirm) — deletes the
6533
+ // given version, then selects the next-most-recent one if the deleted
6534
+ // one was selected.
6535
+ async _onLocalSyncModalDelete(id) {
6536
+ await this.deleteLocalVersion(id)
6537
+ if (this.localSyncModal.selectedId == null) {
6538
+ const remaining = this.localSyncModal.versions
6539
+ await this._selectLocalSyncModalVersion(remaining.length ? remaining[0].id : null)
6362
6540
  }
6363
6541
  },
6364
6542
 
@@ -6371,6 +6549,7 @@ const ExcelGridComponent = {
6371
6549
  this.internalLocked = !this.internalLocked
6372
6550
  this.$emit('update:locked', this.internalLocked)
6373
6551
  this.$emit('lock-change', { locked: this.internalLocked })
6552
+ this._flashLock(this.internalLocked)
6374
6553
  },
6375
6554
 
6376
6555
  // Pops a big lock/unlock icon in the center of the grid and fades it