Browse Source

adjust highlights

imwald
Silberengel 2 days ago
parent
commit
d16da938fa
  1. 50
      assets/styles/layout.css
  2. 94
      src/Service/ArticleBodyHighlightInjector.php
  3. 204
      src/Util/HighlightEventTags.php
  4. 14
      templates/components/Organisms/HomeHighlightsAside.html.twig

50
assets/styles/layout.css

@ -647,6 +647,51 @@ aside { @@ -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 { @@ -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;

94
src/Service/ArticleBodyHighlightInjector.php

@ -217,97 +217,13 @@ final class ArticleBodyHighlightInjector @@ -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<int> 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 @@ -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 @@ -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;
}

204
src/Util/HighlightEventTags.php

@ -87,6 +87,192 @@ final class HighlightEventTags @@ -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<int> 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<string>
*/
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 @@ -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 @@ -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).'<p class="user-highlight__marker-orphan">'.self::markHtml($hi).'</p>';
}

14
templates/components/Organisms/HomeHighlightsAside.html.twig

@ -17,6 +17,20 @@ @@ -17,6 +17,20 @@
>
<span class="visually-hidden">{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') }) }}</span>
</a>
{% if h.authorPubkey|default('')|length == 64 %}
<div class="home-aside-highlights__byline">
<span class="home-aside-highlights__who">
<twig:Molecules:UserFromNpub ident="{{ h.authorPubkey }}" />
</span>
{% if h.eventCreatedAt|default(0) > 0 %}
<time class="home-aside-highlights__time" datetime="{{ h.eventCreatedAt|date('c', 'UTC') }}">{{ h.eventCreatedAt|date('M j, Y', 'UTC') }}</time>
{% endif %}
</div>
{% elseif h.eventCreatedAt|default(0) > 0 %}
<div class="home-aside-highlights__byline">
<time class="home-aside-highlights__time" datetime="{{ h.eventCreatedAt|date('c', 'UTC') }}">{{ h.eventCreatedAt|date('M j, Y', 'UTC') }}</time>
</div>
{% endif %}
{% set _html = h.bodyHtml|default('')|trim %}
{% if _html != '' %}
<div class="home-aside-highlights__quote home-aside-highlights__quote--html user-highlight__body">{{ _html|raw }}</div>

Loading…
Cancel
Save