vue2-excel-grid 2.0.0 → 2.0.2
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/dist/vue2-excel-grid.esm.js +519 -219
- package/dist/vue2-excel-grid.umd.js +2 -2
- package/package.json +1 -1
- package/src/components/ExcelGrid.vue +134 -22
- package/src/utils/idbStore.js +54 -1
package/package.json
CHANGED
|
@@ -947,8 +947,8 @@ import EgLocalSyncModal from './EgLocalSyncModal.vue'
|
|
|
947
947
|
import { parseDateValue, formatDate } from '../utils/dateUtils.js'
|
|
948
948
|
import { formatNumber as _formatNumber } from '../utils/numberFormat.js'
|
|
949
949
|
import { Mask, MaskInput } from '../utils/mask.js'
|
|
950
|
-
import { listVersions, getVersion, deleteVersion, saveOrTouchVersion } from '../utils/idbStore.js'
|
|
951
|
-
import { hashRowsAsync } from '../utils/dataHash.js'
|
|
950
|
+
import { listVersions, getVersion, deleteVersion, saveOrTouchVersion, putBuffer, getBuffer, clearBuffer } from '../utils/idbStore.js'
|
|
951
|
+
import { hashRows, hashRowsAsync } from '../utils/dataHash.js'
|
|
952
952
|
|
|
953
953
|
const SIZE_CONFIG = {
|
|
954
954
|
small: { row: 22, header: 24, footer: 24, filter: 26 },
|
|
@@ -2402,7 +2402,20 @@ const ExcelGridComponent = {
|
|
|
2402
2402
|
naturalGridWidth() {
|
|
2403
2403
|
if (this.widthMode === 'auto') this.$nextTick(this.syncHeaderViewportWidth)
|
|
2404
2404
|
},
|
|
2405
|
-
widthMode() { this.$nextTick(this.syncHeaderViewportWidth) }
|
|
2405
|
+
widthMode() { this.$nextTick(this.syncHeaderViewportWidth) },
|
|
2406
|
+
|
|
2407
|
+
// localSyncKey feature — mounted() only calls _loadLocalVersionsOnMount()
|
|
2408
|
+
// once, at mount time. Without this watcher, a key that starts empty (a
|
|
2409
|
+
// host conditionally passing `:local-sync-key="ready ? id : null"`, or
|
|
2410
|
+
// one only known after an async load) and later becomes truthy would
|
|
2411
|
+
// never load its version list, never promote a leftover buffer, and
|
|
2412
|
+
// never show the toolbar badge — the feature would look "broken" for the
|
|
2413
|
+
// rest of that page's lifetime even though everything else works.
|
|
2414
|
+
localSyncKey(newKey, oldKey) {
|
|
2415
|
+
if (newKey === oldKey) return
|
|
2416
|
+
this.localSyncAvailable = false
|
|
2417
|
+
if (newKey) this._loadLocalVersionsOnMount()
|
|
2418
|
+
}
|
|
2406
2419
|
},
|
|
2407
2420
|
|
|
2408
2421
|
created() {
|
|
@@ -2526,6 +2539,7 @@ const ExcelGridComponent = {
|
|
|
2526
2539
|
clearTimeout(this._onInputTimer)
|
|
2527
2540
|
clearTimeout(this._dragCleanupTimer)
|
|
2528
2541
|
clearTimeout(this._widthSyncChainResetTimer)
|
|
2542
|
+
clearTimeout(this._bufferWriteTimer)
|
|
2529
2543
|
if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)
|
|
2530
2544
|
document.removeEventListener('mousemove', this.onGlobalMouseMove)
|
|
2531
2545
|
document.removeEventListener('mouseup', this.onGlobalMouseUp)
|
|
@@ -2537,7 +2551,10 @@ const ExcelGridComponent = {
|
|
|
2537
2551
|
window.removeEventListener('pagehide', this._onPageHideForLocalSync)
|
|
2538
2552
|
// Best-effort — the component may be torn down (SPA route change) without
|
|
2539
2553
|
// the page itself unloading, which visibilitychange/pagehide won't catch.
|
|
2540
|
-
|
|
2554
|
+
// sync: true — see _flushLocalVersionOnHide()'s comment; a component
|
|
2555
|
+
// being destroyed is exactly the same "might not survive an async hop"
|
|
2556
|
+
// situation as the other two triggers.
|
|
2557
|
+
this._flushLocalVersionOnHide(false, true)
|
|
2541
2558
|
if (this.isFullscreen) {
|
|
2542
2559
|
document.body.style.overflow = this._prevBodyOverflow || ''
|
|
2543
2560
|
document.removeEventListener('keydown', this._onFullscreenKeyDown)
|
|
@@ -6055,6 +6072,7 @@ const ExcelGridComponent = {
|
|
|
6055
6072
|
// All edit paths already use spread ({ ...row }) so frozen rows are safe.
|
|
6056
6073
|
const data = result.data.map(row => Object.freeze(row))
|
|
6057
6074
|
this.$emit('input', data)
|
|
6075
|
+
this._scheduleBufferWrite(data)
|
|
6058
6076
|
this.$emit('update:columns', columns)
|
|
6059
6077
|
|
|
6060
6078
|
const hasMerges = merges && merges.length > 0
|
|
@@ -6170,6 +6188,7 @@ const ExcelGridComponent = {
|
|
|
6170
6188
|
}
|
|
6171
6189
|
}
|
|
6172
6190
|
this.$emit('input', newData)
|
|
6191
|
+
this._scheduleBufferWrite(newData)
|
|
6173
6192
|
},
|
|
6174
6193
|
|
|
6175
6194
|
// Emit 'update:merges' and record a merges diff entry.
|
|
@@ -6203,6 +6222,7 @@ const ExcelGridComponent = {
|
|
|
6203
6222
|
newData.length = entry.before.rowCount
|
|
6204
6223
|
}
|
|
6205
6224
|
this.$emit('input', newData)
|
|
6225
|
+
this._scheduleBufferWrite(newData)
|
|
6206
6226
|
} else if (entry.type === 'merges') {
|
|
6207
6227
|
this.internalMerges = entry.before.slice()
|
|
6208
6228
|
this.$emit('update:merges', entry.before.slice())
|
|
@@ -6225,6 +6245,7 @@ const ExcelGridComponent = {
|
|
|
6225
6245
|
newData[entry.after.indices[i]] = entry.after.rows[i]
|
|
6226
6246
|
}
|
|
6227
6247
|
this.$emit('input', newData)
|
|
6248
|
+
this._scheduleBufferWrite(newData)
|
|
6228
6249
|
} else if (entry.type === 'merges') {
|
|
6229
6250
|
this.internalMerges = entry.after.slice()
|
|
6230
6251
|
this.$emit('update:merges', entry.after.slice())
|
|
@@ -6267,6 +6288,7 @@ const ExcelGridComponent = {
|
|
|
6267
6288
|
// already exist for this key (e.g. saved in an earlier visit).
|
|
6268
6289
|
async _loadLocalVersionsOnMount() {
|
|
6269
6290
|
if (!this.localSyncKey) return
|
|
6291
|
+
await this._promoteBufferIfAny()
|
|
6270
6292
|
try {
|
|
6271
6293
|
const versions = await listVersions(this.localSyncKey)
|
|
6272
6294
|
if (!versions.length) return
|
|
@@ -6281,12 +6303,76 @@ const ExcelGridComponent = {
|
|
|
6281
6303
|
}
|
|
6282
6304
|
},
|
|
6283
6305
|
|
|
6306
|
+
// Debounced (800ms), called from the grid's own real-change points
|
|
6307
|
+
// (_emitDataChange, import, undo, redo — the exact same "user worked"
|
|
6308
|
+
// signal _flushLocalVersionOnHide() relies on, never a $watch on `data`,
|
|
6309
|
+
// for the same reason spelled out on the localSyncKey prop's JSDoc: a
|
|
6310
|
+
// host loading rows from an API must never look like a user edit).
|
|
6311
|
+
//
|
|
6312
|
+
// Writes to IndexedDB's 'buffer' store (idbStore.js — one row per key, no
|
|
6313
|
+
// hash, no history) at a moment with no teardown race at all — the user
|
|
6314
|
+
// is actively still on the page. This is what actually fixes reload/
|
|
6315
|
+
// close data loss: a version-store write ATTEMPTED at the leave-signal
|
|
6316
|
+
// has no guarantee of finishing before an actual page teardown (verified
|
|
6317
|
+
// directly — even a trivial, unrelated IndexedDB write failed to survive
|
|
6318
|
+
// a real page.reload() in Chromium), but a buffer write that already
|
|
6319
|
+
// completed minutes or seconds earlier, during ordinary editing, needs no
|
|
6320
|
+
// such guarantee — it's already durably there. _promoteBufferIfAny()
|
|
6321
|
+
// turns it into a real version next time the grid mounts.
|
|
6322
|
+
_scheduleBufferWrite(data) {
|
|
6323
|
+
if (!this.localSyncKey) return
|
|
6324
|
+
const key = this.localSyncKey // captured now, not re-read 800ms later —
|
|
6325
|
+
// a host that swaps localSyncKey mid-edit (rare, but now reactive, see
|
|
6326
|
+
// the localSyncKey watcher above) must not have this land under the
|
|
6327
|
+
// NEW key with the OLD key's data.
|
|
6328
|
+
clearTimeout(this._bufferWriteTimer)
|
|
6329
|
+
this._bufferWriteTimer = setTimeout(() => { this._writeBufferNow(key, data) }, 800)
|
|
6330
|
+
},
|
|
6331
|
+
|
|
6332
|
+
async _writeBufferNow(key, data) {
|
|
6333
|
+
if (!key) return
|
|
6334
|
+
try {
|
|
6335
|
+
await putBuffer(key, data)
|
|
6336
|
+
} catch (error) {
|
|
6337
|
+
this.$emit('local-sync-error', { action: 'save', error })
|
|
6338
|
+
}
|
|
6339
|
+
},
|
|
6340
|
+
|
|
6341
|
+
// Called once from _loadLocalVersionsOnMount(), before this key's version
|
|
6342
|
+
// list is read — folds a leftover buffer (from an edit that was saved
|
|
6343
|
+
// during the PREVIOUS visit but never made it into 'versions' — the
|
|
6344
|
+
// reload/close case this whole mechanism exists for) into a real version.
|
|
6345
|
+
// No time pressure here, so this uses the Worker-backed hashRowsAsync()
|
|
6346
|
+
// like any other non-leave-signal path.
|
|
6347
|
+
async _promoteBufferIfAny() {
|
|
6348
|
+
try {
|
|
6349
|
+
const buffered = await getBuffer(this.localSyncKey)
|
|
6350
|
+
if (!buffered) return
|
|
6351
|
+
const hash = await hashRowsAsync(buffered.data)
|
|
6352
|
+
const { version, overwritten } = await saveOrTouchVersion(
|
|
6353
|
+
this.localSyncKey, buffered.data, hash, this.localSyncMaxVersions
|
|
6354
|
+
)
|
|
6355
|
+
await clearBuffer(this.localSyncKey)
|
|
6356
|
+
this.$emit('local-sync-save', {
|
|
6357
|
+
key: this.localSyncKey,
|
|
6358
|
+
id: version.id,
|
|
6359
|
+
rows: buffered.data.length,
|
|
6360
|
+
updatedAt: version.updatedAt,
|
|
6361
|
+
overwritten
|
|
6362
|
+
})
|
|
6363
|
+
} catch (error) {
|
|
6364
|
+
this.$emit('local-sync-error', { action: 'save', error })
|
|
6365
|
+
}
|
|
6366
|
+
},
|
|
6367
|
+
|
|
6368
|
+
// `sync: true` — see _flushLocalVersionOnHide()'s comment on why the
|
|
6369
|
+
// passive leave-triggers can't afford a Worker round-trip.
|
|
6284
6370
|
_onVisibilityChangeForLocalSync() {
|
|
6285
|
-
if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide()
|
|
6371
|
+
if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide(false, true)
|
|
6286
6372
|
},
|
|
6287
6373
|
|
|
6288
6374
|
_onPageHideForLocalSync() {
|
|
6289
|
-
this._flushLocalVersionOnHide()
|
|
6375
|
+
this._flushLocalVersionOnHide(false, true)
|
|
6290
6376
|
},
|
|
6291
6377
|
|
|
6292
6378
|
// The core save-on-leave path — replaces a per-edit autosave (see the
|
|
@@ -6296,34 +6382,60 @@ const ExcelGridComponent = {
|
|
|
6296
6382
|
// does the latter). Triggered from mounted()'s visibilitychange/pagehide
|
|
6297
6383
|
// listeners and beforeDestroy() — see those for the three trigger points.
|
|
6298
6384
|
//
|
|
6299
|
-
// Content-hashes the current `data`
|
|
6300
|
-
//
|
|
6301
|
-
//
|
|
6302
|
-
//
|
|
6303
|
-
//
|
|
6304
|
-
//
|
|
6305
|
-
// see idbStore.js's saveOrTouchVersion()). Never throws — IndexedDB
|
|
6306
|
-
// failures become local-sync-error.
|
|
6385
|
+
// Content-hashes the current `data` and either touches an existing local
|
|
6386
|
+
// version with the same hash (just refreshes its timestamp — "ustiga
|
|
6387
|
+
// yoziladi, alohida versiya qilinmaydi") or creates a brand-new one,
|
|
6388
|
+
// evicting the oldest past localSyncMaxVersions (FIFO — see idbStore.js's
|
|
6389
|
+
// saveOrTouchVersion()). Never throws — IndexedDB failures become
|
|
6390
|
+
// local-sync-error.
|
|
6307
6391
|
//
|
|
6308
6392
|
// `force` skips the "nothing changed since the last flush" short-circuit
|
|
6309
6393
|
// below. The passive triggers (visibilitychange/pagehide/beforeDestroy)
|
|
6310
|
-
// fire often on an idle tab and must NOT wake
|
|
6311
|
-
//
|
|
6312
|
-
//
|
|
6313
|
-
//
|
|
6314
|
-
//
|
|
6315
|
-
//
|
|
6316
|
-
|
|
6394
|
+
// fire often on an idle tab and must NOT wake IndexedDB for no reason, so
|
|
6395
|
+
// they leave it in place. saveLocalVersionNow() — an explicit, infrequent,
|
|
6396
|
+
// host/modal-initiated "checkpoint now" action — passes force so it never
|
|
6397
|
+
// silently does nothing: even unchanged data still gets its existing
|
|
6398
|
+
// version's timestamp refreshed via the same hash-dedup path, rather than
|
|
6399
|
+
// the caller seeing no effect at all.
|
|
6400
|
+
//
|
|
6401
|
+
// `sync` picks hashRows() (main thread) over the Worker-based
|
|
6402
|
+
// hashRowsAsync(). This was a real, observed bug, not a theoretical one:
|
|
6403
|
+
// a Worker round-trip (postMessage out, worker computes, postMessage
|
|
6404
|
+
// back) is one more hop through the event loop than a page that's
|
|
6405
|
+
// actually being torn down (as opposed to merely hidden — e.g. a tab
|
|
6406
|
+
// switch, where the page stays alive indefinitely and the extra hop
|
|
6407
|
+
// costs nothing) can be relied on to survive. visibilitychange/pagehide/
|
|
6408
|
+
// beforeDestroy all fire in exactly that "might be torn down any moment"
|
|
6409
|
+
// window, so they hash synchronously to remove that hop — the whole
|
|
6410
|
+
// flush then only has the unavoidable async part (the IndexedDB write
|
|
6411
|
+
// itself) left, giving it the best realistic chance to land before
|
|
6412
|
+
// teardown. saveLocalVersionNow() (an explicit host/modal action with no
|
|
6413
|
+
// teardown racing it) keeps the Worker, so hashing a large `data` array
|
|
6414
|
+
// doesn't block the main thread during ordinary use.
|
|
6415
|
+
async _flushLocalVersionOnHide(force = false, sync = false) {
|
|
6317
6416
|
if (!this.localSyncKey) return
|
|
6318
6417
|
if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush
|
|
6319
6418
|
const data = this.data
|
|
6419
|
+
// Fires independently of the version-save attempt below, started as
|
|
6420
|
+
// early as possible — see _scheduleBufferWrite()'s comment. This is
|
|
6421
|
+
// the save that's actually likely to survive an imminent teardown;
|
|
6422
|
+
// the version-store write below is best-effort on top of it, reliable
|
|
6423
|
+
// for the common case (tab switch/background, page stays alive).
|
|
6424
|
+
clearTimeout(this._bufferWriteTimer)
|
|
6425
|
+
this._writeBufferNow(this.localSyncKey, data)
|
|
6320
6426
|
try {
|
|
6321
|
-
const hash = await hashRowsAsync(data)
|
|
6427
|
+
const hash = sync ? hashRows(data) : await hashRowsAsync(data)
|
|
6322
6428
|
if (this.data !== data) return // data changed again mid-hash — a later flush will catch it
|
|
6323
6429
|
const { version, overwritten } = await saveOrTouchVersion(
|
|
6324
6430
|
this.localSyncKey, data, hash, this.localSyncMaxVersions
|
|
6325
6431
|
)
|
|
6326
6432
|
this._lastFlushedDataRef = data
|
|
6433
|
+
// The version store now has this content durably — the buffer's job
|
|
6434
|
+
// for it is done. Best-effort like everything else here: if this
|
|
6435
|
+
// doesn't complete before teardown, _promoteBufferIfAny() finds the
|
|
6436
|
+
// (now-redundant but harmless, dedup makes it a no-op touch) buffer
|
|
6437
|
+
// on next mount anyway.
|
|
6438
|
+
clearBuffer(this.localSyncKey).catch(() => {})
|
|
6327
6439
|
this.$emit('local-sync-save', {
|
|
6328
6440
|
key: this.localSyncKey,
|
|
6329
6441
|
id: version.id,
|
package/src/utils/idbStore.js
CHANGED
|
@@ -17,11 +17,28 @@
|
|
|
17
17
|
// detect "this is the same content as an existing version" — in that case
|
|
18
18
|
// the existing record's `updatedAt` is bumped instead of creating a new id
|
|
19
19
|
// ("ustiga yoziladi, alohida versiya qilinmaydi").
|
|
20
|
+
//
|
|
21
|
+
// v3 adds 'buffer', keyPath 'localSyncKey' (one row per key — always
|
|
22
|
+
// overwritten, no history). This exists to solve a real, verified gap: an
|
|
23
|
+
// IndexedDB write started at visibilitychange/pagehide/beforeDestroy has NO
|
|
24
|
+
// guarantee of completing before an actual page reload/close tears the page
|
|
25
|
+
// down (confirmed directly — even a trivial, unrelated IndexedDB write in a
|
|
26
|
+
// bare-minimum repro failed to survive a real page.reload() in Chromium; this
|
|
27
|
+
// is a browser/spec characteristic, not something a Worker or a different
|
|
28
|
+
// object store can fix). 'versions' writes still happen on the leave-signal
|
|
29
|
+
// (reliable for tab-switch/backgrounding, where the page stays alive), but
|
|
30
|
+
// ExcelGrid.vue ALSO writes to 'buffer' on every real edit (debounced, no
|
|
31
|
+
// hash needed, cheap) — at a moment with no teardown race at all — so that
|
|
32
|
+
// whatever the user last typed survives even a reload/close where the
|
|
33
|
+
// leave-signal write doesn't land in time. On the next mount, the buffer (if
|
|
34
|
+
// any) is promoted into 'versions' via saveOrTouchVersion() at leisure, then
|
|
35
|
+
// cleared. See ExcelGrid.vue's _scheduleBufferWrite()/_promoteBufferIfAny().
|
|
20
36
|
const DB_NAME = 'vue2-excel-grid-sync'
|
|
21
37
|
const STORE_NAME = 'versions'
|
|
38
|
+
const BUFFER_STORE_NAME = 'buffer'
|
|
22
39
|
const OLD_STORE_NAME = 'snapshots' // v1 — dropped on upgrade, not migrated
|
|
23
40
|
const INDEX_BY_KEY = 'by_localSyncKey'
|
|
24
|
-
const DB_VERSION =
|
|
41
|
+
const DB_VERSION = 3
|
|
25
42
|
|
|
26
43
|
function pad2(n) { return n < 10 ? '0' + n : '' + n }
|
|
27
44
|
|
|
@@ -52,6 +69,9 @@ function openDb() {
|
|
|
52
69
|
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
|
53
70
|
store.createIndex(INDEX_BY_KEY, 'localSyncKey', { unique: false })
|
|
54
71
|
}
|
|
72
|
+
if (!db.objectStoreNames.contains(BUFFER_STORE_NAME)) {
|
|
73
|
+
db.createObjectStore(BUFFER_STORE_NAME, { keyPath: 'localSyncKey' })
|
|
74
|
+
}
|
|
55
75
|
}
|
|
56
76
|
req.onsuccess = () => resolve(req.result)
|
|
57
77
|
req.onerror = () => reject(req.error)
|
|
@@ -159,3 +179,36 @@ export async function saveOrTouchVersion(localSyncKey, data, hash, maxVersions)
|
|
|
159
179
|
|
|
160
180
|
return { version: record, overwritten: false }
|
|
161
181
|
}
|
|
182
|
+
|
|
183
|
+
// The 'buffer' store — see the schema comment at the top of this file. One
|
|
184
|
+
// row per localSyncKey, always overwritten; no hash, no history, no eviction.
|
|
185
|
+
export async function putBuffer(localSyncKey, data) {
|
|
186
|
+
const db = await openDb()
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const tx = db.transaction(BUFFER_STORE_NAME, 'readwrite')
|
|
189
|
+
const record = { localSyncKey, data, updatedAt: Date.now() }
|
|
190
|
+
tx.objectStore(BUFFER_STORE_NAME).put(record)
|
|
191
|
+
tx.oncomplete = () => { db.close(); resolve(record) }
|
|
192
|
+
tx.onerror = () => { db.close(); reject(tx.error) }
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function getBuffer(localSyncKey) {
|
|
197
|
+
const db = await openDb()
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
const tx = db.transaction(BUFFER_STORE_NAME, 'readonly')
|
|
200
|
+
const req = tx.objectStore(BUFFER_STORE_NAME).get(localSyncKey)
|
|
201
|
+
req.onsuccess = () => { db.close(); resolve(req.result || null) }
|
|
202
|
+
req.onerror = () => { db.close(); reject(req.error) }
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function clearBuffer(localSyncKey) {
|
|
207
|
+
const db = await openDb()
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const tx = db.transaction(BUFFER_STORE_NAME, 'readwrite')
|
|
210
|
+
tx.objectStore(BUFFER_STORE_NAME).delete(localSyncKey)
|
|
211
|
+
tx.oncomplete = () => { db.close(); resolve() }
|
|
212
|
+
tx.onerror = () => { db.close(); reject(tx.error) }
|
|
213
|
+
})
|
|
214
|
+
}
|