// fixtures/presence-view.mjs — where everybody else is, drawn: a small // avatar on the strip item of the slide each person is on, and an // outline with their name over the block they have selected. // // Two rules run through all of it. // // The name is always there. A colour alone says "somebody" and never // says who, and to a person who cannot tell two of the six palette // colours apart it says nothing at all (WCAG 2.2 1.4.1). So an avatar // carries the person's name in `title` and in its label, and a block // outline carries a chip with the name written out. // // Movement is not announced. A colleague arriving or leaving is worth // one polite line; a colleague clicking down their own slide is not, // and a live region that spoke every time somebody moved would make // the deck unusable with a screen reader (2.2's "aria-live sparingly", // and the plan's own note). announce() takes the diff and says only // arrivals and departures, one message per diff. // // Nothing here touches the Y.Doc or any block's DOM. The marks are an // overlay laid over the stage, positioned from the blocks' rectangles // the way canvas.mjs positions its own — a chip appended INSIDE a // block would be a node ProseMirror does not know about, inside the // element it manages. import { initials } from './presence.mjs' // How many avatars one strip item shows before the rest become "+N". // Three fits the 200px item; the count carries the rest, and the // title of each avatar carries the names that are shown. const MAX_AVATARS = 3 // The palette is the server's (auth.go's presenceColours), but this is // a value off the wire going into a style, so it is checked rather // than trusted: a hex colour, or nothing. const HEX = /^#[0-9a-fA-F]{3,8}$/ /** "{name} is on this slide" with the name in it. */ export function format (template, values) { return String(template || '').replace(/\{(\w+)\}/g, (m, k) => (k in values ? String(values[k]) : m)) } function colourOf (p) { return HEX.test(p.colour || '') ? p.colour : '' } /** * Mounts the presence drawing. stripEl is the strip's
    , stageEl * the stage the slide's section is in, liveEl the polite region * arrivals are announced in; text is the catalog's strings (slide, * block, more, joined, left); getSlideId() is the slide on stage. * * Returns {render, announce, place, destroy}. */ export function mountPresenceView ({ stripEl, stageEl, liveEl, text = {}, getSlideId = () => '' }) { let people = [] const marks = document.createElement('div') marks.setAttribute('slides-presence-marks', '') if (stageEl) stageEl.append(marks) function avatar (p) { const label = format(text.slide, { name: p.name }) const el = document.createElement('span') el.setAttribute('slides-presence-avatar', '') el.setAttribute('role', 'img') el.setAttribute('aria-label', label) el.title = label const colour = colourOf(p) if (colour) el.style.setProperty('--slides-presence-colour', colour) const letters = document.createElement('span') letters.setAttribute('aria-hidden', 'true') letters.textContent = initials(p.name) el.append(letters) return el } function more (n) { const label = format(text.more, { n }) const el = document.createElement('span') el.setAttribute('slides-presence-more', '') el.setAttribute('role', 'img') el.setAttribute('aria-label', label) el.title = label el.textContent = '+' + n return el } // One strip item's avatars, rebuilt: the list is short and changes // rarely, so replacing it is simpler than reconciling it and cannot // leave a stale face behind. Anchored on the thumbnail, not the // item — the item's own corners are the drag handle and the item // menu (editor.css), and the thumbnail is where a face reads best. function paintItem (li, here) { const anchor = li.querySelector('[slides-thumb]') || li let box = anchor.querySelector('[slides-presence-avatars]') if (here.length === 0) { if (box) box.remove() return } if (!box) { box = document.createElement('div') box.setAttribute('slides-presence-avatars', '') anchor.append(box) } box.replaceChildren() for (const p of here.slice(0, MAX_AVATARS)) box.append(avatar(p)) if (here.length > MAX_AVATARS) box.append(more(here.length - MAX_AVATARS)) } function paintStrip () { if (!stripEl) return const bySlide = new Map() for (const p of people) { if (!p.slide) continue if (!bySlide.has(p.slide)) bySlide.set(p.slide, []) bySlide.get(p.slide).push(p) } for (const li of stripEl.querySelectorAll('[slides-strip-item]')) { paintItem(li, bySlide.get(li.dataset.slideId) || []) } } function mark (p) { const el = document.createElement('div') el.setAttribute('slides-presence-mark', '') const colour = colourOf(p) if (colour) el.style.setProperty('--slides-presence-colour', colour) const chip = document.createElement('span') chip.setAttribute('slides-presence-chip', '') // The chip is the name, written out, and its label says what the // outline means — the colour is never the message. chip.setAttribute('role', 'img') const label = format(text.block, { name: p.name }) chip.setAttribute('aria-label', label) chip.title = label chip.textContent = p.name el.append(chip) return el } // The overlay, laid over the blocks other people have selected on // the slide that is on stage. Rebuilt on every place(), because a // load replaces the stage's DOM and every rectangle with it. function place () { if (!stageEl) return if (!marks.isConnected) stageEl.append(marks) marks.replaceChildren() const slide = getSlideId() if (!slide) return const frame = stageEl.getBoundingClientRect() for (const p of people) { if (p.slide !== slide || !p.block) continue const block = stageEl.querySelector(`[slides-block][data-id="${CSS.escape(p.block)}"]`) if (!block) continue const r = block.getBoundingClientRect() const el = mark(p) el.style.left = (r.left - frame.left + stageEl.scrollLeft) + 'px' el.style.top = (r.top - frame.top + stageEl.scrollTop) + 'px' el.style.width = r.width + 'px' el.style.height = r.height + 'px' marks.append(el) } } const ro = typeof ResizeObserver === 'function' && stageEl ? new ResizeObserver(() => place()) : null if (ro) ro.observe(stageEl) return { /** The whole set (this tab already left out), drawn. */ render (next) { people = Array.isArray(next) ? next : [] paintStrip() place() }, /** * One polite line for the arrivals and departures in a diff, and * nothing at all for a move. Several at once are one message: a * reconnect can bring three people back, and three interruptions * for one event is the noise this rule exists to prevent. */ announce ({ added = [], removed = [] } = {}) { if (!liveEl) return '' const said = [ ...added.map(p => format(text.joined, { name: p.name })), ...removed.map(p => format(text.left, { name: p.name })) ].filter(Boolean) if (said.length === 0) return '' const message = said.join(' ') liveEl.textContent = message return message }, place, destroy () { if (ro) ro.disconnect() marks.remove() if (stripEl) for (const box of stripEl.querySelectorAll('[slides-presence-avatars]')) box.remove() } } }