// fixtures/selection.mjs — which block is selected on the stage, and // how that is said: an outline and aria-selected on the block's // element, the block's id in the URL hash after the slide's // (#/), and the inspector's Block section following it. // // Selecting is a click on a block, or a press on the Blocks list in // the inspector (the keyboard's way in); Escape clears. A refresh // replaces the stage's DOM, so the selection is re-applied by id to // the new element after every load — and cleared when the block has // gone, which is how a deleted block leaves the inspector. // // The hash is written with replaceState: a block selection is not a // place to go back to, and the entry's hashchange handler must not // reload the slide for it. The pure parts (the hash's two halves) are // tested in selection.test.mjs. /** '#/' → {slide, block}; a bare '#' has block ''. Never throws. */ export function parseHash (hash) { let raw = (hash || '').replace(/^#/, '') try { raw = decodeURIComponent(raw) } catch { /* a stray %: read as typed */ } const at = raw.indexOf('/') if (at < 0) return { slide: raw, block: '' } return { slide: raw.slice(0, at), block: raw.slice(at + 1) } } /** The hash for a slide and, optionally, a block on it. */ export function formatHash (slide, block) { if (!slide) return '' return '#' + slide + (block ? '/' + block : '') } /** The block element on the stage with this id, or null. */ export function blockElement (stageEl, id) { if (!stageEl || !id) return null return stageEl.querySelector(`[slides-block][data-id="${CSS.escape(id)}"]`) } /** * Mounts the selection over the stage element. getSlideId() is the * stage's current slide; onChange(id) fires with the selected block's * id, or '' on clear. Returns {select, clear, reapply, current}. */ export function mountSelection ({ stageEl, getSlideId, onChange = () => {} }) { let current = '' // Every listener goes on one signal, so destroy() takes all of them // off in one line — the document-level Escape included. A resync // mounts a second selection over the SAME stage element (it is the // page's, not the document's), and without this the first one would // go on marking blocks for a Y.Doc that has been thrown away. const abort = new AbortController() const { signal } = abort function mark (id, on) { const el = blockElement(stageEl, id) if (!el) return false if (on) { el.setAttribute('data-selected', 'true') el.setAttribute('aria-selected', 'true') } else { el.removeAttribute('data-selected') el.removeAttribute('aria-selected') } return true } function writeHash () { const slide = getSlideId() if (!slide) return const want = formatHash(slide, current) if (location.hash !== want) history.replaceState(null, '', want) } // A block just added is selected before the stage has re-fetched // it: the id is kept and the element marked on the next reapply(). function select (id) { if (id === current) return if (current) mark(current, false) current = id || '' mark(current, true) writeHash() onChange(current) } function clear () { if (!current) return mark(current, false) current = '' writeHash() onChange('') } // After a refresh the element is new: mark it again, or let the // selection go with the block. function reapply () { if (!current) return if (!mark(current, true)) { current = '' writeHash() onChange('') } else { onChange(current) } } stageEl.addEventListener('click', e => { const el = e.target.closest('[slides-block][data-id]') if (el && stageEl.contains(el)) select(el.getAttribute('data-id')) }, { signal }) document.addEventListener('keydown', e => { if (e.key !== 'Escape' || e.defaultPrevented || !current) return const target = e.target instanceof Element ? e.target : null if (target && target.closest('dialog, [rst-dropdown]')) return e.preventDefault() clear() if (target instanceof HTMLElement) target.blur() }, { signal }) return { select, clear, reapply, destroy () { abort.abort() }, get current () { return current } } }