Store things in localDb

This commit is contained in:
Benoit Schweblin 2017-07-27 21:19:52 +01:00
parent 97172e28b3
commit 02a4696b40
11 changed files with 283 additions and 363 deletions

View File

@ -5,7 +5,7 @@
<div class="spinner"></div>
</div>
<div class="navigation-bar__title navigation-bar__title--text text-input" v-bind:style="{maxWidth: titleMaxWidth + 'px'}"></div>
<input class="navigation-bar__title navigation-bar__title--input text-input" v-bind:class="{'navigation-bar__title--focused': titleFocused, 'navigation-bar__title--scrolling': titleScrolling}" v-bind:style="{maxWidth: titleMaxWidth + 'px'}" @focus="editTitle(true)" @blur="editTitle(false)" @keyup.enter="submitTitle()" @keyup.esc="submitTitle(true)" v-model.lazy.trim="title">
<input class="navigation-bar__title navigation-bar__title--input text-input" v-bind:class="{'navigation-bar__title--focus': titleFocus, 'navigation-bar__title--scrolling': titleScrolling}" v-bind:style="{maxWidth: titleMaxWidth + 'px'}" @focus="editTitle(true)" @blur="editTitle(false)" @keyup.enter="submitTitle()" @keyup.esc="submitTitle(true)" v-model.lazy.trim="title">
</div>
<div class="navigation-bar__inner navigation-bar__inner--left">
<button class="navigation-bar__button button" @click="pagedownClick('bold')">
@ -55,7 +55,7 @@ import animationSvc from '../services/animationSvc';
export default {
data: () => ({
titleFocused: false,
titleFocus: false,
titleHover: false,
}),
computed: {
@ -65,14 +65,14 @@ export default {
}),
title: {
get() {
return this.$store.state.files.currentFile.name;
return this.$store.getters['files/current'].name;
},
set(value) {
this.$store.commit('files/setCurrentFileName', value);
set(name) {
this.$store.dispatch('files/patchCurrent', { name });
},
},
titleScrolling() {
const result = this.titleHover && !this.titleFocused;
const result = this.titleHover && !this.titleFocus;
if (this.titleInputElt) {
if (result) {
const scrollLeft = this.titleInputElt.scrollWidth - this.titleInputElt.offsetWidth;
@ -95,7 +95,7 @@ export default {
editorSvc.pagedownEditor.uiManager.doClick(name);
},
editTitle(toggle) {
this.titleFocused = toggle;
this.titleFocus = toggle;
if (toggle) {
this.titleInputElt.setSelectionRange(0, this.titleInputElt.value.length);
}
@ -197,7 +197,7 @@ export default {
.navigation-bar__title--input {
cursor: pointer;
&.navigation-bar__title--focused {
&.navigation-bar__title--focus {
cursor: text;
}
}

7
src/data/emptyContent.js Normal file
View File

@ -0,0 +1,7 @@
export default () => ({
state: {},
text: '\n',
properties: {},
discussions: {},
comments: {},
});

5
src/data/emptyFile.js Normal file
View File

@ -0,0 +1,5 @@
export default () => ({
name: '',
folderId: null,
contentId: null,
});

View File

@ -11,8 +11,8 @@ let markerIdxMap;
let previousPatchableText;
let currentPatchableText;
let discussionMarkers;
let content;
let isChangePatch;
let contentId;
function getDiscussionMarkers(discussion, discussionId, onMarker) {
function getMarker(offsetName) {
@ -35,6 +35,7 @@ function getDiscussionMarkers(discussion, discussionId, onMarker) {
}
function syncDiscussionMarkers() {
const content = store.getters['contents/current'];
Object.keys(discussionMarkers)
.forEach((markerKey) => {
const marker = discussionMarkers[markerKey];
@ -99,8 +100,9 @@ export default {
markerIdxMap = Object.create(null);
discussionMarkers = {};
clEditor.on('contentChanged', (text) => {
store.commit('files/setCurrentFileContentText', text);
store.dispatch('contents/patchCurrent', { text });
syncDiscussionMarkers();
const content = store.getters('contents/current');
if (!isChangePatch) {
previousPatchableText = currentPatchableText;
currentPatchableText = clDiffUtils.makePatchableText(content, markerKeys, markerIdxMap);
@ -121,11 +123,12 @@ export default {
clEditor.addMarker(newDiscussionMarker1);
},
initClEditor(opts, reinit) {
if (store.state.files.currentFile.content) {
const content = store.getters('contents/current');
if (content) {
const options = Object.assign({}, opts);
if (content !== store.state.files.currentFile.content) {
content = store.state.files.currentFile.content;
if (contentId !== content.id) {
contentId = content.id;
currentPatchableText = clDiffUtils.makePatchableText(content, markerKeys, markerIdxMap);
previousPatchableText = currentPatchableText;
syncDiscussionMarkers();
@ -153,6 +156,7 @@ export default {
this.lastExternalChange = Date.now();
}
syncDiscussionMarkers();
const content = store.getters('contents/current');
return clEditor.setContent(content.text, isExternal);
},
};

View File

@ -29,8 +29,9 @@ const allowDebounce = (action, wait) => {
};
const diffMatchPatch = new DiffMatchPatch();
let lastContentId = null;
let reinitClEditor = true;
let isPreviewRefreshed = false;
let instantPreview = true;
let tokens;
const anchorHash = {};
@ -171,9 +172,14 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
* Initialize the cledit editor with markdown-it section parser and Prism highlighter
*/
initClEditor() {
if (!store.state.files.currentFile.isLoaded) {
const contentId = store.getters['contents/current'].id;
if (contentId !== lastContentId) {
reinitClEditor = true;
isPreviewRefreshed = false;
instantPreview = true;
lastContentId = contentId;
}
if (!contentId) {
// This means the content is not loaded yet
editorEngineSvc.clEditor.toggleEditable(false);
return;
}
@ -336,8 +342,8 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
* Make the diff between editor's markdown and preview's html.
*/
makeTextToPreviewDiffs: allowDebounce(() => {
if (editorSvc.sectionDescList
&& editorSvc.sectionDescList !== editorSvc.sectionDescMeasuredList) {
if (editorSvc.sectionDescList &&
editorSvc.sectionDescList !== editorSvc.sectionDescMeasuredList) {
editorSvc.sectionDescList
.forEach((sectionDesc) => {
if (!sectionDesc.textToPreviewDiffs) {
@ -355,19 +361,21 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
*/
saveContentState: allowDebounce(() => {
const scrollPosition = editorSvc.getScrollPosition() ||
store.state.files.currentFile.content.state.scrollPosition;
store.commit('files/setCurrentFileContentState', {
store.getters['contents/current'].state.scrollPosition;
store.dispatch('contents/patchCurrent', {
state: {
selectionStart: editorEngineSvc.clEditor.selectionMgr.selectionStart,
selectionEnd: editorEngineSvc.clEditor.selectionMgr.selectionEnd,
scrollPosition,
}, { root: true });
},
});
}, 100),
/**
* Restore the scroll position from the current file content state.
*/
restoreScrollPosition() {
const scrollPosition = store.state.files.currentFile.content.state.scrollPosition;
const scrollPosition = store.getters['contents/current'].state.scrollPosition;
if (scrollPosition && this.sectionDescMeasuredList) {
const objectToScroll = this.getObjectToScroll();
const sectionDesc = this.sectionDescMeasuredList[scrollPosition.sectionIdx];
@ -388,10 +396,10 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
if (range) {
if (
/* eslint-disable no-bitwise */
!(editorSvc.previewElt.compareDocumentPosition(range.startContainer)
& window.Node.DOCUMENT_POSITION_CONTAINED_BY) ||
!(editorSvc.previewElt.compareDocumentPosition(range.endContainer)
& window.Node.DOCUMENT_POSITION_CONTAINED_BY)
!(editorSvc.previewElt.compareDocumentPosition(range.startContainer) &
window.Node.DOCUMENT_POSITION_CONTAINED_BY) ||
!(editorSvc.previewElt.compareDocumentPosition(range.endContainer) &
window.Node.DOCUMENT_POSITION_CONTAINED_BY)
/* eslint-enable no-bitwise */
) {
range = null;
@ -561,14 +569,14 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
const refreshPreview = () => {
this.convert();
if (!isPreviewRefreshed) {
if (instantPreview) {
this.refreshPreview();
this.measureSectionDimensions();
this.restoreScrollPosition();
} else {
setTimeout(() => this.refreshPreview(), 10);
}
isPreviewRefreshed = true;
instantPreview = false;
};
const debouncedRefreshPreview = debounce(refreshPreview, 20);
@ -579,10 +587,10 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
if (this.sectionList !== newSectionList) {
this.sectionList = newSectionList;
this.$emit('sectionList', this.sectionList);
if (isPreviewRefreshed) {
debouncedRefreshPreview();
} else {
if (instantPreview) {
refreshPreview();
} else {
debouncedRefreshPreview();
}
}
if (this.selectionRange !== newSelectionRange) {
@ -710,7 +718,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
// })
store.watch(
state => state.files.currentFile.content.properties,
() => store.getters['contents/current'].properties,
(properties) => {
const options = properties && extensionSvc.getOptions(properties, true);
if (JSON.stringify(options) !== JSON.stringify(editorSvc.options)) {

View File

@ -1,286 +1,34 @@
import 'babel-polyfill';
import 'indexeddbshim';
import utils from './utils';
import debug from 'debug';
import store from '../store';
const dbg = debug('stackedit:localDbSvc');
let indexedDB = window.indexedDB;
const localStorage = window.localStorage;
const dbVersion = 1;
const dbStoreName = 'objects';
// Use the shim on Safari or if indexedDB is not available
// Use the shim on Safari or when indexedDB is not available
if (window.shimIndexedDB && (!indexedDB || (navigator.userAgent.indexOf('Chrome') === -1 && navigator.userAgent.indexOf('Safari') !== -1))) {
indexedDB = window.shimIndexedDB;
}
function getStorePrefixFromType(type) {
// Return `files` for type `file`, `folders` for type `folder`, etc...
const prefix = `${type}s`;
return store.state[prefix] && prefix;
}
const deletedMarkerMaxAge = 1000;
function identity(value) {
return value;
}
function makeStore(storeName, schemaParameter) {
const schema = {
...schemaParameter,
updated: 'int',
};
const schemaKeys = Object.keys(schema);
const schemaKeysLen = schemaKeys.length;
const complexKeys = [];
let complexKeysLen = 0;
const attributeCheckers = {};
const attributeReaders = {};
const attributeWriters = {};
class Dao {
constructor(id, skipInit) {
this.id = id || utils.uid();
if (!skipInit) {
const fakeItem = {};
for (let i = 0; i < schemaKeysLen; i += 1) {
attributeReaders[schemaKeys[i]](fakeItem, this);
}
this.$dirty = true;
}
}
}
function createDao(id) {
return new Dao(id);
}
Object.keys(schema).forEach((key) => {
const value = schema[key];
const storedValueKey = `_${key}`;
let defaultValue = value.default === undefined ? '' : value.default;
let serializer = value.serializer || identity;
let parser = value.parser || identity;
if (value === 'int') {
defaultValue = 0;
} else if (value === 'object') {
defaultValue = 'null';
parser = JSON.parse;
serializer = JSON.stringify;
}
attributeReaders[key] = (dbItem, dao) => {
dao[storedValueKey] = dbItem[key] || defaultValue;
};
attributeWriters[key] = (dbItem, dao) => {
const storedValue = dao[storedValueKey];
if (storedValue && storedValue !== defaultValue) {
dbItem[key] = storedValue;
}
};
function getter() {
return this[storedValueKey];
}
function setter(param) {
const val = param || defaultValue;
if (this[storedValueKey] === val) {
return false;
}
this[storedValueKey] = val;
this.$dirty = true;
return true;
}
if (key === 'updated') {
Object.defineProperty(Dao.prototype, key, {
get: getter,
set: (value) => {
if (setter.call(this, value)) {
this.$dirtyUpdated = true;
}
}
})
} else if (value === 'string' || value === 'int') {
Object.defineProperty(Dao.prototype, key, {
get: getter,
set: setter
})
} else if (![64, 128] // Handle string64 and string128
.cl_some(function (length) {
if (value === 'string' + length) {
Object.defineProperty(Dao.prototype, key, {
get: getter,
set: function (value) {
if (value && value.length > length) {
value = value.slice(0, length)
}
setter.call(this, value)
}
})
return true
}
})
) {
// Other types go to complexKeys list
complexKeys.push(key)
complexKeysLen++
// And have complex readers/writers
attributeReaders[key] = function (dbItem, dao) {
const storedValue = dbItem[key]
if (!storedValue) {
storedValue = defaultValue
}
dao[storedValueKey] = storedValue
dao[key] = parser(storedValue)
}
attributeWriters[key] = function (dbItem, dao) {
const storedValue = serializer(dao[key])
dao[storedValueKey] = storedValue
if (storedValue && storedValue !== defaultValue) {
dbItem[key] = storedValue
}
}
// Checkers are only for complex types
attributeCheckers[key] = function (dao) {
return serializer(dao[key]) !== dao[storedValueKey]
}
}
})
const lastTx = 0
const storedSeqs = Object.create(null)
function readDbItem(item, daoMap) {
const dao = daoMap[item.id] || new Dao(item.id, true)
if (!item.updated) {
delete storedSeqs[item.id]
if (dao.updated) {
delete daoMap[item.id]
return true
}
return
}
if (storedSeqs[item.id] === item.seq) {
return
}
storedSeqs[item.id] = item.seq
for (const i = 0; i < schemaKeysLen; i++) {
attributeReaders[schemaKeys[i]](item, dao)
}
dao.$dirty = false
dao.$dirtyUpdated = false
daoMap[item.id] = dao
return true
}
function getPatch(tx, cb) {
let resetMap;
// We may have missed some deleted markers
if (lastTx && tx.txCounter - lastTx > deletedMarkerMaxAge) {
// Delete all dirty daos, user was asleep anyway...
resetMap = true
// And retrieve everything from DB
lastTx = 0
}
const hasChanged = !lastTx
const store = tx.objectStore(storeName)
const index = store.index('seq')
const range = $window.IDBKeyRange.lowerBound(lastTx, true)
const items = []
const itemsToDelete = []
index.openCursor(range).onsuccess = function (event) {
const cursor = event.target.result
if (!cursor) {
itemsToDelete.cl_each(function (item) {
store.delete(item.id)
})
items.length && debug('Got ' + items.length + ' ' + storeName + ' items')
// Return a patch, to apply changes later
return cb(function (daoMap) {
if (resetMap) {
Object.keys(daoMap).cl_each(function (key) {
delete daoMap[key]
})
storedSeqs = Object.create(null)
}
items.cl_each(function (item) {
hasChanged |= readDbItem(item, daoMap)
})
return hasChanged
})
}
const item = cursor.value
items.push(item)
// Remove old deleted markers
if (!item.updated && tx.txCounter - item.seq > deletedMarkerMaxAge) {
itemsToDelete.push(item)
}
cursor.continue()
}
}
function writeAll(daoMap, tx) {
lastTx = tx.txCounter
const store = tx.objectStore(storeName)
// Remove deleted daos
const storedIds = Object.keys(storedSeqs)
const storedIdsLen = storedIds.length
for (const i = 0; i < storedIdsLen; i++) {
const id = storedIds[i]
if (!daoMap[id]) {
// Put a deleted marker to notify other tabs
store.put({
id: id,
seq: lastTx
})
delete storedSeqs[id]
}
}
// Put changes
const daoIds = Object.keys(daoMap)
const daoIdsLen = daoIds.length
for (i = 0; i < daoIdsLen; i++) {
const dao = daoMap[daoIds[i]]
const dirty = dao.$dirty
for (const j = 0; !dirty && j < complexKeysLen; j++) {
dirty |= attributeCheckers[complexKeys[j]](dao)
}
if (dirty) {
if (!dao.$dirtyUpdated) {
// Force update the `updated` attribute
dao.updated = Date.now()
}
const item = {
id: daoIds[i],
seq: lastTx
}
for (j = 0; j < schemaKeysLen; j++) {
attributeWriters[schemaKeys[j]](item, dao)
}
debug('Put ' + storeName + ' item')
store.put(item)
storedSeqs[item.id] = item.seq
dao.$dirty = false
dao.$dirtyUpdated = false
}
}
}
return {
getPatch,
writeAll,
createDao,
Dao,
};
}
class Connection {
constructor() {
this.getTxCbs = [];
// Init connexion
const request = indexedDB.open('classeur-db', dbVersion);
const request = indexedDB.open('stackedit-db', dbVersion);
request.onerror = () => {
throw new Error("Can't connect to IndexedDB.");
@ -298,23 +46,21 @@ class Connection {
request.onupgradeneeded = (event) => {
const eventDb = event.target.result;
const oldVersion = event.oldVersion || 0;
function createStore(name) {
const store = eventDb.createObjectStore(name, { keyPath: 'id' });
store.createIndex('seq', 'seq', { unique: false });
}
// We don't use 'break' in this switch statement,
// the fall-through behaviour is what we want.
/* eslint-disable no-fallthrough */
switch (oldVersion) {
case 0:
[
'contents',
'files',
'folders',
'objects',
'app',
].forEach(createStore);
{
// Create store
const dbStore = eventDb.createObjectStore(dbStoreName, {
keyPath: 'id',
});
dbStore.createIndex('tx', 'tx', {
unique: false,
});
}
default:
}
/* eslint-enable no-fallthrough */
@ -333,31 +79,144 @@ class Connection {
return;
}
const tx = this.db.transaction(this.db.objectStoreNames, 'readwrite');
// tx.onerror = (evt) => {
// dbg('Rollback transaction', evt);
// };
const store = tx.objectStore('app');
const request = store.get('txCounter');
tx.onerror = (evt) => {
dbg('Rollback transaction', evt);
};
const dbStore = tx.objectStore(dbStoreName);
const request = dbStore.get('txCounter');
request.onsuccess = () => {
tx.txCounter = request.result ? request.result.value : 0;
tx.txCounter = request.result ? request.result.tx : 0;
tx.txCounter += 1;
store.put({
dbStore.put({
id: 'txCounter',
value: tx.txCounter,
tx: tx.txCounter,
});
cb(tx);
};
}
}
class LocalDbStorage {
init(store) {
this.store = store;
store.subscribe((mutation, state) => {
console.log(mutation, state);
});
class Storage {
constructor() {
this.lastTx = 0;
this.updatedMap = Object.create(null);
this.connection = new Connection();
}
sync() {
const storeItemMap = {};
[
store.state.files,
].forEach(moduleState => Object.assign(storeItemMap, moduleState.itemMap));
const tx = this.connection.createTx();
this.readAll(storeItemMap, tx, () => this.writeAll(storeItemMap, tx));
}
readAll(storeItemMap, tx, cb) {
let resetMap;
// We may have missed some deleted markers
if (this.lastTx && tx.txCounter - this.lastTx > deletedMarkerMaxAge) {
// Delete all dirty store items (user was asleep anyway...)
resetMap = true;
// And retrieve everything from DB
this.lastTx = 0;
}
const dbStore = tx.objectStore(dbStoreName);
const index = dbStore.index('tx');
const range = window.IDBKeyRange.lowerBound(this.lastTx, true);
const items = [];
const itemsToDelete = [];
index.openCursor(range).onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
itemsToDelete.forEach((item) => {
dbStore.delete(item.id);
});
if (items.length) {
dbg(`Got ${items.length} items`);
}
if (resetMap) {
Object.keys(storeItemMap).forEach((id) => {
delete storeItemMap[id];
});
this.updatedMap = Object.create(null);
}
items.forEach(item => this.readDbItem(item, storeItemMap));
cb();
}
const item = cursor.value;
items.push(item);
// Remove old deleted markers
if (!item.updated && tx.txCounter - item.tx > deletedMarkerMaxAge) {
itemsToDelete.push(item);
}
cursor.continue();
};
}
writeAll(storeItemMap, tx) {
this.lastTx = tx.txCounter;
const dbStore = tx.objectStore(dbStoreName);
// Remove deleted store items
const storedIds = Object.keys(this.updatedMap);
const storedIdsLen = storedIds.length;
for (let i = 0; i < storedIdsLen; i += 1) {
const id = storedIds[i];
if (!storeItemMap[id]) {
// Put a deleted marker to notify other tabs
dbStore.put({
id,
tx: this.lastTx,
});
delete this.updatedMap[id];
}
}
export default LocalDbStorage;
// Put changes
const storeItemIds = Object.keys(storeItemMap);
const storeItemIdsLen = storeItemIds.length;
for (let i = 0; i < storeItemIdsLen; i += 1) {
const storeItem = storeItemMap[storeItemIds[i]];
// Store object has changed
if (this.updatedMap[storeItem.id] !== storeItem.updated) {
const item = {
...storeItem,
tx: this.lastTx,
};
dbg('Putting 1 item');
dbStore.put(item);
this.updatedMap[item.id] = item.updated;
}
}
}
readDbItem(dbItem, storeItemMap) {
const existingStoreItem = storeItemMap[dbItem.id];
if (!dbItem.updated) {
delete this.updatedMap[dbItem.id];
if (existingStoreItem) {
const prefix = getStorePrefixFromType(existingStoreItem.type);
if (prefix) {
delete storeItemMap[existingStoreItem.id];
// Remove object from the store
store.commit(`${prefix}/deleteItem`, existingStoreItem.id);
}
}
} else if (this.updatedMap[dbItem.id] !== dbItem.updated) {
const storeItem = {
...dbItem,
tx: undefined,
};
this.updatedMap[storeItem.id] = storeItem.updated;
storeItemMap[storeItem.id] = storeItem;
// Put object in the store
const prefix = getStorePrefixFromType(storeItem.type);
store.commit(`${prefix}/setItem`, storeItem);
}
}
}
export default Storage;

View File

@ -4,14 +4,11 @@ import Vuex from 'vuex';
import files from './modules/files';
import layout from './modules/layout';
import editor from './modules/editor';
import LocalDbStorage from '../services/localDbSvc';
Vue.use(Vuex);
const debug = process.env.NODE_ENV !== 'production';
const localDbStorage = new LocalDbStorage();
const store = new Vuex.Store({
modules: {
files,
@ -19,7 +16,7 @@ const store = new Vuex.Store({
editor,
},
strict: debug,
plugins: [_store => localDbStorage.init(_store)].concat(debug ? [createLogger()] : []),
plugins: debug ? [createLogger()] : [],
});
export default store;

View File

@ -0,0 +1,22 @@
import moduleTemplate from './moduleTemplate';
import emptyContent from '../../data/emptyContent';
const module = moduleTemplate();
module.getters = {
...module.getters,
current: (state, getters, rootState, rootGetters) =>
state.itemMap[rootGetters['files/current'].contentId] || emptyContent(),
};
module.actions = {
...module.actions,
patchCurrent({ getters, commit }, value) {
commit('patchItem', {
...value,
id: getters.current.id,
});
},
};
export default module;

View File

@ -1,36 +1,26 @@
import mdSample from '../../markdown/sample.md';
import moduleTemplate from './moduleTemplate';
import defaultFile from '../../data/emptyFile';
export default {
namespaced: true,
state: {
files: [],
currentFile: {
name: 'Test 123456 abcdefghijkl 123456 abcdefghijkl 123456 abcdefghijkl 123456 abcdefghijkl',
folderId: null,
isLoaded: true,
content: {
state: {},
text: mdSample,
properties: {},
discussions: {},
comments: {},
},
},
},
mutations: {
setCurrentFile: (state, value) => {
state.currentFile = value;
},
setCurrentFileName: (state, value) => {
if (value) {
state.currentFile.name = value;
}
},
setCurrentFileContentText: (state, value) => {
state.currentFile.content.text = value;
},
setCurrentFileContentState: (state, value) => {
state.currentFile.content.state = value;
},
const module = moduleTemplate();
module.state = {
...module.state,
currentId: null,
};
module.getters = {
...module.getters,
current: state => state.itemMap[state.currentId] || defaultFile(),
};
module.actions = {
...module.actions,
patchCurrent({ getters, commit }, value) {
commit('patchItem', {
...value,
id: getters.current.id,
});
},
};
export default module;

View File

@ -0,0 +1,28 @@
import Vue from 'vue';
export default () => ({
namespaced: true,
state: {
itemMap: {},
},
getters: {},
mutations: {
setItem(state, item) {
Vue.set(state.itemMap, item.id, item);
},
patchItem(state, patch) {
const item = state.itemMap[patch.id];
if (item) {
Vue.set(state.itemMap, item.id, {
...item,
...patch,
updated: Date.now(), // Trigger sync
});
}
},
deleteItem(state, id) {
Vue.delete(state.itemMap, id);
},
},
actions: {},
});