diff --git a/assets/bootstrap.js b/assets/bootstrap.js index 219beff..d564fdb 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -2,6 +2,7 @@ import { startStimulusApp } from '@symfony/stimulus-bundle'; import ArticleCommentsController from './controllers/article_comments_controller.js'; import CommentReplyController from './controllers/comment_reply_controller.js'; import CopyTextController from './controllers/copy_text_controller.js'; +import ArticleHighlightController from './controllers/article_highlight_controller.js'; const app = startStimulusApp(); @@ -21,3 +22,8 @@ try { } catch { /* already registered by the bundle */ } +try { + app.register('article-highlight', ArticleHighlightController); +} catch { + /* already registered by the bundle */ +} diff --git a/assets/controllers/article_highlight_controller.js b/assets/controllers/article_highlight_controller.js new file mode 100644 index 0000000..9062f51 --- /dev/null +++ b/assets/controllers/article_highlight_controller.js @@ -0,0 +1,288 @@ +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; + } + 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 || '') + + '