@bobfrankston/rmfmail 1.2.259 → 1.2.261

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -519,12 +519,188 @@ async function translateAndShow(text: string): Promise<void> {
519
519
  }
520
520
  }
521
521
 
522
+ // ── Search-match highlighting ──
523
+ // Opening a hit from a search should show you WHERE the hit is, not hand you
524
+ // a 40-screen newsletter and wish you luck (Bob 2026-08-15). The terms come
525
+ // from the search box (app.ts calls setSearchHighlightTerms); the marks are
526
+ // painted with the CSS Custom Highlight API, which takes plain Ranges and
527
+ // styles them via ::highlight() WITHOUT touching the DOM. That matters here:
528
+ // the message body is sanitized, sandboxed, sometimes progressively appended,
529
+ // and read back for copy/quote — wrapping matches in <mark> would edit the
530
+ // letter itself and leak into every one of those paths.
531
+ const HIGHLIGHT_NAME = "mailx-find";
532
+ /** Cap on painted ranges. A one-letter term in a megabyte newsletter would
533
+ * otherwise build tens of thousands of Ranges on the click path. */
534
+ const HIGHLIGHT_MAX = 2000;
535
+ let searchHighlightTerms: string[] = [];
536
+
537
+ /** Pull the highlightable words out of a search query. Qualifier values that
538
+ * address a header (`subject:`) are kept — they're visible in the message —
539
+ * while `from:`/`to:`/`date:`/`has:`/`is:`/`folder:` are search plumbing, not
540
+ * body text. `NOT foo` is dropped: it says the word is ABSENT. Quoted phrases
541
+ * stay whole. Mirrors the qualifier set parsed in mailx-store's db.ts. */
542
+ export function parseHighlightTerms(query: string): string[] {
543
+ const parts = (query || "").match(/"[^"]*"|\S+/g) || [];
544
+ const out: string[] = [];
545
+ let negate = false;
546
+ for (const raw of parts) {
547
+ const part = raw.replace(/^"|"$/g, "");
548
+ if (/^(AND|OR)$/i.test(part)) continue;
549
+ if (/^NOT$/i.test(part)) { negate = true; continue; }
550
+ if (negate) { negate = false; continue; }
551
+ if (part.startsWith("-")) continue; // -term = exclude
552
+ const q = part.match(/^([a-z]+):(.*)$/i);
553
+ if (q) {
554
+ const [, field, value] = q;
555
+ if (/^subject$/i.test(field) && value) out.push(value.replace(/^"|"$/g, ""));
556
+ continue; // other qualifiers aren't body text
557
+ }
558
+ if (part.length >= 2) out.push(part);
559
+ }
560
+ // Longest first: with both "fox" and "foxes" in the query, matching the
561
+ // longer one first stops the shorter from claiming the same start offset
562
+ // and leaving a stray tail unhighlighted.
563
+ return Array.from(new Set(out.map(t => t.toLowerCase()))).sort((a, b) => b.length - a.length);
564
+ }
565
+
566
+ /** Collect a Range per term occurrence in a document's visible text. */
567
+ function collectHighlightRanges(doc: Document, terms: string[]): Range[] {
568
+ const ranges: Range[] = [];
569
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
570
+ acceptNode(node: Node) {
571
+ const tag = (node.parentElement?.tagName || "").toUpperCase();
572
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") return NodeFilter.FILTER_REJECT;
573
+ return node.nodeValue && node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
574
+ },
575
+ });
576
+ for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
577
+ const hay = n.data.toLowerCase();
578
+ // Track claimed spans so two terms can't double-highlight one stretch.
579
+ const taken: Array<[number, number]> = [];
580
+ for (const term of terms) {
581
+ let from = 0;
582
+ for (;;) {
583
+ const at = hay.indexOf(term, from);
584
+ if (at < 0) break;
585
+ const end = at + term.length;
586
+ from = end;
587
+ if (taken.some(([s, e]) => at < e && end > s)) continue;
588
+ taken.push([at, end]);
589
+ const r = doc.createRange();
590
+ r.setStart(n, at);
591
+ r.setEnd(n, end);
592
+ ranges.push(r);
593
+ if (ranges.length >= HIGHLIGHT_MAX) return ranges;
594
+ }
595
+ }
596
+ }
597
+ return ranges;
598
+ }
599
+
600
+ /** Paint (or clear) the highlight inside one preview iframe. Returns the
601
+ * match count. No-ops on a host without the Custom Highlight API — the
602
+ * message still renders, it just isn't marked up. */
603
+ function applySearchHighlight(iframe: HTMLIFrameElement, scrollToFirst = false): number {
604
+ const doc = iframe.contentDocument;
605
+ const win = iframe.contentWindow as any;
606
+ if (!doc?.body || !win?.CSS?.highlights || typeof win.Highlight !== "function") return 0;
607
+ try {
608
+ win.CSS.highlights.delete(HIGHLIGHT_NAME);
609
+ if (searchHighlightTerms.length === 0) return 0;
610
+ const ranges = collectHighlightRanges(doc, searchHighlightTerms);
611
+ if (ranges.length === 0) return 0;
612
+ win.CSS.highlights.set(HIGHLIGHT_NAME, new win.Highlight(...ranges));
613
+ // Bring the first hit into view when it's below the fold — the point
614
+ // of the feature is not having to hunt. Never scroll for a match that
615
+ // is already visible: yanking a preview the user can already read is
616
+ // the arrival-jump behavior we removed elsewhere.
617
+ if (scrollToFirst) {
618
+ const rect = ranges[0].getBoundingClientRect();
619
+ const h = doc.documentElement.clientHeight || 0;
620
+ if (rect.height > 0 && (rect.top < 0 || rect.bottom > h)) {
621
+ const target = (doc.scrollingElement || doc.documentElement).scrollTop + rect.top - h / 3;
622
+ (doc.scrollingElement || doc.documentElement).scrollTop = Math.max(0, target);
623
+ }
624
+ }
625
+ return ranges.length;
626
+ } catch { return 0; } // a torn-down iframe mid-render — nothing to mark
627
+ }
628
+
629
+ /** Same paint for the viewer's own chrome (subject line lives outside the
630
+ * iframe, and it's the field people search most). */
631
+ function applyHeaderHighlight(): void {
632
+ const anyWin = window as any;
633
+ if (!anyWin.CSS?.highlights || typeof anyWin.Highlight !== "function") return;
634
+ try {
635
+ anyWin.CSS.highlights.delete(HIGHLIGHT_NAME);
636
+ const subj = document.querySelector(".mv-subject") as HTMLElement | null;
637
+ if (!subj || searchHighlightTerms.length === 0) return;
638
+ const ranges = collectHighlightRangesIn(subj, searchHighlightTerms);
639
+ if (ranges.length) anyWin.CSS.highlights.set(HIGHLIGHT_NAME, new anyWin.Highlight(...ranges));
640
+ } catch { /* header not rendered yet */ }
641
+ }
642
+
643
+ /** collectHighlightRanges scoped to one element of the PARENT document. */
644
+ function collectHighlightRangesIn(root: HTMLElement, terms: string[]): Range[] {
645
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
646
+ const ranges: Range[] = [];
647
+ for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
648
+ const hay = n.data.toLowerCase();
649
+ for (const term of terms) {
650
+ let from = 0;
651
+ for (;;) {
652
+ const at = hay.indexOf(term, from);
653
+ if (at < 0) break;
654
+ from = at + term.length;
655
+ const r = document.createRange();
656
+ r.setStart(n, at);
657
+ r.setEnd(n, from);
658
+ ranges.push(r);
659
+ }
660
+ }
661
+ }
662
+ return ranges;
663
+ }
664
+
665
+ /** Set the terms to highlight in the open message (empty = clear). Called by
666
+ * app.ts whenever the search box changes or a search tab is restored. */
667
+ export function setSearchHighlightTerms(terms: string[]): void {
668
+ const next = terms.filter(Boolean).map(t => t.toLowerCase());
669
+ const same = next.length === searchHighlightTerms.length
670
+ && next.every((t, i) => t === searchHighlightTerms[i]);
671
+ if (same) return;
672
+ searchHighlightTerms = next;
673
+ applyHeaderHighlight();
674
+ for (const f of Array.from(document.querySelectorAll("iframe"))) {
675
+ applySearchHighlight(f as HTMLIFrameElement, true);
676
+ }
677
+ }
678
+
679
+ /** Re-paint after content lands (initial render, progressive text append). */
680
+ export function refreshSearchHighlight(iframe: HTMLIFrameElement, scrollToFirst = false): void {
681
+ if (searchHighlightTerms.length === 0) return;
682
+ applySearchHighlight(iframe, scrollToFirst);
683
+ applyHeaderHighlight();
684
+ }
685
+
522
686
  function installPreviewControls(iframe: HTMLIFrameElement): void {
523
687
  const attach = () => {
524
688
  const doc = iframe.contentDocument;
525
689
  if (!doc) return;
690
+ // Idempotent per DOCUMENT. A freshly-appended srcdoc iframe first
691
+ // exposes a throwaway `about:blank` document whose readyState is
692
+ // already "complete" — so binding to it is binding to something that
693
+ // is discarded a frame later, and every path below has to run again
694
+ // against the real one. Marking the document (not the iframe) lets us
695
+ // safely attach from both the immediate and the load path.
696
+ if ((doc as any).__mvControlsBound) return;
697
+ (doc as any).__mvControlsBound = true;
526
698
 
527
699
  applyZoom(doc);
700
+ // Paint search marks as soon as the text exists — `attach` also runs
701
+ // on `load`, which on a remote-image newsletter is many seconds after
702
+ // the words are readable.
703
+ refreshSearchHighlight(iframe, true);
528
704
 
529
705
  doc.addEventListener("keydown", (e) => {
530
706
  const target = e.target as HTMLElement | null;
@@ -579,8 +755,26 @@ function installPreviewControls(iframe: HTMLIFrameElement): void {
579
755
  // host; the doc-level handler missed cases where WebView2's native
580
756
  // menu fired before our parent listener got installed.
581
757
  };
758
+ // Bind on BOTH paths, not one or the other: `load` is when the real
759
+ // srcdoc document exists, and the immediate call covers an iframe that
760
+ // is genuinely already loaded (re-render of a live preview). The
761
+ // per-document guard in attach() makes the overlap a no-op — where the
762
+ // old `complete ? attach() : onload` choice could bind the whole set to
763
+ // the discarded about:blank document and never run against the letter.
764
+ iframe.addEventListener("load", attach);
582
765
  if (iframe.contentDocument?.readyState === "complete") attach();
583
- else iframe.addEventListener("load", attach, { once: true });
766
+ // DOMContentLoaded is the "text is painted" mark (see the _ptick pair at
767
+ // the render site); highlight there too so marks appear with the words
768
+ // rather than after the last tracking pixel resolves.
769
+ queueMicrotask(() => {
770
+ const doc = iframe.contentDocument;
771
+ if (!doc) return;
772
+ if (doc.readyState === "loading") {
773
+ doc.addEventListener("DOMContentLoaded", () => refreshSearchHighlight(iframe, true), { once: true });
774
+ } else {
775
+ refreshSearchHighlight(iframe, true);
776
+ }
777
+ });
584
778
  }
585
779
 
586
780
  export function clearViewer(): void {
@@ -2034,6 +2228,9 @@ function appendTextProgressively(iframe: HTMLIFrameElement, rest: string, gen: n
2034
2228
  const span = doc.createElement("span");
2035
2229
  span.innerHTML = linkifyText(chunks[i++]);
2036
2230
  host.appendChild(span);
2231
+ // Matches in a chunk that arrives after the first paint would stay
2232
+ // unmarked — re-collect once the tail is in.
2233
+ if (i >= chunks.length) refreshSearchHighlight(iframe);
2037
2234
  requestAnimationFrame(step);
2038
2235
  };
2039
2236
  // The iframe document may not exist for a frame or two after srcdoc is
@@ -2228,6 +2425,12 @@ ${csp}
2228
2425
  word-break: break-word;
2229
2426
  }
2230
2427
  blockquote { border-left: 3px solid #ccc; padding-left: 1rem; margin-left: 0; color: #666; }
2428
+ /* Search matches. Painted by the parent through the CSS Custom Highlight
2429
+ API (CSS.highlights) — no <mark> in the message DOM, so the letter the
2430
+ user copies, quotes or re-renders is byte-identical to what arrived.
2431
+ Both colors are explicit: a highlight that inherits the body color is
2432
+ invisible against its own background in one theme or the other. */
2433
+ ::highlight(mailx-find) { background: #ffd54a; color: #1a1a2e; }
2231
2434
  @media (prefers-color-scheme: dark) {
2232
2435
  body { color: #cdd6f4; background: #282840; }
2233
2436
  a { color: #89b4fa; }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-client",
3
- "version": "1.0.34",
3
+ "version": "1.0.36",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1999,6 +1999,11 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
1999
1999
  .mv-from { font-weight: 600; }
2000
2000
  .mv-to { color: var(--color-text-muted); }
2001
2001
  .mv-subject { font-size: var(--font-size-lg); font-weight: 600; margin-top: var(--gap-xs); }
2002
+ /* Search matches in the subject. Same treatment as the body (which paints
2003
+ its own copy of this rule inside the preview iframe — the iframe can't
2004
+ see this sheet). Literal colors, not tokens: the pair has to stay
2005
+ legible in both themes on its own. */
2006
+ ::highlight(mailx-find) { background: #ffd54a; color: #1a1a2e; }
2002
2007
  .mv-date { color: var(--color-text-muted); font-size: var(--font-size-sm); }
2003
2008
  .mv-unsubscribe {
2004
2009
  font-size: var(--font-size-sm);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/rmfmail",
3
- "version": "1.2.259",
3
+ "version": "1.2.261",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -37,7 +37,7 @@
37
37
  "@bobfrankston/mailx-imap": "^0.1.153",
38
38
  "@bobfrankston/mailx-store-web": "^0.1.80",
39
39
  "@bobfrankston/mailx-sync": "^0.1.29",
40
- "@bobfrankston/miscinfo": "^1.0.17",
40
+ "@bobfrankston/miscinfo": "^1.0.19",
41
41
  "@bobfrankston/msger": "^0.1.425",
42
42
  "@bobfrankston/node-tcp-transport": "^0.1.10",
43
43
  "@bobfrankston/oauthsupport": "^1.0.34",
@@ -117,7 +117,7 @@
117
117
  "@bobfrankston/mailx-imap": "^0.1.153",
118
118
  "@bobfrankston/mailx-store-web": "^0.1.80",
119
119
  "@bobfrankston/mailx-sync": "^0.1.29",
120
- "@bobfrankston/miscinfo": "^1.0.17",
120
+ "@bobfrankston/miscinfo": "^1.0.19",
121
121
  "@bobfrankston/msger": "^0.1.425",
122
122
  "@bobfrankston/node-tcp-transport": "^0.1.10",
123
123
  "@bobfrankston/oauthsupport": "^1.0.34",