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.
- package/README.md +1 -1
- package/dist/vue2-excel-grid.esm.js +1211 -387
- package/dist/vue2-excel-grid.umd.js +3 -3
- package/package.json +1 -1
- package/src/components/EgLocalSyncModal.vue +99 -48
- package/src/components/ExcelGrid.vue +325 -146
- package/src/utils/dataHash.js +139 -0
- package/src/utils/i18n.js +32 -28
- package/src/utils/idbStore.js +174 -19
|
@@ -820,13 +820,16 @@
|
|
|
820
820
|
:visible="localSyncModal.visible"
|
|
821
821
|
:grid-options="_selfComponentDef"
|
|
822
822
|
:columns="_localSyncPreviewColumns"
|
|
823
|
-
:
|
|
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="
|
|
829
|
-
@clear="
|
|
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 {
|
|
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
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
1420
|
-
*
|
|
1421
|
-
*
|
|
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
|
|
1425
|
-
* entirely to opt out completely: no IndexedDB access happens
|
|
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
|
|
1429
|
-
* `
|
|
1430
|
-
* `
|
|
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
|
|
1510
|
-
//
|
|
1511
|
-
|
|
1512
|
-
//
|
|
1513
|
-
|
|
1514
|
-
//
|
|
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) {
|
|
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.
|
|
2454
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 —
|
|
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
|
|
6225
|
-
//
|
|
6226
|
-
//
|
|
6227
|
-
|
|
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
|
-
|
|
6241
|
-
|
|
6242
|
-
this.
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
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
|
-
|
|
6254
|
-
|
|
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
|
-
|
|
6264
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
6283
|
-
//
|
|
6284
|
-
//
|
|
6285
|
-
//
|
|
6286
|
-
//
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
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
|
-
//
|
|
6295
|
-
//
|
|
6296
|
-
|
|
6297
|
-
|
|
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
|
|
6301
|
-
if (
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
this
|
|
6306
|
-
this
|
|
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: '
|
|
6430
|
+
this.$emit('local-sync-error', { action: 'save', error })
|
|
6309
6431
|
}
|
|
6310
6432
|
},
|
|
6311
6433
|
|
|
6312
|
-
// Public, ref-callable:
|
|
6313
|
-
//
|
|
6314
|
-
|
|
6315
|
-
|
|
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
|
|
6318
|
-
|
|
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: '
|
|
6454
|
+
this.$emit('local-sync-error', { action: 'load', error })
|
|
6455
|
+
return []
|
|
6323
6456
|
}
|
|
6324
6457
|
},
|
|
6325
6458
|
|
|
6326
|
-
// Public, ref-callable:
|
|
6327
|
-
//
|
|
6328
|
-
//
|
|
6329
|
-
//
|
|
6330
|
-
|
|
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
|
|
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:
|
|
6343
|
-
//
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
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
|
|
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
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
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
|