vue2-excel-grid 1.7.2 → 2.0.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.
@@ -61,11 +61,12 @@
61
61
  <button
62
62
  v-if="localSyncKey && toolbarHas('localSync')"
63
63
  type="button"
64
- class="eg-toolbar-btn"
64
+ class="eg-toolbar-btn eg-toolbar-btn-badged"
65
65
  :disabled="toolbarDisabled('localSync')"
66
- :title="t.localSync"
66
+ :title="localSyncAvailable ? t.localSyncAvailable : t.localSync"
67
67
  @click="openLocalSyncModal"
68
- ><span class="eg-toolbar-icon eg-icon-local-sync"></span></button>
68
+ ><span class="eg-toolbar-icon eg-icon-local-sync"></span
69
+ ><span v-if="localSyncAvailable" class="eg-toolbar-badge" aria-hidden="true"></span></button>
69
70
  </div>
70
71
 
71
72
  <slot name="toolbar-extra" :locked="internalLocked" :fullscreen="isFullscreen"></slot>
@@ -819,13 +820,16 @@
819
820
  :visible="localSyncModal.visible"
820
821
  :grid-options="_selfComponentDef"
821
822
  :columns="_localSyncPreviewColumns"
822
- :snapshot="localSyncModal.snapshot"
823
+ :versions="localSyncModal.versions"
824
+ :selected-id="localSyncModal.selectedId"
825
+ :selected-version="localSyncModal.selectedVersion"
823
826
  :locale="locale"
824
827
  :theme="internalTheme"
825
828
  @close="closeLocalSyncModal"
829
+ @select="_selectLocalSyncModalVersion"
826
830
  @save="_onLocalSyncModalSave"
827
- @restore="restoreLocalSnapshot"
828
- @clear="clearLocalSnapshot"
831
+ @restore="restoreLocalVersion"
832
+ @clear="_onLocalSyncModalDelete"
829
833
  />
830
834
 
831
835
  <!-- Lock/unlock state feedback — brief centered icon flash -->
@@ -943,7 +947,8 @@ import EgLocalSyncModal from './EgLocalSyncModal.vue'
943
947
  import { parseDateValue, formatDate } from '../utils/dateUtils.js'
944
948
  import { formatNumber as _formatNumber } from '../utils/numberFormat.js'
945
949
  import { Mask, MaskInput } from '../utils/mask.js'
946
- import { saveSnapshot, loadSnapshot, deleteSnapshot } from '../utils/idbStore.js'
950
+ import { listVersions, getVersion, deleteVersion, saveOrTouchVersion } from '../utils/idbStore.js'
951
+ import { hashRowsAsync } from '../utils/dataHash.js'
947
952
 
948
953
  const SIZE_CONFIG = {
949
954
  small: { row: 22, header: 24, footer: 24, filter: 26 },
@@ -1408,34 +1413,48 @@ const ExcelGridComponent = {
1408
1413
  sort: { type: Object, default: null },
1409
1414
 
1410
1415
  /**
1411
- * Unique key that opts the grid into local autosave: whenever `data`
1412
- * changes, a debounced (~800ms) copy is written to the browser's
1413
- * IndexedDB, keyed by this string — so a page reload/crash/tab close
1414
- * doesn't lose in-progress work. Also adds a toolbar button ('localSync'
1415
- * — see the `toolbar` prop's `list`/`disabled`) that opens a modal
1416
- * showing the last saved copy as a read-only table, with three actions:
1417
- * **Save** (pushes the current `v-model` into the saved copy right now,
1418
- * bypassing the debounce), **Restore** (the other direction — pulls the
1419
- * saved copy back into `v-model`), and **Delete** (clears the saved
1420
- * 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.
1421
1428
  *
1422
1429
  * Two different grid instances must use different keys — the same key
1423
- * means "the same saved copy" (last writer wins). Omit this prop
1424
- * entirely to opt out completely: no IndexedDB access happens at all,
1425
- * 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.
1426
1433
  *
1427
- * See also the ref-callable methods `saveLocalSnapshot()`,
1428
- * `restoreLocalSnapshot()`, `clearLocalSnapshot()`, `getLocalSnapshot()`,
1429
- * `getLocalSnapshotInfo()`, `openLocalSyncModal()`/`closeLocalSyncModal()`,
1430
- * and the
1431
- * `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`
1432
1439
  * events — [details](/local-sync).
1433
1440
  * @type {String}
1434
1441
  * @default null
1435
1442
  * @example
1436
1443
  * <excel-grid :columns="columns" v-model="data" local-sync-key="invoice-42" toolbar />
1437
1444
  */
1438
- 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 }
1439
1458
  },
