18 changed files with 79 additions and 513 deletions
@ -1,290 +0,0 @@ |
|||||||
import { Controller } from '@hotwired/stimulus'; |
|
||||||
|
|
||||||
const MARK_SEL = 'mark.article-body-highlight'; |
|
||||||
|
|
||||||
/** |
|
||||||
* In-article NIP-84 marks: show popover on hover / click; #h-<64-hex> scrolls and focuses the mark. |
|
||||||
*/ |
|
||||||
export default class extends Controller { |
|
||||||
static targets = ['article', 'meta', 'popover', 'popoverInner']; |
|
||||||
|
|
||||||
connect() { |
|
||||||
this._metaById = {}; |
|
||||||
if (this.hasMetaTarget) { |
|
||||||
try { |
|
||||||
this._metaById = JSON.parse(this.metaTarget.textContent || '{}'); |
|
||||||
} catch { |
|
||||||
this._metaById = {}; |
|
||||||
} |
|
||||||
} |
|
||||||
this._pinnedId = null; |
|
||||||
this._openId = null; |
|
||||||
this._hoverLeaveTimer = null; |
|
||||||
this._onHash = () => { |
|
||||||
this._scrollToHash(); |
|
||||||
}; |
|
||||||
this._onScrollReposition = () => { |
|
||||||
if (this._openId && this.hasPopoverTarget) { |
|
||||||
const a = this._getMarkByEventId(this._openId); |
|
||||||
if (a) { |
|
||||||
this._placePopover(a); |
|
||||||
} |
|
||||||
} |
|
||||||
}; |
|
||||||
this._onPopoverPointerEnter = () => { |
|
||||||
this._clearHoverLeaveTimer(); |
|
||||||
}; |
|
||||||
this._onPopoverPointerLeave = () => { |
|
||||||
if (!this._pinnedId) { |
|
||||||
this._hoverLeaveTimer = setTimeout(() => { |
|
||||||
this._hoverLeaveTimer = null; |
|
||||||
this._hideUnpinnedPopover(); |
|
||||||
}, 200); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
this._onMarkPointerEnter = (e) => { |
|
||||||
const a = e.target && e.target.closest && e.target.closest(MARK_SEL); |
|
||||||
if (!a) { |
|
||||||
return; |
|
||||||
} |
|
||||||
this._clearHoverLeaveTimer(); |
|
||||||
this._openForMark(a, false); |
|
||||||
}; |
|
||||||
this._onMarkPointerLeave = (e) => { |
|
||||||
const a = e.target && e.target.closest && e.target.closest(MARK_SEL); |
|
||||||
if (!a) { |
|
||||||
return; |
|
||||||
} |
|
||||||
this._onMarkLeave(a); |
|
||||||
}; |
|
||||||
this._onMarkClick = (e) => { |
|
||||||
const a = e.target && e.target.closest && e.target.closest(MARK_SEL); |
|
||||||
if (!a) { |
|
||||||
return; |
|
||||||
} |
|
||||||
e.preventDefault(); |
|
||||||
e.stopPropagation(); |
|
||||||
const id = (a.getAttribute('data-event-id') || '').toLowerCase(); |
|
||||||
if (this._pinnedId === id) { |
|
||||||
this._closePinned(); |
|
||||||
this._hideUnpinnedPopover(); |
|
||||||
return; |
|
||||||
} |
|
||||||
this._openForMark(a, true); |
|
||||||
}; |
|
||||||
this._onMarkFocus = (e) => { |
|
||||||
const t = e.target; |
|
||||||
if (!t || !t.classList || !t.classList.contains('article-body-highlight')) { |
|
||||||
return; |
|
||||||
} |
|
||||||
this._openForMark(t, false); |
|
||||||
}; |
|
||||||
this._onMarkKeydown = (e) => { |
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') { |
|
||||||
return; |
|
||||||
} |
|
||||||
const t = e.target; |
|
||||||
if (!t || !t.classList || !t.classList.contains('article-body-highlight')) { |
|
||||||
return; |
|
||||||
} |
|
||||||
e.preventDefault(); |
|
||||||
this._openForMark(t, true); |
|
||||||
}; |
|
||||||
|
|
||||||
if (this.hasArticleTarget) { |
|
||||||
this.articleTarget.addEventListener('pointerenter', this._onMarkPointerEnter, true); |
|
||||||
this.articleTarget.addEventListener('pointerleave', this._onMarkPointerLeave, true); |
|
||||||
this.articleTarget.addEventListener('click', this._onMarkClick, true); |
|
||||||
this.articleTarget.addEventListener('focusin', this._onMarkFocus, true); |
|
||||||
this.articleTarget.addEventListener('keydown', this._onMarkKeydown, true); |
|
||||||
} |
|
||||||
if (this.hasPopoverTarget) { |
|
||||||
this.popoverTarget.addEventListener('pointerenter', this._onPopoverPointerEnter); |
|
||||||
this.popoverTarget.addEventListener('pointerleave', this._onPopoverPointerLeave); |
|
||||||
} |
|
||||||
window.addEventListener('scroll', this._onScrollReposition, true); |
|
||||||
window.addEventListener('resize', this._onScrollReposition); |
|
||||||
this._scrollToHash(); |
|
||||||
window.addEventListener('hashchange', this._onHash); |
|
||||||
} |
|
||||||
|
|
||||||
disconnect() { |
|
||||||
window.removeEventListener('hashchange', this._onHash); |
|
||||||
window.removeEventListener('scroll', this._onScrollReposition, true); |
|
||||||
window.removeEventListener('resize', this._onScrollReposition); |
|
||||||
if (this.hasArticleTarget) { |
|
||||||
this.articleTarget.removeEventListener('pointerenter', this._onMarkPointerEnter, true); |
|
||||||
this.articleTarget.removeEventListener('pointerleave', this._onMarkPointerLeave, true); |
|
||||||
this.articleTarget.removeEventListener('click', this._onMarkClick, true); |
|
||||||
this.articleTarget.removeEventListener('focusin', this._onMarkFocus, true); |
|
||||||
this.articleTarget.removeEventListener('keydown', this._onMarkKeydown, true); |
|
||||||
} |
|
||||||
if (this.hasPopoverTarget) { |
|
||||||
this.popoverTarget.removeEventListener('pointerenter', this._onPopoverPointerEnter); |
|
||||||
this.popoverTarget.removeEventListener('pointerleave', this._onPopoverPointerLeave); |
|
||||||
} |
|
||||||
this._clearHoverLeaveTimer(); |
|
||||||
} |
|
||||||
|
|
||||||
closeOnOutside(event) { |
|
||||||
if (!this.hasPopoverTarget) { |
|
||||||
return; |
|
||||||
} |
|
||||||
if (!this._pinnedId && !this._openId) { |
|
||||||
return; |
|
||||||
} |
|
||||||
const t = event.target; |
|
||||||
if (this.popoverTarget.contains(t)) { |
|
||||||
return; |
|
||||||
} |
|
||||||
if (t && t.closest && t.closest('mark.article-body-highlight')) { |
|
||||||
return; |
|
||||||
} |
|
||||||
if (this._pinnedId) { |
|
||||||
this._closePinned(); |
|
||||||
} else { |
|
||||||
this._hideUnpinnedPopover(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
onKeydown(event) { |
|
||||||
if (event.key === 'Escape') { |
|
||||||
this._closePinned(); |
|
||||||
this._hideUnpinnedPopover(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
closePopover() { |
|
||||||
this._closePinned(); |
|
||||||
this._hideUnpinnedPopover(); |
|
||||||
} |
|
||||||
|
|
||||||
_onMarkLeave(mark) { |
|
||||||
this._clearHoverLeaveTimer(); |
|
||||||
if (this._pinnedId) { |
|
||||||
return; |
|
||||||
} |
|
||||||
this._hoverLeaveTimer = setTimeout(() => { |
|
||||||
this._hoverLeaveTimer = null; |
|
||||||
if (this._openId === (mark.getAttribute('data-event-id') || '').toLowerCase()) { |
|
||||||
this._hideUnpinnedPopover(); |
|
||||||
} |
|
||||||
}, 200); |
|
||||||
} |
|
||||||
|
|
||||||
_clearHoverLeaveTimer() { |
|
||||||
if (this._hoverLeaveTimer) { |
|
||||||
clearTimeout(this._hoverLeaveTimer); |
|
||||||
this._hoverLeaveTimer = null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
_getMarkByEventId(id) { |
|
||||||
if (!id) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
return ( |
|
||||||
this.element.querySelector(`#highlight-${id}`) || |
|
||||||
this.element.querySelector(`mark.article-body-highlight[data-event-id="${CSS.escape(id)}"]`) |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
_openForMark(mark, pin) { |
|
||||||
if (!this.hasPopoverTarget || !this.hasPopoverInnerTarget) { |
|
||||||
return; |
|
||||||
} |
|
||||||
const id = (mark.getAttribute('data-event-id') || '').toLowerCase(); |
|
||||||
if (!id || 64 !== id.length) { |
|
||||||
return; |
|
||||||
} |
|
||||||
const meta = this._metaById[id]; |
|
||||||
if (!meta) { |
|
||||||
return; |
|
||||||
} |
|
||||||
this._openId = id; |
|
||||||
if (pin) { |
|
||||||
this._pinnedId = id; |
|
||||||
} else { |
|
||||||
this._clearHoverLeaveTimer(); |
|
||||||
} |
|
||||||
const head = meta.headHtml || ''; |
|
||||||
const body = (meta.bodyHtml || '').trim(); |
|
||||||
this.popoverInnerTarget.innerHTML = |
|
||||||
head + (body !== '' ? `<div class="article-body-highlight__body user-highlight__body">${body}</div>` : ''); |
|
||||||
this._placePopover(mark); |
|
||||||
this.popoverTarget.hidden = false; |
|
||||||
} |
|
||||||
|
|
||||||
_placePopover(anchor) { |
|
||||||
if (!this.hasPopoverTarget) { |
|
||||||
return; |
|
||||||
} |
|
||||||
const p = this.popoverTarget; |
|
||||||
p.style.position = 'fixed'; |
|
||||||
p.style.zIndex = '200'; |
|
||||||
p.style.left = '0'; |
|
||||||
p.style.top = '0'; |
|
||||||
const r = anchor.getBoundingClientRect(); |
|
||||||
p.hidden = false; |
|
||||||
const pr = p.getBoundingClientRect(); |
|
||||||
let left = r.left + r.width / 2 - pr.width / 2; |
|
||||||
let top = r.bottom + 8; |
|
||||||
if (left < 8) { |
|
||||||
left = 8; |
|
||||||
} |
|
||||||
if (left + pr.width > window.innerWidth - 8) { |
|
||||||
left = Math.max(8, window.innerWidth - 8 - pr.width); |
|
||||||
} |
|
||||||
if (top + pr.height > window.innerHeight - 8) { |
|
||||||
top = Math.max(8, r.top - 8 - pr.height); |
|
||||||
} |
|
||||||
p.style.left = `${Math.round(left)}px`; |
|
||||||
p.style.top = `${Math.round(top)}px`; |
|
||||||
} |
|
||||||
|
|
||||||
_hideUnpinnedPopover() { |
|
||||||
this._openId = null; |
|
||||||
this._clearHoverLeaveTimer(); |
|
||||||
if (this._pinnedId) { |
|
||||||
return; |
|
||||||
} |
|
||||||
if (this.hasPopoverTarget) { |
|
||||||
this.popoverTarget.hidden = true; |
|
||||||
} |
|
||||||
if (this.hasPopoverInnerTarget) { |
|
||||||
this.popoverInnerTarget.innerHTML = ''; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
_closePinned() { |
|
||||||
this._pinnedId = null; |
|
||||||
if (this.hasPopoverTarget) { |
|
||||||
this.popoverTarget.hidden = true; |
|
||||||
} |
|
||||||
if (this.hasPopoverInnerTarget) { |
|
||||||
this.popoverInnerTarget.innerHTML = ''; |
|
||||||
} |
|
||||||
this._openId = null; |
|
||||||
} |
|
||||||
|
|
||||||
_scrollToHash() { |
|
||||||
const raw = window.location.hash || ''; |
|
||||||
if (!raw.startsWith('#h-')) { |
|
||||||
return; |
|
||||||
} |
|
||||||
const id = raw.slice(3).toLowerCase().replace(/[^0-9a-f]/g, ''); |
|
||||||
if (id.length !== 64) { |
|
||||||
return; |
|
||||||
} |
|
||||||
const el = this.element.querySelector(`#highlight-${id}`) || this._getMarkByEventId(id); |
|
||||||
if (!el) { |
|
||||||
return; |
|
||||||
} |
|
||||||
el.classList.remove('article-body-highlight--target'); |
|
||||||
void el.offsetWidth; |
|
||||||
el.classList.add('article-body-highlight--target'); |
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' }); |
|
||||||
this._openForMark(el, false); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,8 +0,0 @@ |
|||||||
<div class="article-body-highlight__head"> |
|
||||||
<div class="article-body-highlight__who"> |
|
||||||
<twig:Molecules:UserFromNpub ident="{{ authorPubkey }}" /> |
|
||||||
</div> |
|
||||||
{% if dateLabel|default('')|trim != '' %} |
|
||||||
<span class="text-subtle article-body-highlight__when">{{ dateLabel }}</span> |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
Loading…
Reference in new issue