9 changed files with 689 additions and 36 deletions
@ -0,0 +1,239 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
const HIDE_MS = 180; |
||||||
|
|
||||||
|
function el(tag, cls, parent) { |
||||||
|
const e = document.createElement(tag); |
||||||
|
if (cls) { |
||||||
|
e.className = cls; |
||||||
|
} |
||||||
|
if (parent) { |
||||||
|
parent.appendChild(e); |
||||||
|
} |
||||||
|
return e; |
||||||
|
} |
||||||
|
|
||||||
|
function shortNpub(n) { |
||||||
|
if (n == null || n.length < 16) { |
||||||
|
return n || ''; |
||||||
|
} |
||||||
|
return `${n.slice(0, 12)}…${n.slice(-6)}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* In-article highlight marks: hover/focus to show a tooltip of user-badges for everyone |
||||||
|
* who highlighted the same passage (data-hl JSON from {@see \App\Service\ArticleBodyHighlightInjector}). |
||||||
|
*/ |
||||||
|
export default class extends Controller { |
||||||
|
connect() { |
||||||
|
this.tip = el('div', 'user-highlight__tip-popover', document.body); |
||||||
|
this.tip.setAttribute('role', 'tooltip'); |
||||||
|
this.tip.setAttribute('hidden', ''); |
||||||
|
|
||||||
|
this.activeMark = null; |
||||||
|
this._hideT = 0; |
||||||
|
this._inTip = false; |
||||||
|
|
||||||
|
this._onOver = (e) => { |
||||||
|
if (!(e instanceof MouseEvent)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const t = e.target; |
||||||
|
if (!(t instanceof Node)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const m = |
||||||
|
t.nodeType === 1 |
||||||
|
? /** @type {Element} */ (t).closest('mark.user-highlight__marker[data-hl]') |
||||||
|
: t.parentElement?.closest('mark.user-highlight__marker[data-hl]') ?? null; |
||||||
|
if (m) { |
||||||
|
this._cancelHide(); |
||||||
|
this._show(/** @type {HTMLElement} */ (m), e); |
||||||
|
} |
||||||
|
}; |
||||||
|
this._onOut = (e) => { |
||||||
|
if (!(e instanceof MouseEvent)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const t = e.target; |
||||||
|
if (!(t instanceof Node)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const m = |
||||||
|
t.nodeType === 1 |
||||||
|
? /** @type {Element} */ (t).closest('mark.user-highlight__marker[data-hl]') |
||||||
|
: null; |
||||||
|
if (m) { |
||||||
|
const to = e.relatedTarget; |
||||||
|
if (to && (m.contains(to) || (to instanceof Node && this.tip.contains(/** @type {Node} */(to))))) { |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
this._scheduleHide(); |
||||||
|
}; |
||||||
|
|
||||||
|
this.tip.addEventListener('mouseenter', () => { |
||||||
|
this._inTip = true; |
||||||
|
this._cancelHide(); |
||||||
|
}); |
||||||
|
this.tip.addEventListener('mouseleave', () => { |
||||||
|
this._inTip = false; |
||||||
|
this._scheduleHide(); |
||||||
|
}); |
||||||
|
|
||||||
|
this._onFocus = (e) => { |
||||||
|
const t = e.target; |
||||||
|
if (!(t instanceof Element)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const m = t.closest('mark.user-highlight__marker[data-hl]'); |
||||||
|
if (m) { |
||||||
|
this._cancelHide(); |
||||||
|
this._show(/** @type {HTMLElement} */ (m), e); |
||||||
|
} |
||||||
|
}; |
||||||
|
this._onBlur = (e) => { |
||||||
|
const t = e.target; |
||||||
|
if (!(t instanceof Node)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const m = t.nodeType === 1 ? t.closest('mark.user-highlight__marker[data-hl]') : null; |
||||||
|
if (m) { |
||||||
|
const to = e.relatedTarget; |
||||||
|
if (to && (m.contains(to) || (to instanceof Node && this.tip.contains(/** @type {Node} */ (to))))) { |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
this._scheduleHide(); |
||||||
|
}; |
||||||
|
|
||||||
|
this.element.addEventListener('mouseover', this._onOver); |
||||||
|
this.element.addEventListener('mouseout', this._onOut); |
||||||
|
this.element.addEventListener('focusin', this._onFocus); |
||||||
|
this.element.addEventListener('focusout', this._onBlur); |
||||||
|
|
||||||
|
this._onResize = () => { |
||||||
|
if (this.activeMark) { |
||||||
|
this._place(this.activeMark); |
||||||
|
} |
||||||
|
}; |
||||||
|
window.addEventListener('resize', this._onResize); |
||||||
|
} |
||||||
|
|
||||||
|
disconnect() { |
||||||
|
this.element.removeEventListener('mouseover', this._onOver); |
||||||
|
this.element.removeEventListener('mouseout', this._onOut); |
||||||
|
this.element.removeEventListener('focusin', this._onFocus); |
||||||
|
this.element.removeEventListener('focusout', this._onBlur); |
||||||
|
window.removeEventListener('resize', this._onResize); |
||||||
|
this._cancelHide(); |
||||||
|
this.tip.remove(); |
||||||
|
} |
||||||
|
|
||||||
|
_cancelHide() { |
||||||
|
if (this._hideT) { |
||||||
|
clearTimeout(this._hideT); |
||||||
|
this._hideT = 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
_scheduleHide() { |
||||||
|
this._cancelHide(); |
||||||
|
this._hideT = window.setTimeout(() => { |
||||||
|
this._hideT = 0; |
||||||
|
if (this._inTip) { |
||||||
|
return; |
||||||
|
} |
||||||
|
this._doHide(); |
||||||
|
}, HIDE_MS); |
||||||
|
} |
||||||
|
|
||||||
|
_doHide() { |
||||||
|
this.tip.setAttribute('hidden', ''); |
||||||
|
this.tip.replaceChildren(); |
||||||
|
this.activeMark = null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {HTMLElement} mark |
||||||
|
* @param {UIEvent} _e |
||||||
|
*/ |
||||||
|
_show(mark, _e) { |
||||||
|
this.activeMark = mark; |
||||||
|
const raw = mark.getAttribute('data-hl'); |
||||||
|
if (raw == null || raw === '') { |
||||||
|
this._doHide(); |
||||||
|
return; |
||||||
|
} |
||||||
|
/** @type {Array<{e?: string, n: string, a?: string, p?: string}>} */ |
||||||
|
let rows; |
||||||
|
try { |
||||||
|
rows = JSON.parse(raw); |
||||||
|
} catch { |
||||||
|
this._doHide(); |
||||||
|
return; |
||||||
|
} |
||||||
|
if (!Array.isArray(rows) || rows.length === 0) { |
||||||
|
this._doHide(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.tip.removeAttribute('hidden'); |
||||||
|
this.tip.replaceChildren(); |
||||||
|
|
||||||
|
const head = el('div', 'user-highlight__tip-head', this.tip); |
||||||
|
head.textContent = 'Highlighted by'; |
||||||
|
|
||||||
|
const list = el('ul', 'user-highlight__tip-list', this.tip); |
||||||
|
for (const row of rows) { |
||||||
|
if (!row || typeof row.n !== 'string' || !row.n.startsWith('npub1')) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
const li = el('li', 'user-highlight__tip-item', list); |
||||||
|
const a = el('a', 'user-badge user-badge--in-tip', li); |
||||||
|
a.setAttribute('href', `/p/${encodeURIComponent(row.n)}`); |
||||||
|
const label = (row.a && String(row.a).trim() !== '' ? String(row.a) : shortNpub(row.n)) || shortNpub(row.n); |
||||||
|
|
||||||
|
const av = el('span', 'user-badge__avatar user-badge__avatar--in-tip', a); |
||||||
|
if (row.p && typeof row.p === 'string' && row.p.length > 0) { |
||||||
|
const im = el('img', 'user-badge__avatar-img', av); |
||||||
|
im.setAttribute('src', row.p); |
||||||
|
im.setAttribute('alt', ''); |
||||||
|
im.setAttribute('loading', 'lazy'); |
||||||
|
im.addEventListener('error', () => { |
||||||
|
im.remove(); |
||||||
|
av.setAttribute('aria-hidden', 'true'); |
||||||
|
const dot = el('span', 'user-badge__avatar-fallback--dot', av); |
||||||
|
dot.textContent = label.charAt(0).toUpperCase() || '…'; |
||||||
|
}); |
||||||
|
} else { |
||||||
|
av.setAttribute('aria-hidden', 'true'); |
||||||
|
const dot = el('span', 'user-badge__avatar-fallback--dot', av); |
||||||
|
dot.textContent = label.charAt(0).toUpperCase() || '…'; |
||||||
|
} |
||||||
|
const nm = el('span', 'user-badge__name', a); |
||||||
|
nm.appendChild(document.createTextNode(label)); |
||||||
|
} |
||||||
|
|
||||||
|
requestAnimationFrame(() => { |
||||||
|
this._place(mark); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {HTMLElement} mark |
||||||
|
*/ |
||||||
|
_place(mark) { |
||||||
|
const r = mark.getBoundingClientRect(); |
||||||
|
const pad = 8; |
||||||
|
this.tip.style.position = 'fixed'; |
||||||
|
this.tip.style.zIndex = '2000'; |
||||||
|
this.tip.style.top = `${Math.round(r.bottom + pad)}px`; |
||||||
|
let left = Math.round(r.left); |
||||||
|
const w = this.tip.getBoundingClientRect().width || 300; |
||||||
|
if (left + w + 12 > window.innerWidth) { |
||||||
|
left = Math.max(8, window.innerWidth - w - 8); |
||||||
|
} |
||||||
|
this.tip.style.left = `${left}px`; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue