// 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()
}
}
}