oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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. */
18export 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. */
27export 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. */
33export 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 */
43export 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