From 0bc43f74887946f307a1146e23f9d2790b182f7f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 25 Apr 2026 22:59:50 +0200 Subject: [PATCH] highlight rendering --- assets/bootstrap.js | 6 + .../article_highlight_controller.js | 288 ++++++++++++++++ assets/styles/app.css | 19 ++ assets/styles/article.css | 119 ++++++- assets/styles/layout.css | 156 ++++++++- config/unfold.yaml | 3 +- migrations/Version20260425200000.php | 31 ++ src/Command/PrewarmCommand.php | 153 ++++++--- src/Controller/ArticleController.php | 82 ++++- src/Controller/DefaultController.php | 3 + src/Entity/ArticleHighlight.php | 156 +++++++++ src/Repository/ArticleHighlightRepository.php | 68 ++++ src/Service/ArticleBodyHighlightInjector.php | 317 ++++++++++++++++++ src/Service/ArticleCommentThreadLoader.php | 3 +- src/Service/HighlightSyncService.php | 104 ++++++ src/Service/NostrClient.php | 116 +++++-- src/Util/HighlightEventTags.php | 137 ++++++++ .../ArticleHighlightMetaHead.html.twig | 8 + .../components/Organisms/Comments.html.twig | 1 - .../Organisms/HomeHighlightsAside.html.twig | 38 +++ templates/home.html.twig | 3 +- templates/pages/article.html.twig | 24 +- translations/messages.en.yaml | 7 + 23 files changed, 1736 insertions(+), 106 deletions(-) create mode 100644 assets/controllers/article_highlight_controller.js create mode 100644 migrations/Version20260425200000.php create mode 100644 src/Entity/ArticleHighlight.php create mode 100644 src/Repository/ArticleHighlightRepository.php create mode 100644 src/Service/ArticleBodyHighlightInjector.php create mode 100644 src/Service/HighlightSyncService.php create mode 100644 src/Util/HighlightEventTags.php create mode 100644 templates/components/Molecules/ArticleHighlightMetaHead.html.twig create mode 100644 templates/components/Organisms/HomeHighlightsAside.html.twig 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 || '') + + '
' + + (meta.bodyHtml || '') + + '
'; + 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); + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index 9df1761..c4ad10c 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -197,6 +197,17 @@ svg.icon { box-shadow: 0 1px 0 color-mix(in srgb, var(--color-text) 6%, transparent); border-top: 3px solid hsl(var(--tile-hue) 38% 40%); overflow: hidden; + transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease; +} + +/* Home masonry tiles: global `a:hover { text-decoration: underline }` would apply; cancel + lift card on hover / focus. */ +.featured-tile:has(.featured-tile__link:hover), +.featured-tile:has(.featured-tile__link:focus-visible) { + box-shadow: + 0 1px 0 color-mix(in srgb, var(--color-text) 6%, transparent), + 0 10px 28px color-mix(in srgb, var(--color-text) 8%, transparent); + border-color: color-mix(in srgb, var(--color-text-mid) 14%, var(--color-border) 86%); + transform: translateY(-2px); } .featured-tile__link { @@ -205,6 +216,14 @@ svg.icon { text-decoration: none; } +.featured-tile__link:hover, +.featured-tile__link:hover .card-title, +.featured-tile__link:hover .lede, +.featured-tile__link:hover h2, +.featured-tile__link:hover p { + text-decoration: none; +} + .featured-tile__link:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; diff --git a/assets/styles/article.css b/assets/styles/article.css index 4f83883..6396a0a 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -164,16 +164,7 @@ .comments-quotes__title { font-size: 1.25rem; - margin: 0 0 0.35rem; -} - -.comments-quotes__lede { - font-size: 0.95rem; - margin: 0 0 1.25rem; -} - -.comments-quotes__lede code { - font-size: 0.9em; + margin: 0 0 0.9rem; } .comments-quotes__sep { @@ -430,3 +421,111 @@ border-color: #a12b2b; background: #fdecec; } + +/* NIP-84: kind-9802 marks in .article-main + client popover (no separate thread list) */ +.article-body-highlight { + cursor: pointer; + scroll-margin-top: 6rem; +} + +.article-body-highlight--target { + box-shadow: inset 0 -2px 0 0 var(--color-secondary); + background: color-mix(in srgb, var(--color-secondary) 10%, transparent); + transition: background 0.35s ease, box-shadow 0.35s ease; +} + +.article-body-highlight:focus-visible { + outline: 2px solid var(--color-focus-ring, var(--color-primary)); + outline-offset: 2px; +} + +.article-body-highlight__popover[hidden] { + display: none; +} + +.article-body-highlight__popover { + position: fixed; + z-index: 200; + max-width: min(24rem, calc(100vw - 1.5rem)); + margin: 0; + padding: 0.65rem 2rem 0.85rem 0.85rem; + border: 1px solid color-mix(in srgb, var(--color-border) 80%, var(--color-bg) 20%); + border-radius: 0.45rem; + color: var(--color-text); + background: var(--color-bg); + box-shadow: 0 0.4rem 1.25rem rgba(0, 0, 0, 0.12); + pointer-events: auto; +} + +.article-body-highlight__close { + position: absolute; + top: 0.2rem; + right: 0.35rem; + margin: 0; + border: 0; + padding: 0.15rem 0.4rem; + line-height: 1; + font-size: 1.35rem; + color: var(--color-text-mid); + background: transparent; + border-radius: 0.2rem; + cursor: pointer; +} + +.article-body-highlight__close:hover, +.article-body-highlight__close:focus-visible { + color: var(--color-text); + outline: 2px solid var(--color-focus-ring, var(--color-primary)); +} + +.article-body-highlight__inner { + position: relative; +} + +.article-body-highlight__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.4rem 0.75rem; + margin: 0 0 0.4rem; + font-size: 0.86rem; +} + +.user-highlight__body--popover { + margin-top: 0.3rem; + max-height: 40vh; + overflow: auto; +} + +/* Full `context` quote + optional on the `content` substring (highlighter, not a box) */ +.user-highlight__body { + margin: 0.35rem 0 0; + font-size: 0.95rem; + line-height: 1.65; + color: var(--color-text); + font-family: var(--main-body-font), serif; +} + +/* In-flow highlighter marker (NIP-84: `content` inside `context` quote) */ +.article-main mark.user-highlight__marker, +.user-highlight__body mark.user-highlight__marker, +mark.user-highlight__marker { + margin: 0; + padding: 0.08em 0.1em 0.12em; + border-radius: 0.12em; + font: inherit; + line-height: inherit; + color: inherit; + background: color-mix(in srgb, #7ad67a 38%, #f0e8a0 62%); + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +/* When `content` is not a substring of `context` (rare) */ +.user-highlight__marker-orphan { + margin: 0.5rem 0 0; + font-size: 0.9rem; + line-height: 1.5; + color: var(--color-text-mid); +} diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 0423699..2416c7c 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -179,33 +179,29 @@ margin: 0; } - /* Pill badges: align with article `a.tag` (app.css) but scoped to the nav */ + /* Pill badges: borderless, low-contrast chips (softer than article `a.tag`) */ .layout > nav a.topic-badge.sidebar-top-topics__link, .layout > nav a.topic-badge { display: inline-block; max-width: 100%; - background-color: var(--color-bg-light); - color: var(--color-text-mid); - padding: 0.22rem 0.55rem; + background-color: color-mix(in srgb, var(--color-text-mid) 7%, var(--color-bg)); + color: color-mix(in srgb, var(--color-text-mid) 78%, var(--color-bg) 22%); + padding: 0.2rem 0.5rem; border-radius: 999px; - font-size: 0.72rem; + font-size: 0.7rem; line-height: 1.35; - font-weight: 500; + font-weight: 400; text-decoration: none; - border: 1px solid var(--color-border); + border: none; box-sizing: border-box; word-break: break-word; - transition: - background-color 0.2s ease, - color 0.2s ease, - border-color 0.2s ease; + transition: background-color 0.2s ease, color 0.2s ease; } .layout > nav a.topic-badge:hover, .layout > nav a.topic-badge:focus-visible { - background-color: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-light)); - color: var(--color-primary); - border-color: color-mix(in srgb, var(--color-primary) 22%, var(--color-border)); + background-color: color-mix(in srgb, var(--color-text-mid) 12%, var(--color-bg)); + color: color-mix(in srgb, var(--color-primary) 42%, var(--color-text-mid)); text-decoration: none; } } @@ -494,6 +490,18 @@ main { margin-top: 152px; } + /* Right column: same clearance as
so the highlights pane is not under #site-header. */ + .layout > aside { + margin-top: 152px; + /* Default: do not stretch — avoids a full-height empty column on pages with blank