1440
1459
 
1441
1460
  data() {
@@ -1505,9 +1524,15 @@ const ExcelGridComponent = {
1505
1524
  importState: { active: false, percent: 0, message: '' },
1506
1525
  exportState: { active: false },
1507
1526
 
1508
- // localSyncKey feature — modal state + pending debounce timer (the
1509
- // timer itself lives outside `data()`'s reactive tracking, see created()).
1510
- localSyncModal: { visible: false, snapshot: null },
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.
1535
+ localSyncAvailable: false,
1511
1536
 
1512
1537
  resizing: {
1513
1538
  active: false,
@@ -2265,13 +2290,6 @@ const ExcelGridComponent = {
2265
2290
  this.$nextTick(() => this.updateVirtualScroll())
2266
2291
  },
2267
2292
 
2268
- // Guarded by _lockFlashArmed (set once mounted()) so an initial `locked:
2269
- // true` prop doesn't flash the icon before the grid has even appeared.
2270
- internalLocked(v) {
2271
- if (!this._lockFlashArmed) return
2272
- this._flashLock(v)
2273
- },
2274
-
2275
2293
  autoWidth(v) {
2276
2294
  if (v) this._applyAutoWidth()
2277
2295
  },
@@ -2342,9 +2360,21 @@ const ExcelGridComponent = {
2342
2360
  immediate: true,
2343
2361
  handler(v) { this.internalCollapsedGroups = v ? [...v] : [] }
2344
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".)
2345
2371
  locked: {
2346
2372
  immediate: true,
2347
- 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
+ }
2348
2378
  },
2349
2379
  data: {
2350
2380
  immediate: true,
@@ -2414,14 +2444,12 @@ const ExcelGridComponent = {
2414
2444
  this._dragScrollRAF = null
2415
2445
  this._dragCleanupTimer = null
2416
2446
 
2417
- // localSyncKey autosave — deliberately a plain (non-immediate) $watch,
2418
- // NOT an entry in the `watch:` option above: the existing `data` watcher
2419
- // there runs with `immediate: true` (fires once on mount with the
2420
- // initial value), which would autosave a host's still-loading/empty
2421
- // initial `data` and silently overwrite a previously-saved IndexedDB
2422
- // snapshot before the user ever gets a chance to see/restore it via the
2423
- // modal. A plain $watch only fires on later, real changes.
2424
- this.$watch('data', () => this._scheduleLocalAutosave())
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
2425
2453
  },
2426
2454
 
2427
2455
  mounted() {
@@ -2453,7 +2481,17 @@ const ExcelGridComponent = {
2453
2481
  // in both modes) fresh when the host's layout changes.
2454
2482
  if (this.$el.parentElement) this._scrollBodyResizeObserver.observe(this.$el.parentElement)
2455
2483
  }
2456
- this._lockFlashArmed = true
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)
2457
2495
  },
2458
2496
 
2459
2497
  updated() {
@@ -2488,7 +2526,6 @@ const ExcelGridComponent = {
2488
2526
  clearTimeout(this._onInputTimer)
2489
2527
  clearTimeout(this._dragCleanupTimer)
2490
2528
  clearTimeout(this._widthSyncChainResetTimer)
2491
- clearTimeout(this._localSyncTimer)
2492
2529
  if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)
2493
2530
  document.removeEventListener('mousemove', this.onGlobalMouseMove)
2494
2531
  document.removeEventListener('mouseup', this.onGlobalMouseUp)
@@ -2496,6 +2533,11 @@ const ExcelGridComponent = {
2496
2533
  document.removeEventListener('mousedown', this._onDocMouseDownForDropdown, true)
2497
2534
  window.removeEventListener('resize', this.syncHeaderViewportWidth)
2498
2535
  window.removeEventListener('scroll', this._onWindowScrollForDropdown, true)
2536
+ document.removeEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)
2537
+ window.removeEventListener('pagehide', this._onPageHideForLocalSync)
2538
+ // Best-effort — the component may be torn down (SPA route change) without
2539
+ // the page itself unloading, which visibilitychange/pagehide won't catch.
2540
+ this._flushLocalVersionOnHide()
2499
2541
  if (this.isFullscreen) {
2500
2542
  document.body.style.overflow = this._prevBodyOverflow || ''
2501
2543
  document.removeEventListener('keydown', this._onFullscreenKeyDown)
@@ -6216,113 +6258,190 @@ const ExcelGridComponent = {
6216
6258
  },
6217
6259
 
6218
6260
  // =========================================================
6219
- // LOCAL SYNC — autosave `data` to IndexedDB, keyed by localSyncKey
6261
+ // LOCAL SYNC — save `data` as a local IndexedDB *version*, keyed by
6262
+ // localSyncKey, only when the page is being left (not per-edit).
6220
6263
  // =========================================================
6221
6264
 
6222
- // Internal: debounced (800ms) autosave, scheduled by the plain $watch
6223
- // set up in created() every time `data` actually changes. No-op when
6224
- // localSyncKey isn't set — zero IndexedDB access at all in that case.
6225
- _scheduleLocalAutosave() {
6265
+ // Internal, called once from mounted(): populates the toolbar "unseen
6266
+ // local versions" dot and emits local-sync-available if any versions
6267
+ // already exist for this key (e.g. saved in an earlier visit).
6268
+ async _loadLocalVersionsOnMount() {
6226
6269
  if (!this.localSyncKey) return
6227
- clearTimeout(this._localSyncTimer)
6228
- this._localSyncTimer = setTimeout(() => {
6229
- this.saveLocalSnapshot()
6230
- }, 800)
6231
- },
6232
-
6233
- // Public, ref-callable: saves the current `data` to IndexedDB right now,
6234
- // bypassing the debounce (e.g. a host wants to force a checkpoint before
6235
- // navigating away). No-op (resolves to null) when localSyncKey isn't set.
6236
- async saveLocalSnapshot() {
6237
- if (!this.localSyncKey) return null
6238
- clearTimeout(this._localSyncTimer)
6239
6270
  try {
6240
- const { updatedAt } = await saveSnapshot(this.localSyncKey, this.data)
6241
- this.$emit('local-sync-save', { key: this.localSyncKey, rows: this.data.length, updatedAt })
6242
- return updatedAt
6271
+ const versions = await listVersions(this.localSyncKey)
6272
+ if (!versions.length) return
6273
+ this.localSyncAvailable = true
6274
+ this.$emit('local-sync-available', {
6275
+ key: this.localSyncKey,
6276
+ versions: versions.length,
6277
+ mostRecentUpdatedAt: versions[0].updatedAt
6278
+ })
6243
6279
  } catch (error) {
6244
- this.$emit('local-sync-error', { action: 'save', error })
6245
- return null
6246
- }
6247
- },
6248
-
6249
- // Modal's "Save" button (push v-model -> IndexedDB — the other direction
6250
- // from "Restore", which pulls IndexedDB -> v-model). Unlike
6251
- // restoreLocalSnapshot(), this deliberately does NOT close the modal —
6252
- // the freshly-saved snapshot is reflected in place (updated timestamp/
6253
- // row count/preview) so the user sees the save actually happened.
6254
- async _onLocalSyncModalSave() {
6255
- const updatedAt = await this.saveLocalSnapshot()
6256
- if (updatedAt != null) {
6257
- this.localSyncModal.snapshot = { data: this.data, updatedAt }
6280
+ this.$emit('local-sync-error', { action: 'load', error })
6258
6281
  }
6259
6282
  },
6260
6283
 
6261
- // Public, ref-callable: loads the IndexedDB snapshot and writes it back
6262
- // into v-model (input/update:data) — the modal's "Restore" button calls
6263
- // this too. No-op if localSyncKey isn't set or nothing is saved yet.
6264
- async restoreLocalSnapshot() {
6284
+ _onVisibilityChangeForLocalSync() {
6285
+ if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide()
6286
+ },
6287
+
6288
+ _onPageHideForLocalSync() {
6289
+ this._flushLocalVersionOnHide()
6290
+ },
6291
+
6292
+ // The core save-on-leave path — replaces a per-edit autosave (see the
6293
+ // localSyncKey prop's JSDoc for why: an autosave tied to "data changed"
6294
+ // can't distinguish a user's edit from a host loading rows from an API,
6295
+ // and would silently overwrite a user's saved work the moment a host
6296
+ // does the latter). Triggered from mounted()'s visibilitychange/pagehide
6297
+ // listeners and beforeDestroy() — see those for the three trigger points.
6298
+ //
6299
+ // Content-hashes the current `data` (via a Web Worker when available —
6300
+ // src/utils/dataHash.js — falling back to the same algorithm
6301
+ // synchronously otherwise, so this always resolves) and either touches
6302
+ // an existing local version with the same hash (just refreshes its
6303
+ // timestamp — "ustiga yoziladi, alohida versiya qilinmaydi") or creates
6304
+ // a brand-new one, evicting the oldest past localSyncMaxVersions (FIFO —
6305
+ // see idbStore.js's saveOrTouchVersion()). Never throws — IndexedDB
6306
+ // failures become local-sync-error.
6307
+ //
6308
+ // `force` skips the "nothing changed since the last flush" short-circuit
6309
+ // below. The passive triggers (visibilitychange/pagehide/beforeDestroy)
6310
+ // fire often on an idle tab and must NOT wake the worker/IndexedDB for
6311
+ // no reason, so they leave it in place. saveLocalVersionNow() — an
6312
+ // explicit, infrequent, host/modal-initiated "checkpoint now" action —
6313
+ // passes force so it never silently does nothing: even unchanged data
6314
+ // still gets its existing version's timestamp refreshed via the same
6315
+ // hash-dedup path, rather than the caller seeing no effect at all.
6316
+ async _flushLocalVersionOnHide(force = false) {
6265
6317
  if (!this.localSyncKey) return
6318
+ if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush
6319
+ const data = this.data
6266
6320
  try {
6267
- const snapshot = await loadSnapshot(this.localSyncKey)
6268
- if (!snapshot) return
6269
- this.$emit('input', snapshot.data)
6270
- this.$emit('update:data', snapshot.data)
6271
- this.$emit('local-sync-restore', { key: this.localSyncKey, rows: snapshot.data.length })
6272
- this.closeLocalSyncModal()
6321
+ const hash = await hashRowsAsync(data)
6322
+ if (this.data !== data) return // data changed again mid-hash — a later flush will catch it
6323
+ const { version, overwritten } = await saveOrTouchVersion(
6324
+ this.localSyncKey, data, hash, this.localSyncMaxVersions
6325
+ )
6326
+ this._lastFlushedDataRef = data
6327
+ this.$emit('local-sync-save', {
6328
+ key: this.localSyncKey,
6329
+ id: version.id,
6330
+ rows: data.length,
6331
+ updatedAt: version.updatedAt,
6332
+ overwritten
6333
+ })
6273
6334
  } catch (error) {
6274
- this.$emit('local-sync-error', { action: 'restore', error })
6335
+ this.$emit('local-sync-error', { action: 'save', error })
6275
6336
  }
6276
6337
  },
6277
6338
 
6278
- // Public, ref-callable: deletes the IndexedDB snapshot — the modal's
6279
- // "Delete" button calls this too (after its own inline confirm step).
6280
- async clearLocalSnapshot() {
6281
- if (!this.localSyncKey) return
6339
+ // Public, ref-callable: runs the save-on-leave flow immediately, for a
6340
+ // host that wants its own explicit "save checkpoint" button instead of
6341
+ // waiting for a page-hide. Always goes through IndexedDB (force — see
6342
+ // _flushLocalVersionOnHide()), but still through the same hash-dedup
6343
+ // logic, so repeated calls with unchanged data don't spam duplicate
6344
+ // versions — they just refresh the existing matching version.
6345
+ async saveLocalVersionNow() {
6346
+ await this._flushLocalVersionOnHide(true)
6347
+ },
6348
+
6349
+ // Public, ref-callable: lightweight metadata for every saved version of
6350
+ // this localSyncKey, newest first — no row data (use getLocalVersion(id)
6351
+ // for that). Powers the built-in modal's list; also usable to build a
6352
+ // custom UI.
6353
+ async listLocalVersions() {
6354
+ if (!this.localSyncKey) return []
6282
6355
  try {
6283
- await deleteSnapshot(this.localSyncKey)
6284
- if (this.localSyncModal.snapshot) this.localSyncModal.snapshot = null
6285
- this.$emit('local-sync-clear', { key: this.localSyncKey })
6356
+ const versions = await listVersions(this.localSyncKey)
6357
+ return versions.map(v => ({ id: v.id, createdAt: v.createdAt, updatedAt: v.updatedAt, rowCount: v.data.length }))
6286
6358
  } catch (error) {
6287
- this.$emit('local-sync-error', { action: 'clear', error })
6359
+ this.$emit('local-sync-error', { action: 'load', error })
6360
+ return []
6288
6361
  }
6289
6362
  },
6290
6363
 
6291
- // Public, ref-callable: returns the saved copy's full { data, updatedAt }
6292
- // (or null) WITHOUT applying it to v-model and without opening the
6293
- // built-in modal — the pure "Read" a host needs to build its own custom
6294
- // UI (preview/inspect the saved rows, decide what to do, etc.), as
6295
- // opposed to restoreLocalSnapshot() which reads AND immediately commits
6296
- // the result into v-model as one step.
6297
- async getLocalSnapshot() {
6298
- if (!this.localSyncKey) return null
6364
+ // Public, ref-callable: the full { id, data, createdAt, updatedAt } for
6365
+ // one saved version, WITHOUT applying it to v-model — the pure "Read"
6366
+ // for a custom UI (as opposed to restoreLocalVersion(), which reads AND
6367
+ // commits in one step).
6368
+ async getLocalVersion(id) {
6299
6369
  try {
6300
- return await loadSnapshot(this.localSyncKey)
6370
+ return await getVersion(id)
6301
6371
  } catch (error) {
6302
6372
  this.$emit('local-sync-error', { action: 'load', error })
6303
6373
  return null
6304
6374
  }
6305
6375
  },
6306
6376
 
6307
- // Public, ref-callable: lightweight { updatedAt, rowCount } | null variant
6308
- // of getLocalSnapshot() — for a host that only wants to know whether/when
6309
- // something is saved (e.g. an "unsaved local changes" prompt on mount)
6310
- // without pulling the full row data across.
6311
- async getLocalSnapshotInfo() {
6312
- const snapshot = await this.getLocalSnapshot()
6313
- return snapshot ? { updatedAt: snapshot.updatedAt, rowCount: snapshot.data.length } : null
6377
+ // Public, ref-callable: writes one saved version back into v-model
6378
+ // (input/update:data) — the modal's "Restore" button calls this too.
6379
+ async restoreLocalVersion(id) {
6380
+ try {
6381
+ const version = await this.getLocalVersion(id)
6382
+ if (!version) return
6383
+ this.$emit('input', version.data)
6384
+ this.$emit('update:data', version.data)
6385
+ this._lastFlushedDataRef = version.data // what's now on screen is already what's stored
6386
+ this.localSyncAvailable = false
6387
+ this.$emit('local-sync-restore', { key: this.localSyncKey, id, rows: version.data.length })
6388
+ this.closeLocalSyncModal()
6389
+ } catch (error) {
6390
+ this.$emit('local-sync-error', { action: 'restore', error })
6391
+ }
6392
+ },
6393
+
6394
+ // Public, ref-callable: deletes one saved version — the modal's "Delete"
6395
+ // button calls this too (after its own inline confirm step).
6396
+ async deleteLocalVersion(id) {
6397
+ try {
6398
+ await deleteVersion(id)
6399
+ if (this.localSyncModal.selectedId === id) {
6400
+ this.localSyncModal.selectedId = null
6401
+ this.localSyncModal.selectedVersion = null
6402
+ }
6403
+ this.localSyncModal.versions = this.localSyncModal.versions.filter(v => v.id !== id)
6404
+ this.$emit('local-sync-clear', { key: this.localSyncKey, id })
6405
+ } catch (error) {
6406
+ this.$emit('local-sync-error', { action: 'clear', error })
6407
+ }
6314
6408
  },
6315
6409
 
6316
6410
  // Public, ref-callable: opens the local-sync modal (also reachable via
6317
- // the toolbar button), loading the latest snapshot fresh each time.
6411
+ // the toolbar button), loading the version list fresh each time.
6318
6412
  async openLocalSyncModal() {
6319
6413
  if (!this.localSyncKey) return
6320
6414
  this.localSyncModal.visible = true
6321
- try {
6322
- this.localSyncModal.snapshot = await loadSnapshot(this.localSyncKey)
6323
- } catch (error) {
6324
- this.localSyncModal.snapshot = null
6325
- this.$emit('local-sync-error', { action: 'load', error })
6415
+ this.localSyncAvailable = false // the version(s) have now been seen
6416
+ const versions = await this.listLocalVersions()
6417
+ this.localSyncModal.versions = versions
6418
+ await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)
6419
+ },
6420
+
6421
+ // Internal: modal's list-selection handler — loads the selected
6422
+ // version's full data for the nested-grid preview (EgLocalSyncModal.vue).
6423
+ async _selectLocalSyncModalVersion(id) {
6424
+ this.localSyncModal.selectedId = id
6425
+ this.localSyncModal.selectedVersion = id ? await this.getLocalVersion(id) : null
6426
+ },
6427
+
6428
+ // Modal's "Save" button — same as saveLocalVersionNow(), then refreshes
6429
+ // the modal's own list/selection in place so the user sees it happened.
6430
+ async _onLocalSyncModalSave() {
6431
+ await this.saveLocalVersionNow()
6432
+ const versions = await this.listLocalVersions()
6433
+ this.localSyncModal.versions = versions
6434
+ await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)
6435
+ },
6436
+
6437
+ // Modal's "Delete" button (after its own inline confirm) — deletes the
6438
+ // given version, then selects the next-most-recent one if the deleted
6439
+ // one was selected.
6440
+ async _onLocalSyncModalDelete(id) {
6441
+ await this.deleteLocalVersion(id)
6442
+ if (this.localSyncModal.selectedId == null) {
6443
+ const remaining = this.localSyncModal.versions
6444
+ await this._selectLocalSyncModalVersion(remaining.length ? remaining[0].id : null)
6326
6445
  }
6327
6446
  },
6328
6447
 
@@ -6335,6 +6454,7 @@ const ExcelGridComponent = {
6335
6454
  this.internalLocked = !this.internalLocked
6336
6455
  this.$emit('update:locked', this.internalLocked)
6337
6456
  this.$emit('lock-change', { locked: this.internalLocked })
6457
+ this._flashLock(this.internalLocked)
6338
6458
  },
6339
6459
 
6340
6460
  // Pops a big lock/unlock icon in the center of the grid and fades it
@@ -6789,6 +6909,22 @@ export default ExcelGridComponent
6789
6909
  color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));
6790
6910
  }
6791
6911
 
6912
+ /* Anchors the "unseen local draft" dot (localSyncKey feature) — only the
6913
+ local-sync button carries it, but kept as its own class so any future
6914
+ badged tool gets the same positioning. */
6915
+ .eg-toolbar-btn-badged {
6916
+ position: relative;
6917
+ }
6918
+ .eg-toolbar-badge {
6919
+ position: absolute;
6920
+ top: 2px;
6921
+ right: 2px;
6922
+ width: 7px;
6923
+ height: 7px;
6924
+ border-radius: 50%;
6925
+ background: var(--eg-color-accent, var(--eg-color-accent-default));
6926
+ pointer-events: none;
6927
+ }
6792
6928
  .eg-toolbar-btn:hover:not(:disabled) {
6793
6929
  background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));
6794
6930
  border-color: var(--eg-color-border, var(--eg-color-border-default));