oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/presence-view.mjs — where everybody else is, drawn: a small
2// avatar on the strip item of the slide each person is on, and an
3// outline with their name over the block they have selected.
4//
5// Two rules run through all of it.
6//
7// The name is always there. A colour alone says "somebody" and never
8// says who, and to a person who cannot tell two of the six palette
9// colours apart it says nothing at all (WCAG 2.2 1.4.1). So an avatar
10// carries the person's name in `title` and in its label, and a block
11// outline carries a chip with the name written out.
12//
13// Movement is not announced. A colleague arriving or leaving is worth
14// one polite line; a colleague clicking down their own slide is not,
15// and a live region that spoke every time somebody moved would make
16// the deck unusable with a screen reader (2.2's "aria-live sparingly",
17// and the plan's own note). announce() takes the diff and says only
18// arrivals and departures, one message per diff.
19//
20// Nothing here touches the Y.Doc or any block's DOM. The marks are an
21// overlay laid over the stage, positioned from the blocks' rectangles
22// the way canvas.mjs positions its own — a chip appended INSIDE a
23// block would be a node ProseMirror does not know about, inside the
24// element it manages.
25import { initials } from './presence.mjs'
26
27// How many avatars one strip item shows before the rest become "+N".
28// Three fits the 200px item; the count carries the rest, and the
29// title of each avatar carries the names that are shown.
30const MAX_AVATARS = 3
31
32// The palette is the server's (auth.go's presenceColours), but this is
33// a value off the wire going into a style, so it is checked rather
34// than trusted: a hex colour, or nothing.
35const HEX = /^#[0-9a-fA-F]{3,8}$/
36
37/** "{name} is on this slide" with the name in it. */
38export function format (template, values) {
39 return String(template || '').replace(/\{(\w+)\}/g, (m, k) => (k in values ? String(values[k]) : m))
40}
41
42function colourOf (p) {
43 return HEX.test(p.colour || '') ? p.colour : ''
44}
45
46/**
47 * Mounts the presence drawing. stripEl is the strip's <ol>, stageEl
48 * the stage the slide's section is in, liveEl the polite region
49 * arrivals are announced in; text is the catalog's strings (slide,
50 * block, more, joined, left); getSlideId() is the slide on stage.
51 *
52 * Returns {render, announce, place, destroy}.
53 */
54export function mountPresenceView ({ stripEl, stageEl, liveEl, text = {}, getSlideId = () => '' }) {
55 let people = []
56
57 const marks = document.createElement('div')
58 marks.setAttribute('slides-presence-marks', '')
59 if (stageEl) stageEl.append(marks)
60
61 function avatar (p) {
62 const label = format(text.slide, { name: p.name })
63 const el = document.createElement('span')
64 el.setAttribute('slides-presence-avatar', '')
65 el.setAttribute('role', 'img')
66 el.setAttribute('aria-label', label)
67 el.title = label
68 const colour = colourOf(p)
69 if (colour) el.style.setProperty('--slides-presence-colour', colour)
70 const letters = document.createElement('span')
71 letters.setAttribute('aria-hidden', 'true')
72 letters.textContent = initials(p.name)
73 el.append(letters)
74 return el
75 }
76
77 function more (n) {
78 const label = format(text.more, { n })
79 const el = document.createElement('span')
80 el.setAttribute('slides-presence-more', '')
81 el.setAttribute('role', 'img')
82 el.setAttribute('aria-label', label)
83 el.title = label
84 el.textContent = '+' + n
85 return el
86 }
87
88 // One strip item's avatars, rebuilt: the list is short and changes
89 // rarely, so replacing it is simpler than reconciling it and cannot
90 // leave a stale face behind. Anchored on the thumbnail, not the
91 // item — the item's own corners are the drag handle and the item
92 // menu (editor.css), and the thumbnail is where a face reads best.
93 function paintItem (li, here) {
94 const anchor = li.querySelector('[slides-thumb]') || li
95 let box = anchor.querySelector('[slides-presence-avatars]')
96 if (here.length === 0) {
97 if (box) box.remove()
98 return
99 }
100 if (!box) {
101 box = document.createElement('div')
102 box.setAttribute('slides-presence-avatars', '')
103 anchor.append(box)
104 }
105 box.replaceChildren()
106 for (const p of here.slice(0, MAX_AVATARS)) box.append(avatar(p))
107 if (here.length > MAX_AVATARS) box.append(more(here.length - MAX_AVATARS))
108 }
109
110 function paintStrip () {
111 if (!stripEl) return
112 const bySlide = new Map()
113 for (const p of people) {
114 if (!p.slide) continue
115 if (!bySlide.has(p.slide)) bySlide.set(p.slide, [])
116 bySlide.get(p.slide).push(p)
117 }
118 for (const li of stripEl.querySelectorAll('[slides-strip-item]')) {
119 paintItem(li, bySlide.get(li.dataset.slideId) || [])
120 }
121 }
122
123 function mark (p) {
124 const el = document.createElement('div')
125 el.setAttribute('slides-presence-mark', '')
126 const colour = colourOf(p)
127 if (colour) el.style.setProperty('--slides-presence-colour', colour)
128 const chip = document.createElement('span')
129 chip.setAttribute('slides-presence-chip', '')
130 // The chip is the name, written out, and its label says what the
131 // outline means — the colour is never the message.
132 chip.setAttribute('role', 'img')
133 const label = format(text.block, { name: p.name })
134 chip.setAttribute('aria-label', label)
135 chip.title = label
136 chip.textContent = p.name
137 el.append(chip)
138 return el
139 }
140
141 // The overlay, laid over the blocks other people have selected on
142 // the slide that is on stage. Rebuilt on every place(), because a
143 // load replaces the stage's DOM and every rectangle with it.
144 function place () {
145 if (!stageEl) return
146 if (!marks.isConnected) stageEl.append(marks)
147 marks.replaceChildren()
148 const slide = getSlideId()
149 if (!slide) return
150 const frame = stageEl.getBoundingClientRect()
151 for (const p of people) {
152 if (p.slide !== slide || !p.block) continue
153 const block = stageEl.querySelector(`[slides-block][data-id="${CSS.escape(p.block)}"]`)
154 if (!block) continue
155 const r = block.getBoundingClientRect()
156 const el = mark(p)
157 el.style.left = (r.left - frame.left + stageEl.scrollLeft) + 'px'
158 el.style.top = (r.top - frame.top + stageEl.scrollTop) + 'px'
159 el.style.width = r.width + 'px'
160 el.style.height = r.height + 'px'
161 marks.append(el)
162 }
163 }
164
165 const ro = typeof ResizeObserver === 'function' && stageEl ? new ResizeObserver(() => place()) : null
166 if (ro) ro.observe(stageEl)
167
168 return {
169 /** The whole set (this tab already left out), drawn. */
170 render (next) {
171 people = Array.isArray(next) ? next : []
172 paintStrip()
173 place()
174 },
175
176 /**
177 * One polite line for the arrivals and departures in a diff, and
178 * nothing at all for a move. Several at once are one message: a
179 * reconnect can bring three people back, and three interruptions
180 * for one event is the noise this rule exists to prevent.
181 */
182 announce ({ added = [], removed = [] } = {}) {
183 if (!liveEl) return ''
184 const said = [
185 ...added.map(p => format(text.joined, { name: p.name })),
186 ...removed.map(p => format(text.left, { name: p.name }))
187 ].filter(Boolean)
188 if (said.length === 0) return ''
189 const message = said.join(' ')
190 liveEl.textContent = message
191 return message
192 },
193
194 place,
195
196 destroy () {
197 if (ro) ro.disconnect()
198 marks.remove()
199 if (stripEl) for (const box of stripEl.querySelectorAll('[slides-presence-avatars]')) box.remove()
200 }
201 }
202}
203