aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/urlState.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/urlState.js')
-rw-r--r--public/bluebey-studio/src/urlState.js79
1 files changed, 77 insertions, 2 deletions
diff --git a/public/bluebey-studio/src/urlState.js b/public/bluebey-studio/src/urlState.js
index a3a7e48..d6fb6bd 100644
--- a/public/bluebey-studio/src/urlState.js
+++ b/public/bluebey-studio/src/urlState.js
@@ -24,8 +24,16 @@
* `decodeState` is deliberately total: any malformed, truncated or hostile input
* returns `null` rather than throwing, because it is fed whatever came out of
* the address bar.
+ *
+ * Most of a session is still at its default, and a default costs bytes on every
+ * link. `pruneDefaults` drops exactly the fields that equal `defaultState()`, and
+ * `restoreDefaults` merges what is left back onto a fresh default, so the link
+ * carries only what the user actually changed. The pruning is lossless and the
+ * decoder still accepts the older, full-state links.
*/
+import { applyPatch, defaultState } from './presets.js';
+
/** Above this length a `data:` background image is a photo, not a link. */
const MAX_INLINE_IMAGE = 2048;
@@ -53,6 +61,73 @@ export function stripForUrl(state) {
return copy;
}
+/** Plain objects only: not `null`, not an array. */
+function isPlainObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+/** Structural equality for the JSON-shaped values this module deals in. */
+function deepEqual(a, b) {
+ if (a === b) return true;
+ if (typeof a !== typeof b || a === null || b === null) return false;
+ if (typeof a !== 'object') return Number.isNaN(a) && Number.isNaN(b);
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+ if (Array.isArray(a)) {
+ return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
+ }
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ if (aKeys.length !== bKeys.length) return false;
+ return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]));
+}
+
+/** A detached copy of a JSON-shaped value. */
+function cloneValue(value) {
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
+}
+
+/** Marks a value that equals its default, so its key can be left out entirely. */
+const OMIT = Symbol('urlState.omit');
+
+/**
+ * `value` with every sub-tree that deep-equals the matching `def` removed.
+ * Arrays are kept whole - never pruned element by element - because that is how
+ * `applyPatch` replaces them; objects are pruned key by key. `OMIT` means the
+ * value matched its default and can be dropped from the parent.
+ */
+function pruneValue(value, def) {
+ if (deepEqual(value, def)) return OMIT;
+ if (isPlainObject(value)) {
+ if (!isPlainObject(def)) return cloneValue(value);
+ const out = {};
+ for (const [key, child] of Object.entries(value)) {
+ const pruned = pruneValue(child, def[key]);
+ if (pruned !== OMIT) out[key] = pruned;
+ }
+ return Object.keys(out).length === 0 ? OMIT : out;
+ }
+ return Array.isArray(value) ? cloneValue(value) : value;
+}
+
+/**
+ * A copy of `state` with every value equal to `defaultState()` omitted. Lossless:
+ * `restoreDefaults` merges the result onto a fresh default. Arrays that differ
+ * from the default are carried whole so `applyPatch` can replace them.
+ */
+export function pruneDefaults(state) {
+ const pruned = pruneValue(state, defaultState());
+ return pruned === OMIT ? {} : pruned;
+}
+
+/**
+ * The inverse of `pruneDefaults`: a full state, with every field the partial does
+ * not mention left at its default. Merging a previously full state is idempotent,
+ * so links made by the older encoder still decode.
+ */
+export function restoreDefaults(partial) {
+ return applyPatch(defaultState(), partial);
+}
+
/** True when the encoded fragment is bigger than a URL can comfortably hold. */
export function isTooLong(text, limit = 1800) {
return typeof text === 'string' && text.length > limit;
@@ -117,7 +192,7 @@ async function inflateRaw(bytes) {
* @returns {Promise<string>} a URL-safe string (tag + base64url)
*/
export async function encodeState(state) {
- const json = JSON.stringify(stripForUrl(state) ?? null);
+ const json = JSON.stringify(pruneDefaults(stripForUrl(state)));
const bytes = new TextEncoder().encode(json);
if (typeof CompressionStream === 'function') {
@@ -155,7 +230,7 @@ export async function decodeState(text) {
const parsed = JSON.parse(new TextDecoder().decode(bytes));
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
- return { state: parsed };
+ return { state: restoreDefaults(parsed) };
} catch {
return null;
}