From d16da938fabaebb3c52cfd9a576eb0d99cd96436 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 26 Apr 2026 09:56:39 +0200 Subject: [PATCH] adjust highlights --- assets/styles/layout.css | 50 +++++ src/Service/ArticleBodyHighlightInjector.php | 94 +------- src/Util/HighlightEventTags.php | 204 +++++++++++++++++- .../Organisms/HomeHighlightsAside.html.twig | 14 ++ 4 files changed, 264 insertions(+), 98 deletions(-) diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 969531f..ec07c22 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -647,6 +647,51 @@ aside { text-decoration: none; } +/* Highlight author (small badge link) + date above quote; badge is clickable, rest of row opens article. */ +.home-aside-highlights__byline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem 0.5rem; + margin: 0 0 0.35rem; + position: relative; + z-index: 3; + font-family: var(--font-family), system-ui, sans-serif; + font-size: 0.68rem; + line-height: 1.2; + pointer-events: none; +} + +.home-aside-highlights__who { + display: inline-flex; + max-width: 100%; + pointer-events: auto; +} + +.home-aside-highlights__byline .user-badge { + gap: 0.28rem; +} + +.home-aside-highlights__byline .user-badge__avatar { + width: 1.125rem; + height: 1.125rem; +} + +.home-aside-highlights__byline .user-badge__name { + font-size: 0.68rem; + max-width: 7.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-aside-highlights__time { + font-size: 0.65rem; + color: color-mix(in srgb, var(--color-text-mid) 88%, var(--color-bg) 12%); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + /* Let clicks go to the overlay; quote/meta stay visible above background only visually (no pointer on text). */ .home-aside-highlights__item-inner .home-aside-highlights__quote, .home-aside-highlights__item-inner .home-aside-highlights__meta { @@ -692,6 +737,11 @@ aside { color: color-mix(in srgb, var(--color-primary) 45%, var(--color-text-mid) 55%); } +.home-aside-highlights__item-inner:hover .home-aside-highlights__time, +.home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) .home-aside-highlights__time { + color: color-mix(in srgb, var(--color-primary) 32%, var(--color-text-mid) 68%); +} + table { width: 100%; margin: 20px 0; diff --git a/src/Service/ArticleBodyHighlightInjector.php b/src/Service/ArticleBodyHighlightInjector.php index a6f1c62..8420fe6 100644 --- a/src/Service/ArticleBodyHighlightInjector.php +++ b/src/Service/ArticleBodyHighlightInjector.php @@ -217,97 +217,13 @@ final class ArticleBodyHighlightInjector "\xC2\xA0" => ' ', // nbsp "\xE2\x80\x99" => "'", "\xE2\x80\x98" => "'", - "\xE2\x80\x9C" => '"', - "\xE2\x80\x9D" => '"', + "\xE2\x80\x9C" => "\x22", + "\xE2\x80\x9D" => "\x22", "\xE2\x80\x93" => '-', "\xE2\x80\x94" => '-', ]); } - /** - * Strips/rewrites typographic/linebreak chars so Nostr `content` (e.g. e‑book soft hyphens) can - * match the article’s flat HTML text, then map positions back to the real string for {@see wrapTextSlice}. - */ - private function stringForSearch(string $s): string - { - $L = \mb_strlen($s, 'UTF-8'); - $out = ''; - for ($i = 0; $i < $L; ++$i) { - $ch = \mb_substr($s, $i, 1, 'UTF-8'); - $out .= $this->searchCharacterNormalized($ch); - } - - return $out; - } - - private function searchCharacterNormalized(string $ch): string - { - if ($ch === "\xC2\xAD") { // U+00AD soft hyphen - return ''; - } - if ($ch === "\xE2\x80\x8B" // U+200B - || $ch === "\xE2\x80\x8C" // U+200C - || $ch === "\xE2\x80\x8D" // U+200D - || $ch === "\xEF\xBB\xBF" // U+FEFF - ) { - return ''; - } - if ($ch === "\xC2\xA0" // U+00A0 - || $ch === "\xE2\x80\xAF" // U+202F narrow no-break - ) { - return ' '; - } - - return $ch; - } - - /** - * @return list length L+1; cuml[i] = "search" string length of prefix s[0..i) after per-char normalization - */ - private function buildCumulativeSearchLens(string $s): array - { - $L = \mb_strlen($s, 'UTF-8'); - $cuml = [0]; - for ($i = 0; $i < $L; ++$i) { - $ch = \mb_substr($s, $i, 1, 'UTF-8'); - $add = $this->searchCharacterNormalized($ch); - $cuml[] = $cuml[$i] + \mb_strlen($add, 'UTF-8'); - } - - return $cuml; - } - - /** - * @return array{0: int, 1: int} half-open [start, end) in mb char indices of $orig - */ - private function mapSearchStringRangeToOrigStringRange(string $orig, int $nStart, int $nEnd): array - { - $L = \mb_strlen($orig, 'UTF-8'); - $cuml = $this->buildCumulativeSearchLens($orig); - if (0 > $nStart || $nStart > $cuml[$L] || $nEnd < $nStart || $nEnd > $cuml[$L]) { - return [0, 0]; - } - $startO = -1; - for ($i = 0; $i < $L; ++$i) { - if ($cuml[$i + 1] > $nStart) { - $startO = $i; - break; - } - } - if ($startO < 0) { - return [0, 0]; - } - $endO = $L; - for ($e = 0; $e <= $L; ++$e) { - if ($cuml[$e] >= $nEnd) { - $endO = $e; - break; - } - } - - return [$startO, $endO]; - } - private function tryWrapInDocument(DOMElement $root, string $needle, string $eventId): bool { $textNodes = $this->collectTextNodes($root); @@ -333,8 +249,8 @@ final class ArticleBodyHighlightInjector $pEnd = $p + \mb_strlen($needle, 'UTF-8'); } else { // e.g. soft hyphens (U+00AD) or NBSP in the event `content` vs plain text in the article - $catS = $this->stringForSearch($cat); - $needleS = $this->stringForSearch($needle); + $catS = HighlightEventTags::stringForSearch($cat); + $needleS = HighlightEventTags::stringForSearch($needle); if ($needleS === '') { return false; } @@ -343,7 +259,7 @@ final class ArticleBodyHighlightInjector return false; } $nEnd = $pN + \mb_strlen($needleS, 'UTF-8'); - [$p, $pEnd] = $this->mapSearchStringRangeToOrigStringRange($cat, $pN, $nEnd); + [$p, $pEnd] = HighlightEventTags::mapSearchStringRangeToOrigStringRange($cat, $pN, $nEnd); if ($pEnd <= $p) { return false; } diff --git a/src/Util/HighlightEventTags.php b/src/Util/HighlightEventTags.php index 32541ce..71daaf7 100644 --- a/src/Util/HighlightEventTags.php +++ b/src/Util/HighlightEventTags.php @@ -87,6 +87,192 @@ final class HighlightEventTags return \mb_substr($joined, 0, 8000); } + /** + * Same character normalization as {@see \App\Service\ArticleBodyHighlightInjector} so + * `content` can match the `context` tag when Unicode (NBSP, soft hyphen, etc.) differs — NIP-84 + * requires `content` to be a substring of the passage, but clients often diverge on code points. + */ + public static function stringForSearch(string $s): string + { + $L = \mb_strlen($s, 'UTF-8'); + $out = ''; + for ($i = 0; $i < $L; ++$i) { + $ch = \mb_substr($s, $i, 1, 'UTF-8'); + $out .= self::searchCharacterNormalized($ch); + } + + return $out; + } + + /** + * @return list length L+1; cuml[i] = "search" string length of prefix s[0..i) after per-char normalization + */ + public static function buildCumulativeSearchLens(string $s): array + { + $L = \mb_strlen($s, 'UTF-8'); + $cuml = [0]; + for ($i = 0; $i < $L; ++$i) { + $ch = \mb_substr($s, $i, 1, 'UTF-8'); + $add = self::searchCharacterNormalized($ch); + $cuml[] = $cuml[$i] + \mb_strlen($add, 'UTF-8'); + } + + return $cuml; + } + + /** + * @return array{0: int, 1: int} half-open [start, end) in mb char indices of $orig + */ + public static function mapSearchStringRangeToOrigStringRange(string $orig, int $nStart, int $nEnd): array + { + $L = \mb_strlen($orig, 'UTF-8'); + $cuml = self::buildCumulativeSearchLens($orig); + if (0 > $nStart || $nStart > $cuml[$L] || $nEnd < $nStart || $nEnd > $cuml[$L]) { + return [0, 0]; + } + $startO = -1; + for ($i = 0; $i < $L; ++$i) { + if ($cuml[$i + 1] > $nStart) { + $startO = $i; + break; + } + } + if ($startO < 0) { + return [0, 0]; + } + $endO = $L; + for ($e = 0; $e <= $L; ++$e) { + if ($cuml[$e] >= $nEnd) { + $endO = $e; + break; + } + } + + return [$startO, $endO]; + } + + /** + * Find `content` inside `context` (literal or after Unicode/Nostr normalization). Returns half-open + * mb indices into $context, or null. + * + * @return array{0: int, 1: int}|null + */ + public static function findContentSpanInContext(string $context, string $content): ?array + { + $q = self::normalizeLineEndingsForHighlight($context); + if ($q === '' || $content === '') { + return null; + } + foreach (self::highlightContentSearchVariants($content) as $needle) { + $needle = self::normalizeLineEndingsForHighlight($needle); + if ($needle === '') { + continue; + } + $p = \mb_strpos($q, $needle, 0, 'UTF-8'); + if (false !== $p) { + $len = \mb_strlen($needle, 'UTF-8'); + + return [$p, $p + $len]; + } + } + $hS = self::stringForSearch($q); + foreach (self::highlightContentSearchVariants($content) as $needle) { + $needle = self::normalizeLineEndingsForHighlight($needle); + if ($needle === '') { + continue; + } + $nS = self::stringForSearch($needle); + if ($nS === '') { + continue; + } + $pN = \mb_strpos($hS, $nS, 0, 'UTF-8'); + if (false === $pN) { + continue; + } + $nEnd = $pN + \mb_strlen($nS, 'UTF-8'); + [$a, $b] = self::mapSearchStringRangeToOrigStringRange($q, $pN, $nEnd); + if ($b > $a) { + return [$a, $b]; + } + } + + return null; + } + + /** + * @return list + */ + public static function highlightContentSearchVariants(string $content): array + { + if ($content === '') { + return []; + } + $candidates = [ + $content, + self::replaceTypographicQuotesForSearch($content), + ]; + $t = \trim($content); + if ($t !== '' && $t !== $content) { + $candidates[] = $t; + } + if (\class_exists(\Normalizer::class)) { + $c = \Normalizer::normalize($content, \Normalizer::FORM_C); + if (\is_string($c) && $c !== '' && $c !== $content) { + $candidates[] = $c; + } + } + $out = []; + $seen = []; + foreach ($candidates as $n) { + if ($n === '' || isset($seen[$n])) { + continue; + } + $seen[$n] = true; + $out[] = $n; + } + + return $out; + } + + private static function replaceTypographicQuotesForSearch(string $s): string + { + return \strtr($s, [ + "\xC2\xA0" => ' ', // nbsp + "\xE2\x80\x99" => "'", + "\xE2\x80\x98" => "'", + "\xE2\x80\x9C" => '"', + "\xE2\x80\x9D" => '"', + "\xE2\x80\x93" => '-', + "\xE2\x80\x94" => '-', + ]); + } + + private static function normalizeLineEndingsForHighlight(string $s): string + { + return \str_replace("\r\n", "\n", \str_replace("\r", "\n", $s)); + } + + private static function searchCharacterNormalized(string $ch): string + { + if ($ch === "\xC2\xAD") { // U+00AD soft hyphen + return ''; + } + if ($ch === "\xE2\x80\x8B" // U+200B + || $ch === "\xE2\x80\x8C" // U+200C + || $ch === "\xE2\x80\x8D" // U+200D + || $ch === "\xEF\xBB\xBF" // U+FEFF + ) { + return ''; + } + if ($ch === "\xC2\xA0" // U+00A0 + || $ch === "\xE2\x80\xAF" // U+202F narrow no-break + ) { + return ' '; + } + + return $ch; + } + /** * With `context`, show the full quote and mark the `content` substring. With no `context`, wrap * all of `content` in one mark. @@ -98,8 +284,8 @@ final class HighlightEventTags */ public static function buildHighlightedBodyHtml(string $contextQuote, string $contentField): string { - $q = (string) $contextQuote; - $hi = (string) $contentField; + $q = self::normalizeLineEndingsForHighlight((string) $contextQuote); + $hi = self::normalizeLineEndingsForHighlight((string) $contentField); if ($q === '' && $hi === '') { return ''; } @@ -109,17 +295,17 @@ final class HighlightEventTags if ($hi === '') { return self::escapeWithNl2br($q); } - $pos = \mb_strpos($q, $hi, 0, 'UTF-8'); - if ($pos !== false) { - $len = \mb_strlen($hi, 'UTF-8'); - $before = \mb_substr($q, 0, $pos, 'UTF-8'); - $match = \mb_substr($q, $pos, $len, 'UTF-8'); - $after = \mb_substr($q, $pos + $len, null, 'UTF-8'); + $span = self::findContentSpanInContext($q, $hi); + if (null !== $span) { + [$start, $end] = $span; + $before = \mb_substr($q, 0, $start, 'UTF-8'); + $match = \mb_substr($q, $start, $end - $start, 'UTF-8'); + $after = \mb_substr($q, $end, null, 'UTF-8'); return self::escapeWithNl2br($before).self::markHtml($match).self::escapeWithNl2br($after); } - // Substring not found: show the full context quote, then the highlight line so the note is not empty. + // Substring not found after normalization / variants: show the full context quote, then the highlight so the card is not empty. return self::escapeWithNl2br($q).'

'.self::markHtml($hi).'

'; } diff --git a/templates/components/Organisms/HomeHighlightsAside.html.twig b/templates/components/Organisms/HomeHighlightsAside.html.twig index 62fb93f..6fa1c51 100644 --- a/templates/components/Organisms/HomeHighlightsAside.html.twig +++ b/templates/components/Organisms/HomeHighlightsAside.html.twig @@ -17,6 +17,20 @@ > {{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') }) }} + {% if h.authorPubkey|default('')|length == 64 %} + + {% elseif h.eventCreatedAt|default(0) > 0 %} + + {% endif %} {% set _html = h.bodyHtml|default('')|trim %} {% if _html != '' %}
{{ _html|raw }}