|
class HashStateManager { |
|
constructor ({ global = window, prefix = '!' } = {}) { |
|
this.window = global |
|
this.prefix = prefix |
|
this.callbacks = new Set |
|
this.urlListenerEnabled = true |
|
this.boundUrlListener = this.urlListener.bind(this) |
|
|
|
this.internalState = this.parseState() |
|
this.publicState = this.createState() |
|
|
|
this.window.addEventListener('popstate', this.boundUrlListener) |
|
} |
|
|
|
/** |
|
* The listener that's attached for the window's `popstate` event |
|
* @param {PopStateEvent} evt The event object delivered by the `popstate` event |
|
*/ |
|
urlListener (evt) { |
|
if (!this.urlListenerEnabled) return |
|
|
|
console.log('popstate', evt.state) |
|
if (evt.state && '__hashManager__' in evt.state) { |
|
this.internalState = this.createState(evt.state) |
|
} else { |
|
this.internalState = this.parseState() |
|
} |
|
|
|
this.notify() |
|
|
|
this.publicState = this.createState() |
|
} |
|
|
|
/** |
|
* Reverts all side effects the HashManager instance introduced |
|
*/ |
|
destroy () { |
|
this.window.removeEventListener('popstate', this.boundUrlListener) |
|
} |
|
|
|
/** |
|
* Create a state object from the given object. Copies the given state and removes any __hashManager__ properties. |
|
* @param {object} state The provided state object |
|
* @return {object} The created state object |
|
*/ |
|
createState (state = this.internalState) { |
|
const stateCopy = { ...state } |
|
delete stateCopy.__hashManager__ |
|
return stateCopy |
|
} |
|
|
|
/** |
|
* Parses the the state from the URL into an object |
|
*/ |
|
parseState () { |
|
if (!this.window.location.hash.startsWith('#' + this.prefix)) { |
|
return {} |
|
} else { |
|
return this.window.location.hash |
|
.slice(this.prefix.length + 1) |
|
.split('/') |
|
.reduce((carry, contentGroup) => { |
|
const [ groupName, serializedItems ] = contentGroup.split(':') |
|
const items = serializedItems == null |
|
? null |
|
: serializedItems.split(',') |
|
|
|
if (groupName.length) { |
|
return { ...carry, [groupName]: items } |
|
} else { |
|
return carry |
|
} |
|
}, {}) |
|
} |
|
} |
|
|
|
/** |
|
* Takes the internal state and publishes it to the URL. Also notifies attached listeners. |
|
*/ |
|
publishState () { |
|
// Disable the `popstate` listener |
|
this.urlListenerEnabled = false |
|
|
|
let hash = Object.entries(this.internalState) |
|
.map(([ group, items ]) => `${group}:${items.join(',')}`) |
|
.join('/') |
|
|
|
if (hash.length) { |
|
hash = '#!' + hash |
|
} |
|
|
|
this.window.history.pushState({ __hashManager__: true, ...this.internalState }, null, this.window.location.pathname + hash) |
|
|
|
// Re-enable the `popstate` listener |
|
this.urlListenerEnabled = true |
|
|
|
this.notify() |
|
|
|
this.publicState = this.createState() |
|
} |
|
|
|
/** |
|
* Checks if there is state for a given key or at all |
|
* @param {string} key The key to check for |
|
* @param {string} value The value key to check for in the key |
|
* @return {boolean} Tells if the value is set for the key. If the value is omitted, it tells if the key exists. If key and value are omitted, it tells if there's any state at all. |
|
*/ |
|
has (key, value) { |
|
if (typeof value === 'string') { |
|
return key in this.internalState && this.internalState[key].includes(value) |
|
} else if (typeof key === 'string') { |
|
return key in this.internalState |
|
} else { |
|
return this.window.location.hash.startsWith('#!') |
|
} |
|
} |
|
|
|
/** |
|
* Gets the state as a whole or from a given key |
|
* @param {string} key The state (array of strings) of the according key |
|
* @return {object|Array} The state of the key. If the key is omitted, the whole state object is returned. |
|
*/ |
|
get (key) { |
|
if (typeof key === 'string') { |
|
return this.internalState[key] |
|
} else { |
|
return this.internalState |
|
} |
|
} |
|
|
|
/** |
|
* Adds a value to a new or existing key |
|
* @param {string} key The key to affect |
|
* @param {string} value The value to attach to the key |
|
*/ |
|
add (key, value) { |
|
this.validateValue(value) |
|
|
|
if (!this.has(key)) { |
|
this.set(key, [ value ]) |
|
} else if (!this.internalState[key].includes(value)) { |
|
this.internalState[key].push(value) |
|
this.publishState() |
|
} |
|
} |
|
|
|
/** |
|
* Removes a key or a key's value from the state |
|
* @param {string} key The key to affect |
|
* @param {string} value The value to remove from the key. If omitted, the whole key will be discarded. |
|
*/ |
|
remove (key, value) { |
|
if (this.has(key)) { |
|
if (typeof value === 'string') { |
|
this.validateValue(value) |
|
|
|
if (this.internalState[key].includes(value)) { |
|
this.internalState[key].splice(this.internalState[key].indexOf(value), 1) |
|
|
|
if (this.internalState[key].length) { |
|
this.publishState() |
|
} else { |
|
this.set(key, []) |
|
} |
|
} |
|
} else { |
|
this.set(key, []) |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Sets the given key to a value |
|
* @param {string} key The key to define |
|
* @param {Array|string} value The value(s) to set. If an empty array is given, the key will be removed. If the value is omitted, a state object is expected as key. |
|
*/ |
|
set (key, value) { |
|
if (typeof key === 'object') { |
|
this.internalState = this.createState(key) |
|
} else { |
|
if (typeof value === 'string') { |
|
value = [ value ] |
|
} |
|
|
|
if (Array.isArray(value) && value.length) { |
|
value.forEach(this.validateValue) |
|
|
|
this.internalState[key] = value |
|
} else { |
|
delete this.internalState[key] |
|
} |
|
} |
|
|
|
this.publishState() |
|
} |
|
|
|
/** |
|
* Attaches a listener callback for state changes |
|
* @param {Function} callback A callback that takes the new state object and the old state object as arguments |
|
* @return {Function} A callback to detach the listener from the hash manager |
|
*/ |
|
onChange (callback) { |
|
this.callbacks.add(callback) |
|
return () => this.callbacks.delete(callback) |
|
} |
|
|
|
/** |
|
* Notifies the attached listeners of a change |
|
*/ |
|
notify () { |
|
for (const callback of this.callbacks) { |
|
callback(this.internalState, this.publicState) |
|
} |
|
} |
|
|
|
/** |
|
* Checks if a given value is a valid hash value, otherwise throws an Error |
|
* @param {any} value The value to check |
|
*/ |
|
validateValue (value) { |
|
if (typeof value !== 'string' || /[,/:]/.test(value)) { |
|
throw new Error(`Invalid value "${value}"`) |
|
} |
|
} |
|
} |