doxa-maps/library/composables/useViewAddress.js
Doxa Maps 00f7d349eb Shareable pages P2b: nested shells at any depth have their tab in the address (?subtab= path)
When a shell sits inside another plugin that owns the address (Plugin Studio → the Research Gallery → …), its own
tab had no key. P1 never wrote it from a nested shell, and P2's ownAddress dropped a nested `tab`, so every tab
inside Studio had the same URL.

`subtab` is a PATH, one segment per nested shell below the owner: ?subtab=regions-dashboard at depth 1, and
?subtab=posterverse.upg-100 when a shell sits inside a shell. There are at most 6 segments, each a plain id.
- A nested shell claims its tab with claimView({ tab }, { depth }), where depth comes from its profile-config
  addressDepth (depthOf; its parent passes it down).
- The owner records the claim as that segment: the segments above are kept, the deeper ones and pane/place dropped.
- The same tab again is a no-op (a shell's first-paint claim on a deep link keeps everything below it).
- A segment missing above the claimed depth (a hole) is refused.
- The owner's own tab change drops the whole path.
- subtabAt(search, depth) gives a shell its segment.
Standalone pages and DX Life never see subtab: there the shell is the owner and writes `tab`.

check:view-address H covers depth 1, 2 and 3: first-paint replace, pick push, a composite's pane/place beside
subtab, the same tab claimed again, Back, and the owner's tab change. It has 9 new poisons (52).
2026-10-03 07:50:25 +00:00

168 lines
11 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>&subtab=<nested shells' tabs>&pane=<view inside the tab>&place=<selection>
* `subtab` is a PATH, one segment per shell NESTED below the owner (Plugin Studio → the Research Gallery → …):
* ?subtab=regions-dashboard at depth 1, ?subtab=posterverse.upg-100 when a shell sits inside a shell. A nested
* shell's depth comes from its profile-config `addressDepth` (depthOf), which its parent shell passes down.
* Levels: changing `tab` drops `subtab`, `pane` and `place`, which belong to the tab; changing a subtab segment drops
* the deeper segments and `pane` and `place`. `pane` and `place` are siblings: a
* composite keeps its selection while the reader looks at another of its panes (Regions keeps the region while the
* reader is on the list), so the address keeps it too. 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 (ownAddress).
* claimView() is the one call for a reader's choice: the owner writes it, any other element announces 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', 'subtab', 'pane', 'place']
const LEVEL = { tab: 0, subtab: 1, pane: 2, place: 2 }
const MAX_DEPTH = 6
/** nested element → the address owner: { changes: { tab?, pane?, place? }, push, depth }. A nested SHELL's tab comes
* with its depth and is recorded as that segment of `subtab`. Composed + bubbling, so it leaves
* shadow roots. One event per reader action, so one action is one Back step. */
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 : '' }
/** A subtab PATH: plain-id segments joined by '.', at most MAX_DEPTH; '' if any segment is not a plain id. */
const cleanPath = (v) => { const p = String(v ?? '').trim().split('.'); return p.length <= MAX_DEPTH && p.every((x) => cleanKey(x) === x && x) ? p.join('.') : '' }
const clean = (k, v) => (k === 'subtab' ? cleanPath(v) : cleanKey(v))
/** A nested shell's depth below the address owner: profile-config addressDepth, 1 when absent. */
export const depthOf = (cfg) => Math.min(MAX_DEPTH, Math.max(1, Math.floor(Number(cfg && cfg.addressDepth)) || 1))
/** { 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, clean(k, q.get(k))]))
}
/** The tab a shell nested at `depth` should open: that segment of ?subtab=, or ''. */
export const subtabAt = (search, depth = 1) => readViewKeys(search).subtab.split('.')[depth - 1] || ''
// 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 `tab` drops `pane` and `place` unless `changes` sets them 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) => {
if (!(k in changes) || clean(k, changes[k]) === next[k]) return
next[k] = clean(k, changes[k])
VIEW_KEYS.forEach((d) => { if (LEVEL[d] > LEVEL[k] && !(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)
// Unchanged means the same VALUES, not the same string: viewSearch puts the view keys first, so comparing strings
// rewrote a link like ?owner=x&pane=map as ?pane=map&owner=x on every fresh load (measured in a browser), a
// needless replace (and on a host, a needless router.replace).
const now = readViewKeys(location.search), next = readViewKeys(search)
if (VIEW_KEYS.every((k) => now[k] === next[k])) 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'
}
// Only the view keys, each value cleaned: what an announcement may carry.
const viewChanges = (changes) => Object.fromEntries(Object.entries(changes || {}).filter(([k]) => VIEW_KEYS.includes(k)).map(([k, v]) => [k, clean(k, v)]))
/** A nested element reports a reader's choice to whoever owns the address (it never writes itself). */
export function announceView(el, changes, { push = true, depth = 1 } = {}) {
const c = viewChanges(changes)
return Object.keys(c).length > 0 && send(el, VIEW_EVENT, { changes: c, push, depth })
}
/** THE call for a reader's choice: the owner writes it ('written' | 'announced' | 'unchanged' | 'none'); any other
* element announces it to the owner above it ('announced' | 'none'). */
export const claimView = (changes, { owner = '', el = null, push = true, depth = 1 } = {}) =>
owner ? writeViewKeys(changes, { owner, el, push }) : (announceView(el, changes, { push, depth }) ? 'announced' : 'none')
/** An owner records what the elements inside it announce. `tab` is the owner's own key, so a nested element's tab
* (a nested SHELL's) is recorded as segment `depth` of `subtab`, never as `tab`. The segments above it are kept and
* the deeper ones dropped. The same tab again changes nothing, which keeps the deeper segments, pane and place: a
* shell's first-paint claim on a deep link. A segment above it that is missing (a hole) is refused, so a path never
* names the wrong depth. getOwner() is read per event (a host may set profile-config after mount); while it is ''
* the event passes on to an owner further out. Returns off(). */
export function ownAddress(el, getOwner) {
const h = (e) => {
const owner = getOwner()
if (!owner || !e.detail) return
e.stopPropagation()
const { tab, subtab, ...changes } = viewChanges(e.detail.changes)
if (tab) {
const depth = Math.min(MAX_DEPTH, Math.max(1, Math.floor(e.detail.depth) || 1))
const path = readViewKeys(location.search).subtab.split('.').filter(Boolean)
if (path.length < depth - 1 || path[depth - 1] === tab) return
changes.subtab = path.slice(0, depth - 1).concat(tab).join('.')
}
if (Object.keys(changes).length) writeViewKeys(changes, { owner, el, push: e.detail.push !== false })
}
el?.addEventListener?.(VIEW_EVENT, h)
return () => el?.removeEventListener?.(VIEW_EVENT, h)
}
/** 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)
}