diff options
Diffstat (limited to 'packages/frontend/src/pizzax.ts')
| -rw-r--r-- | packages/frontend/src/pizzax.ts | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts new file mode 100644 index 0000000000..642e1f4f7f --- /dev/null +++ b/packages/frontend/src/pizzax.ts @@ -0,0 +1,169 @@ +// PIZZAX --- A lightweight store + +import { onUnmounted, Ref, ref, watch } from 'vue'; +import { $i } from './account'; +import { api } from './os'; +import { stream } from './stream'; + +type StateDef = Record<string, { + where: 'account' | 'device' | 'deviceAccount'; + default: any; +}>; + +type ArrayElement<A> = A extends readonly (infer T)[] ? T : never; + +const connection = $i && stream.useChannel('main'); + +export class Storage<T extends StateDef> { + public readonly key: string; + public readonly keyForLocalStorage: string; + + public readonly def: T; + + // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 + public readonly state: { [K in keyof T]: T[K]['default'] }; + public readonly reactiveState: { [K in keyof T]: Ref<T[K]['default']> }; + + constructor(key: string, def: T) { + this.key = key; + this.keyForLocalStorage = 'pizzax::' + key; + this.def = def; + + // TODO: indexedDBにする + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); + const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {}; + const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {}; + + const state = {}; + const reactiveState = {}; + for (const [k, v] of Object.entries(def)) { + if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { + state[k] = deviceState[k]; + } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { + state[k] = registryCache[k]; + } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { + state[k] = deviceAccountState[k]; + } else { + state[k] = v.default; + if (_DEV_) console.log('Use default value', k, v.default); + } + } + for (const [k, v] of Object.entries(state)) { + reactiveState[k] = ref(v); + } + this.state = state as any; + this.reactiveState = reactiveState as any; + + if ($i) { + // なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう) + window.setTimeout(() => { + api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => { + const cache = {}; + for (const [k, v] of Object.entries(def)) { + if (v.where === 'account') { + if (Object.prototype.hasOwnProperty.call(kvs, k)) { + state[k] = kvs[k]; + reactiveState[k].value = kvs[k]; + cache[k] = kvs[k]; + } else { + state[k] = v.default; + reactiveState[k].value = v.default; + } + } + } + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + }); + }, 1); + // streamingのuser storage updateイベントを監視して更新 + connection?.on('registryUpdated', ({ scope, key, value }: { scope: string[], key: keyof T, value: T[typeof key]['default'] }) => { + if (scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return; + + this.state[key] = value; + this.reactiveState[key].value = value; + + const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); + if (cache[key] !== value) { + cache[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + } + }); + } + } + + public set<K extends keyof T>(key: K, value: T[K]['default']): void { + if (_DEV_) console.log('set', key, value); + + this.state[key] = value; + this.reactiveState[key].value = value; + + switch (this.def[key].where) { + case 'device': { + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); + deviceState[key] = value; + localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState)); + break; + } + case 'deviceAccount': { + if ($i == null) break; + const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}'); + deviceAccountState[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState)); + break; + } + case 'account': { + if ($i == null) break; + const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); + cache[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + api('i/registry/set', { + scope: ['client', this.key], + key: key, + value: value, + }); + break; + } + } + } + + public push<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): void { + const currentState = this.state[key]; + this.set(key, [...currentState, value]); + } + + public reset(key: keyof T) { + this.set(key, this.def[key].default); + } + + /** + * 特定のキーの、簡易的なgetter/setterを作ります + * 主にvue場で設定コントロールのmodelとして使う用 + */ + public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]) { + const valueRef = ref(this.state[key]); + + const stop = watch(this.reactiveState[key], val => { + valueRef.value = val; + }); + + // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする + onUnmounted(() => { + stop(); + }); + + // TODO: VueのcustomRef使うと良い感じになるかも + return { + get: () => { + if (getter) { + return getter(valueRef.value); + } else { + return valueRef.value; + } + }, + set: (value: unknown) => { + const val = setter ? setter(value) : value; + this.set(key, val); + valueRef.value = val; + }, + }; + } +} |