2025-05-09 17:04:06 -04:00
|
|
|
|
import { deepEach, deepGet, deepSet } from './util/deep.js';
|
2025-05-20 10:16:49 -04:00
|
|
|
|
import { camelCase, kebabCase } from './util/string.js';
|
2025-02-13 19:28:20 -05:00
|
|
|
|
|
|
|
|
|
|
export default class Permalink extends URLSearchParams {
|
|
|
|
|
|
/** Params changed since last URL I/O */
|
|
|
|
|
|
changed = false;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(params) {
|
|
|
|
|
|
super(location.search);
|
|
|
|
|
|
this.params = params;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
toJSON() {
|
|
|
|
|
|
return Object.fromEntries(this.entries());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-09 17:04:06 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Set multiple values from an object. Nested values will be joined with a hyphen.
|
|
|
|
|
|
* @param {object} values - The object containing the values to set.
|
|
|
|
|
|
* @param {object} defaults - The object containing the default values.
|
|
|
|
|
|
*
|
|
|
|
|
|
*/
|
|
|
|
|
|
setAll(values, defaults) {
|
|
|
|
|
|
deepEach(values, (value, key, parent, path) => {
|
|
|
|
|
|
let fullPath = [...path, key];
|
2025-05-20 10:16:49 -04:00
|
|
|
|
let param = fullPath.map(kebabCase).join('-');
|
2025-05-09 17:04:06 -04:00
|
|
|
|
|
|
|
|
|
|
if (typeof value === 'object') {
|
|
|
|
|
|
// We'll handle this when we descend into it
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-20 10:16:49 -04:00
|
|
|
|
let defaultValue = deepGet(defaults, fullPath);
|
|
|
|
|
|
|
|
|
|
|
|
if (equals(value, defaultValue)) {
|
2025-05-09 17:04:06 -04:00
|
|
|
|
// Remove the param from the URL
|
|
|
|
|
|
this.delete(param);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.set(param, value);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-20 10:16:49 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Convert the URL params to a (potentially nested) object.
|
|
|
|
|
|
* @param {object} options - Options object.
|
|
|
|
|
|
* @param {(key: string, value: string) => string[]} options.getPath - Function to get the path of a param.
|
|
|
|
|
|
* @returns {object} The nested object.
|
|
|
|
|
|
*/
|
|
|
|
|
|
toObject(options = {}) {
|
|
|
|
|
|
// Default getPath() assumes hyphens always mean nesting
|
|
|
|
|
|
let { ignoreKeys = [], getPath = param => param.split('-') } = options;
|
2025-05-09 17:04:06 -04:00
|
|
|
|
|
|
|
|
|
|
// Get all values as a nested object
|
2025-05-20 10:16:49 -04:00
|
|
|
|
|
2025-05-09 17:04:06 -04:00
|
|
|
|
let obj = {};
|
|
|
|
|
|
|
|
|
|
|
|
for (let [key, value] of this.entries()) {
|
2025-05-20 10:16:49 -04:00
|
|
|
|
let path = getPath(key, value);
|
|
|
|
|
|
|
|
|
|
|
|
if (path === null || ignoreKeys.includes(key)) {
|
|
|
|
|
|
// Skip this param
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Default to key if `getPath()` returns undefined
|
|
|
|
|
|
path ??= key;
|
|
|
|
|
|
|
|
|
|
|
|
path = Array.isArray(path) ? path : [path];
|
|
|
|
|
|
|
|
|
|
|
|
// Camel case any remaining hyphens
|
|
|
|
|
|
path = path.map(camelCase);
|
|
|
|
|
|
|
2025-05-09 17:04:06 -04:00
|
|
|
|
deepSet(obj, path, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return obj;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
delete(key, value) {
|
|
|
|
|
|
let hadValue = this.has(key);
|
|
|
|
|
|
super.delete(key, value);
|
|
|
|
|
|
|
|
|
|
|
|
if (hadValue) {
|
|
|
|
|
|
this.changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-28 17:06:58 -04:00
|
|
|
|
set(key, value, defaultValue) {
|
|
|
|
|
|
if (equals(value, defaultValue) || equals(value, '')) {
|
|
|
|
|
|
value = null;
|
2025-02-13 19:28:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-28 17:06:58 -04:00
|
|
|
|
value ??= null; // undefined -> null
|
2025-02-13 19:28:20 -05:00
|
|
|
|
|
2025-03-28 17:06:58 -04:00
|
|
|
|
let oldValue = Array.isArray(value) ? this.getAll(key) : this.get(key);
|
|
|
|
|
|
let changed = !equals(value, oldValue);
|
2025-02-13 19:28:20 -05:00
|
|
|
|
|
2025-03-28 17:06:58 -04:00
|
|
|
|
if (!changed) {
|
|
|
|
|
|
// Nothing to do here
|
|
|
|
|
|
return;
|
2025-02-13 19:28:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-28 17:06:58 -04:00
|
|
|
|
if (Array.isArray(value)) {
|
2025-02-13 19:28:20 -05:00
|
|
|
|
super.delete(key);
|
2025-03-28 17:06:58 -04:00
|
|
|
|
value = value.slice();
|
|
|
|
|
|
|
|
|
|
|
|
for (let v of value) {
|
|
|
|
|
|
if (v || v === 0) {
|
|
|
|
|
|
if (typeof v === 'object') {
|
|
|
|
|
|
super.append(key, JSON.stringify(v));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
super.append(key, v);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-02-13 19:28:20 -05:00
|
|
|
|
}
|
2025-03-28 17:06:58 -04:00
|
|
|
|
} else if (value === null) {
|
|
|
|
|
|
super.delete(key);
|
2025-02-13 19:28:20 -05:00
|
|
|
|
} else {
|
|
|
|
|
|
super.set(key, value);
|
|
|
|
|
|
}
|
2025-02-18 16:11:40 -05:00
|
|
|
|
|
|
|
|
|
|
this.sort();
|
2025-03-28 17:06:58 -04:00
|
|
|
|
this.changed ||= changed;
|
2025-02-13 19:28:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Update page URL if it has changed since last time
|
|
|
|
|
|
*/
|
|
|
|
|
|
updateLocation() {
|
|
|
|
|
|
if (this.changed) {
|
|
|
|
|
|
// If there’s already a search, replace it.
|
|
|
|
|
|
// We don’t want to clog the user’s history while they iterate
|
|
|
|
|
|
let search = this.toString();
|
|
|
|
|
|
let historyAction = location.search && search ? 'replaceState' : 'pushState';
|
|
|
|
|
|
history[historyAction](null, '', `?${search}`);
|
|
|
|
|
|
this.changed = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-03-28 17:06:58 -04:00
|
|
|
|
|
|
|
|
|
|
function equals(value, oldValue) {
|
|
|
|
|
|
if (Array.isArray(value) || Array.isArray(oldValue)) {
|
|
|
|
|
|
value = toArray(value);
|
|
|
|
|
|
oldValue = toArray(oldValue);
|
|
|
|
|
|
|
|
|
|
|
|
if (value.length !== oldValue.length) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return value.every((v, i) => equals(v, oldValue[i]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// (value ?? oldValue ?? true) returns true if they're both empty (null or undefined)
|
|
|
|
|
|
[value, oldValue] = [value, oldValue].map(v => (!v && v !== false && v !== 0 ? null : v));
|
|
|
|
|
|
return value === oldValue || String(value) === String(oldValue);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Convert a value to an array. `undefined` and `null` values are converted to an empty array.
|
|
|
|
|
|
* @param {*} value - The value to convert.
|
|
|
|
|
|
* @returns {any[]} The converted array.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function toArray(value) {
|
|
|
|
|
|
value ??= [];
|
|
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
|
return value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Don't convert "foo" into ["f", "o", "o"]
|
|
|
|
|
|
if (typeof value !== 'string' && typeof value[Symbol.iterator] === 'function') {
|
|
|
|
|
|
return Array.from(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [value];
|
|
|
|
|
|
}
|