| 1 | // fixtures/selection.mjs — which block is selected on the stage, and |
| 2 | // how that is said: an outline and aria-selected on the block's |
| 3 | // element, the block's id in the URL hash after the slide's |
| 4 | // (#<slide>/<block>), and the inspector's Block section following it. |
| 5 | // |
| 6 | // Selecting is a click on a block, or a press on the Blocks list in |
| 7 | // the inspector (the keyboard's way in); Escape clears. A refresh |
| 8 | // replaces the stage's DOM, so the selection is re-applied by id to |
| 9 | // the new element after every load — and cleared when the block has |
| 10 | // gone, which is how a deleted block leaves the inspector. |
| 11 | // |
| 12 | // The hash is written with replaceState: a block selection is not a |
| 13 | // place to go back to, and the entry's hashchange handler must not |
| 14 | // reload the slide for it. The pure parts (the hash's two halves) are |
| 15 | // tested in selection.test.mjs. |
| 16 | |
| 17 | /** '#<slide>/<block>' → {slide, block}; a bare '#<slide>' has block ''. Never throws. */ |
| 18 | export function parseHash (hash) { |
| 19 | let raw = (hash || '').replace(/^#/, '') |
| 20 | try { raw = decodeURIComponent(raw) } catch { /* a stray %: read as typed */ } |
| 21 | const at = raw.indexOf('/') |
| 22 | if (at < 0) return { slide: raw, block: '' } |
| 23 | return { slide: raw.slice(0, at), block: raw.slice(at + 1) } |
| 24 | } |
| 25 | |
| 26 | /** The hash for a slide and, optionally, a block on it. */ |
| 27 | export function formatHash (slide, block) { |
| 28 | if (!slide) return '' |
| 29 | return '#' + slide + (block ? '/' + block : '') |
| 30 | } |
| 31 | |
| 32 | /** The block element on the stage with this id, or null. */ |
| 33 | export function blockElement (stageEl, id) { |
| 34 | if (!stageEl || !id) return null |
| 35 | return stageEl.querySelector(`[slides-block][data-id="${CSS.escape(id)}"]`) |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Mounts the selection over the stage element. getSlideId() is the |
| 40 | * stage's current slide; onChange(id) fires with the selected block's |
| 41 | * id, or '' on clear. Returns {select, clear, reapply, current}. |
| 42 | */ |
| 43 | export function mountSelection ({ stageEl, getSlideId, onChange = () => {} }) { |
| 44 | let current = '' |
| 45 | // Every listener goes on one signal, so destroy() takes all of them |
| 46 | // off in one line — the document-level Escape included. A resync |
| 47 | // mounts a second selection over the SAME stage element (it is the |
| 48 | // page's, not the document's), and without this the first one would |
| 49 | // go on marking blocks for a Y.Doc that has been thrown away. |
| 50 | const abort = new AbortController() |
| 51 | const { signal } = abort |
| 52 | |
| 53 | function mark (id, on) { |
| 54 | const el = blockElement(stageEl, id) |
| 55 | if (!el) return false |
| 56 | if (on) { |
| 57 | el.setAttribute('data-selected', 'true') |
| 58 | el.setAttribute('aria-selected', 'true') |
| 59 | } else { |
| 60 | el.removeAttribute('data-selected') |
| 61 | el.removeAttribute('aria-selected') |
| 62 | } |
| 63 | return true |
| 64 | } |
| 65 | |
| 66 | function writeHash () { |
| 67 | const slide = getSlideId() |
| 68 | if (!slide) return |
| 69 | const want = formatHash(slide, current) |
| 70 | if (location.hash !== want) history.replaceState(null, '', want) |
| 71 | } |
| 72 | |
| 73 | // A block just added is selected before the stage has re-fetched |
| 74 | // it: the id is kept and the element marked on the next reapply(). |
| 75 | function select (id) { |
| 76 | if (id === current) return |
| 77 | if (current) mark(current, false) |
| 78 | current = id || '' |
| 79 | mark(current, true) |
| 80 | writeHash() |
| 81 | onChange(current) |
| 82 | } |
| 83 | |
| 84 | function clear () { |
| 85 | if (!current) return |
| 86 | mark(current, false) |
| 87 | current = '' |
| 88 | writeHash() |
| 89 | onChange('') |
| 90 | } |
| 91 | |
| 92 | // After a refresh the element is new: mark it again, or let the |
| 93 | // selection go with the block. |
| 94 | function reapply () { |
| 95 | if (!current) return |
| 96 | if (!mark(current, true)) { |
| 97 | current = '' |
| 98 | writeHash() |
| 99 | onChange('') |
| 100 | } else { |
| 101 | onChange(current) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | stageEl.addEventListener('click', e => { |
| 106 | const el = e.target.closest('[slides-block][data-id]') |
| 107 | if (el && stageEl.contains(el)) select(el.getAttribute('data-id')) |
| 108 | }, { signal }) |
| 109 | document.addEventListener('keydown', e => { |
| 110 | if (e.key !== 'Escape' || e.defaultPrevented || !current) return |
| 111 | const target = e.target instanceof Element ? e.target : null |
| 112 | if (target && target.closest('dialog, [rst-dropdown]')) return |
| 113 | e.preventDefault() |
| 114 | clear() |
| 115 | if (target instanceof HTMLElement) target.blur() |
| 116 | }, { signal }) |
| 117 | |
| 118 | return { |
| 119 | select, |
| 120 | clear, |
| 121 | reapply, |
| 122 | destroy () { abort.abort() }, |
| 123 | get current () { return current } |
| 124 | } |
| 125 | } |
| 126 | |