You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
287 lines
9.4 KiB
287 lines
9.4 KiB
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(); |
|
} |
|
this.popoverInnerTarget.innerHTML = meta.headHtml || ''; |
|
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); |
|
} |
|
}
|
|
|