The header claimed that every write to the address bar goes through commitUrl. Only the view keys and the filters do. The tree's other history writers are pinned by check:view-address's census. The header also says why a push copies the previous state: a Vue Router page is a 'host' owner, so its router does its own pushes. Comment only.
111 lines
7 KiB
JavaScript
111 lines
7 KiB
JavaScript
/**
|
|
* useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
|
|
* place. One shared vocabulary and ONE history writer, so a tab, a view or a selection can be reloaded and sent as a
|
|
* link on every host, while a partner's page is never written to.
|
|
*
|
|
* ★ THE SCHEME (query, not hash: a hash is a host page's anchor, and filters already live there).
|
|
* ?tab=<app tab>&pane=<view inside the tab>&place=<selection>
|
|
* Levels: changing `tab` drops `pane` and `place`; changing `pane` drops `place`. Every other parameter (a host's
|
|
* own, the filters useShareableView writes) and the hash are kept as they are. A value that is not a plain id
|
|
* (letters, digits, `-`, `_`, at most 64) reads as absent, so a stale or hand-edited link falls back to the default.
|
|
* `pane`, not `view`: `?view=` is already published by four of our own pages (gospel-internet's board/map, Gospel
|
|
* Resources' map ?view=zero, pplr-data-maps and upg-neighbor lenses), and one of them sits inside a shell tab.
|
|
*
|
|
* ★ ONE WRITER PER PAGE. Only the OUTERMOST element that owns the address writes it; profile-config `urlOwner` says
|
|
* who that is:
|
|
* 'app' our own pages (a generated leaf, Plugin Studio): writeViewKeys() writes history itself;
|
|
* 'host' a host with its own router (DX Life): writeViewKeys() dispatches ADDRESS_EVENT and the HOST writes;
|
|
* absent a partner's page: nothing is written; links are still READ (readViewKeys works everywhere).
|
|
* A nested element never writes: it announces its choice upward (announceView) and the owner records it.
|
|
*
|
|
* ★ THE ONE HISTORY WRITE: commitUrl(). The view keys and useShareableView's filters both write through it, so those
|
|
* two can never undo each other. The tree's other history writers are pinned by check:view-address's census and move
|
|
* onto commitUrl in later phases. On a push, commitUrl copies the previous entry's state (Vue Router's `position` and
|
|
* `back` included). That is correct for a replace. A Vue Router page is a 'host' owner, so its router does its own
|
|
* pushes. It keeps `history.state` and sets its `current` to the
|
|
* new URL. MEASURED on a Vue Router host (2026-09-30): a pushState that drops Router's state fields makes the next
|
|
* in-app navigation rewrite the entry back to Router's last-known URL, and the link is lost. It writes nothing when
|
|
* the URL would not change. A user's tab/pane/place change is a push (one Back step); first paint, link
|
|
* normalisation and filters are a replace.
|
|
*
|
|
* Pure helpers, no dependency. Safe under node and in a sandboxed frame (every access is guarded).
|
|
*/
|
|
|
|
export const VIEW_KEYS = ['tab', 'pane', 'place']
|
|
/** nested element → the address owner: { key, value, push }. Composed + bubbling, so it leaves shadow roots. */
|
|
export const VIEW_EVENT = 'dxm-view'
|
|
/** an owner with urlOwner 'host' → the host's router: { keys, href, push }; the host does router[push ? 'push' : 'replace'](href). */
|
|
export const ADDRESS_EVENT = 'dxm-address'
|
|
|
|
/** A plain id (letters, digits, `-`, `_`, at most 64), or '' for anything else. */
|
|
export const cleanKey = (v) => { const s = String(v ?? '').trim(); return /^[A-Za-z0-9][\w-]{0,63}$/.test(s) ? s : '' }
|
|
|
|
/** { tab, pane, place } from a search string; '' for a key that is absent or not a plain id. The FIRST of a repeated
|
|
* key decides, as URLSearchParams.get does for the shells that already read ?tab=; a malformed escape reads as absent. */
|
|
export function readViewKeys(search) {
|
|
const q = new URLSearchParams(search || '')
|
|
return Object.fromEntries(VIEW_KEYS.map((k) => [k, cleanKey(q.get(k))]))
|
|
}
|
|
|
|
// Bubbling + composed, so the event leaves every shadow root on its way to the owner or the host.
|
|
const send = (el, type, detail) => !!el?.dispatchEvent && (el.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail })), true)
|
|
|
|
/**
|
|
* The search string after `changes` ({ tab?, pane?, place? }; '' or null removes a key), with the level rule: a
|
|
* changed level drops the deeper levels that `changes` does not set itself. Foreign parameters keep their place and
|
|
* their exact text (useShareableView's legible `,` and unicode are not re-encoded). Returns '' or '?…'.
|
|
*/
|
|
export function viewSearch(search, changes = {}) {
|
|
const next = readViewKeys(search)
|
|
VIEW_KEYS.forEach((k, level) => {
|
|
if (!(k in changes) || cleanKey(changes[k]) === next[k]) return
|
|
next[k] = cleanKey(changes[k])
|
|
VIEW_KEYS.slice(level + 1).forEach((d) => { if (!(d in changes)) next[d] = '' })
|
|
})
|
|
const all = VIEW_KEYS.filter((k) => next[k]).map((k) => k + '=' + next[k])
|
|
.concat((search || '').replace(/^\?/, '').split('&').filter((p) => p && !VIEW_KEYS.includes(p.split('=')[0])))
|
|
return all.length ? '?' + all.join('&') : ''
|
|
}
|
|
|
|
/**
|
|
* THE one write to the address bar. `search` ('' or '?…') and `hash` ('' or '#…') default to the current ones, which
|
|
* is the shape viewSearch, serializeQuery and serializeView all return. Keeps history.state and sets its
|
|
* `current` (Vue Router's field) to the new URL. Returns true when it wrote; false when the URL was already that, or
|
|
* the page cannot be written (no history, a sandbox).
|
|
*/
|
|
export function commitUrl({ search, hash } = {}, { push = false } = {}) {
|
|
try {
|
|
const p = location.pathname
|
|
const url = p + (search ?? location.search) + (hash ?? location.hash)
|
|
if (url === p + location.search + location.hash) return false
|
|
history[push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: url }, '', url)
|
|
return true
|
|
} catch (_) { return false }
|
|
}
|
|
|
|
/** 'app' | 'host' | '' from a profile-config. Anything else is '' (a partner's page: read only). */
|
|
export const urlOwnerOf = (cfg) => (cfg && (cfg.urlOwner === 'app' || cfg.urlOwner === 'host') ? cfg.urlOwner : '')
|
|
|
|
/**
|
|
* The owner's write for tab/pane/place. owner 'app': commitUrl (push for a reader's choice). owner 'host': dispatch
|
|
* ADDRESS_EVENT from `el` for the host's router; nothing is written here. Anything else: nothing. Returns what it did:
|
|
* 'written' | 'announced' | 'unchanged' | 'none'.
|
|
*/
|
|
export function writeViewKeys(changes, { owner = '', push = true, el = null } = {}) {
|
|
if ((owner !== 'app' && owner !== 'host') || !globalThis.location) return 'none'
|
|
const search = viewSearch(location.search, changes)
|
|
if (search === location.search) return 'unchanged'
|
|
if (owner === 'app') return commitUrl({ search }, { push }) ? 'written' : 'unchanged'
|
|
return send(el, ADDRESS_EVENT, { keys: readViewKeys(search), href: location.pathname + search + location.hash, push }) ? 'announced' : 'none'
|
|
}
|
|
|
|
/** A nested element reports a reader's choice to whoever owns the address (it never writes itself). */
|
|
export const announceView = (el, key, value, { push = true } = {}) =>
|
|
VIEW_KEYS.includes(key) && send(el, VIEW_EVENT, { key, value: cleanKey(value), push })
|
|
|
|
/** Back / Forward: cb(readViewKeys(location.search)). Re-apply state only; never write from here. Returns off(). */
|
|
export function onAddressChange(cb) {
|
|
const h = () => cb(readViewKeys(location.search))
|
|
globalThis.addEventListener?.('popstate', h)
|
|
return () => globalThis.removeEventListener?.('popstate', h)
|
|
}
|