@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.
- package/client/app.bundle.js +176 -3
- package/client/app.bundle.js.map +2 -2
- package/client/app.js +11 -1
- package/client/app.js.map +1 -1
- package/client/app.ts +11 -1
- package/client/components/message-viewer.js +227 -2
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +204 -1
- package/client/package.json +1 -1
- package/client/styles/components.css +5 -0
- package/package.json +3 -3
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-39528 → node_modules.npmglobalize-stash-62112}/.package-lock.json +0 -0
package/client/app.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced,
|
|
|
7
7
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, setLiveFilter, getSelectedMessages, markBodiesCached, getCurrentFocused, releaseFocus, removeMessagesAndReconcile, setRowFlagged, scrollFocusedIntoView, refreshPriorityIndex, revealMessage, getCurrentView, selectAllVisible, exitMultiSelect, getDateBasis, setDateBasis } from "./components/message-list.js";
|
|
8
8
|
import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";
|
|
9
9
|
import { initTabs, setActiveView as setActiveTabView, openTab, type ViewTab } from "./components/tabs.js";
|
|
10
|
-
import { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage, popOutToWindow, printCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu, wrapHtmlBody } from "./components/message-viewer.js";
|
|
10
|
+
import { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage, popOutToWindow, printCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu, wrapHtmlBody, setSearchHighlightTerms, parseHighlightTerms } from "./components/message-viewer.js";
|
|
11
11
|
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessage, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage, subscribeStore, cancelServerSearch, installConsoleCapture, getAttachment } from "./lib/api-client.js";
|
|
12
12
|
import * as messageState from "./lib/message-state.js";
|
|
13
13
|
import { editMenuItems } from "./components/edit-menu.js";
|
|
@@ -2652,6 +2652,12 @@ function renderSearchHighlight(settled: boolean): void {
|
|
|
2652
2652
|
|
|
2653
2653
|
function updateSearchHighlight(): void {
|
|
2654
2654
|
renderSearchHighlight(false);
|
|
2655
|
+
// The message-body marks track the same box. Doing it HERE rather than at
|
|
2656
|
+
// each caller means every path that touches the search input — typing,
|
|
2657
|
+
// clearing, folder switch, tab restore — keeps the viewer in step; there
|
|
2658
|
+
// is no way to add a fourth path that forgets. setSearchHighlightTerms
|
|
2659
|
+
// no-ops when the terms are unchanged, so this is free per keystroke.
|
|
2660
|
+
setSearchHighlightTerms(parseHighlightTerms(searchInput?.value || ""));
|
|
2655
2661
|
if (searchRegexSettleTimer) clearTimeout(searchRegexSettleTimer);
|
|
2656
2662
|
searchRegexSettleTimer = setTimeout(() => renderSearchHighlight(true), SEARCH_REGEX_SETTLE_MS);
|
|
2657
2663
|
}
|
|
@@ -2675,6 +2681,10 @@ let serverSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
2675
2681
|
|
|
2676
2682
|
function doSearch(immediate = false): void {
|
|
2677
2683
|
const query = searchInput.value.trim();
|
|
2684
|
+
// Terms to mark in the open message. Set on EVERY doSearch — including the
|
|
2685
|
+
// empty-query exit below, which clears them (Bob 2026-08-15: "search should
|
|
2686
|
+
// highlight the matched text when I view a message").
|
|
2687
|
+
setSearchHighlightTerms(parseHighlightTerms(query));
|
|
2678
2688
|
if (query.length === 0) {
|
|
2679
2689
|
// Leaving search — ABORT any in-flight server sweep. A "Server" search is
|
|
2680
2690
|
// a 90-folder IMAP run on the daemon; if it's still going when you clear
|
|
@@ -558,12 +558,208 @@ async function translateAndShow(text) {
|
|
|
558
558
|
status.textContent = `Translate error: ${err?.message || ""}`;
|
|
559
559
|
}
|
|
560
560
|
}
|
|
561
|
+
// ── Search-match highlighting ──
|
|
562
|
+
// Opening a hit from a search should show you WHERE the hit is, not hand you
|
|
563
|
+
// a 40-screen newsletter and wish you luck (Bob 2026-08-15). The terms come
|
|
564
|
+
// from the search box (app.ts calls setSearchHighlightTerms); the marks are
|
|
565
|
+
// painted with the CSS Custom Highlight API, which takes plain Ranges and
|
|
566
|
+
// styles them via ::highlight() WITHOUT touching the DOM. That matters here:
|
|
567
|
+
// the message body is sanitized, sandboxed, sometimes progressively appended,
|
|
568
|
+
// and read back for copy/quote — wrapping matches in <mark> would edit the
|
|
569
|
+
// letter itself and leak into every one of those paths.
|
|
570
|
+
const HIGHLIGHT_NAME = "mailx-find";
|
|
571
|
+
/** Cap on painted ranges. A one-letter term in a megabyte newsletter would
|
|
572
|
+
* otherwise build tens of thousands of Ranges on the click path. */
|
|
573
|
+
const HIGHLIGHT_MAX = 2000;
|
|
574
|
+
let searchHighlightTerms = [];
|
|
575
|
+
/** Pull the highlightable words out of a search query. Qualifier values that
|
|
576
|
+
* address a header (`subject:`) are kept — they're visible in the message —
|
|
577
|
+
* while `from:`/`to:`/`date:`/`has:`/`is:`/`folder:` are search plumbing, not
|
|
578
|
+
* body text. `NOT foo` is dropped: it says the word is ABSENT. Quoted phrases
|
|
579
|
+
* stay whole. Mirrors the qualifier set parsed in mailx-store's db.ts. */
|
|
580
|
+
export function parseHighlightTerms(query) {
|
|
581
|
+
const parts = (query || "").match(/"[^"]*"|\S+/g) || [];
|
|
582
|
+
const out = [];
|
|
583
|
+
let negate = false;
|
|
584
|
+
for (const raw of parts) {
|
|
585
|
+
const part = raw.replace(/^"|"$/g, "");
|
|
586
|
+
if (/^(AND|OR)$/i.test(part))
|
|
587
|
+
continue;
|
|
588
|
+
if (/^NOT$/i.test(part)) {
|
|
589
|
+
negate = true;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (negate) {
|
|
593
|
+
negate = false;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (part.startsWith("-"))
|
|
597
|
+
continue; // -term = exclude
|
|
598
|
+
const q = part.match(/^([a-z]+):(.*)$/i);
|
|
599
|
+
if (q) {
|
|
600
|
+
const [, field, value] = q;
|
|
601
|
+
if (/^subject$/i.test(field) && value)
|
|
602
|
+
out.push(value.replace(/^"|"$/g, ""));
|
|
603
|
+
continue; // other qualifiers aren't body text
|
|
604
|
+
}
|
|
605
|
+
if (part.length >= 2)
|
|
606
|
+
out.push(part);
|
|
607
|
+
}
|
|
608
|
+
// Longest first: with both "fox" and "foxes" in the query, matching the
|
|
609
|
+
// longer one first stops the shorter from claiming the same start offset
|
|
610
|
+
// and leaving a stray tail unhighlighted.
|
|
611
|
+
return Array.from(new Set(out.map(t => t.toLowerCase()))).sort((a, b) => b.length - a.length);
|
|
612
|
+
}
|
|
613
|
+
/** Collect a Range per term occurrence in a document's visible text. */
|
|
614
|
+
function collectHighlightRanges(doc, terms) {
|
|
615
|
+
const ranges = [];
|
|
616
|
+
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
|
617
|
+
acceptNode(node) {
|
|
618
|
+
const tag = (node.parentElement?.tagName || "").toUpperCase();
|
|
619
|
+
if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT")
|
|
620
|
+
return NodeFilter.FILTER_REJECT;
|
|
621
|
+
return node.nodeValue && node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
625
|
+
const hay = n.data.toLowerCase();
|
|
626
|
+
// Track claimed spans so two terms can't double-highlight one stretch.
|
|
627
|
+
const taken = [];
|
|
628
|
+
for (const term of terms) {
|
|
629
|
+
let from = 0;
|
|
630
|
+
for (;;) {
|
|
631
|
+
const at = hay.indexOf(term, from);
|
|
632
|
+
if (at < 0)
|
|
633
|
+
break;
|
|
634
|
+
const end = at + term.length;
|
|
635
|
+
from = end;
|
|
636
|
+
if (taken.some(([s, e]) => at < e && end > s))
|
|
637
|
+
continue;
|
|
638
|
+
taken.push([at, end]);
|
|
639
|
+
const r = doc.createRange();
|
|
640
|
+
r.setStart(n, at);
|
|
641
|
+
r.setEnd(n, end);
|
|
642
|
+
ranges.push(r);
|
|
643
|
+
if (ranges.length >= HIGHLIGHT_MAX)
|
|
644
|
+
return ranges;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return ranges;
|
|
649
|
+
}
|
|
650
|
+
/** Paint (or clear) the highlight inside one preview iframe. Returns the
|
|
651
|
+
* match count. No-ops on a host without the Custom Highlight API — the
|
|
652
|
+
* message still renders, it just isn't marked up. */
|
|
653
|
+
function applySearchHighlight(iframe, scrollToFirst = false) {
|
|
654
|
+
const doc = iframe.contentDocument;
|
|
655
|
+
const win = iframe.contentWindow;
|
|
656
|
+
if (!doc?.body || !win?.CSS?.highlights || typeof win.Highlight !== "function")
|
|
657
|
+
return 0;
|
|
658
|
+
try {
|
|
659
|
+
win.CSS.highlights.delete(HIGHLIGHT_NAME);
|
|
660
|
+
if (searchHighlightTerms.length === 0)
|
|
661
|
+
return 0;
|
|
662
|
+
const ranges = collectHighlightRanges(doc, searchHighlightTerms);
|
|
663
|
+
if (ranges.length === 0)
|
|
664
|
+
return 0;
|
|
665
|
+
win.CSS.highlights.set(HIGHLIGHT_NAME, new win.Highlight(...ranges));
|
|
666
|
+
// Bring the first hit into view when it's below the fold — the point
|
|
667
|
+
// of the feature is not having to hunt. Never scroll for a match that
|
|
668
|
+
// is already visible: yanking a preview the user can already read is
|
|
669
|
+
// the arrival-jump behavior we removed elsewhere.
|
|
670
|
+
if (scrollToFirst) {
|
|
671
|
+
const rect = ranges[0].getBoundingClientRect();
|
|
672
|
+
const h = doc.documentElement.clientHeight || 0;
|
|
673
|
+
if (rect.height > 0 && (rect.top < 0 || rect.bottom > h)) {
|
|
674
|
+
const target = (doc.scrollingElement || doc.documentElement).scrollTop + rect.top - h / 3;
|
|
675
|
+
(doc.scrollingElement || doc.documentElement).scrollTop = Math.max(0, target);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return ranges.length;
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
return 0;
|
|
682
|
+
} // a torn-down iframe mid-render — nothing to mark
|
|
683
|
+
}
|
|
684
|
+
/** Same paint for the viewer's own chrome (subject line lives outside the
|
|
685
|
+
* iframe, and it's the field people search most). */
|
|
686
|
+
function applyHeaderHighlight() {
|
|
687
|
+
const anyWin = window;
|
|
688
|
+
if (!anyWin.CSS?.highlights || typeof anyWin.Highlight !== "function")
|
|
689
|
+
return;
|
|
690
|
+
try {
|
|
691
|
+
anyWin.CSS.highlights.delete(HIGHLIGHT_NAME);
|
|
692
|
+
const subj = document.querySelector(".mv-subject");
|
|
693
|
+
if (!subj || searchHighlightTerms.length === 0)
|
|
694
|
+
return;
|
|
695
|
+
const ranges = collectHighlightRangesIn(subj, searchHighlightTerms);
|
|
696
|
+
if (ranges.length)
|
|
697
|
+
anyWin.CSS.highlights.set(HIGHLIGHT_NAME, new anyWin.Highlight(...ranges));
|
|
698
|
+
}
|
|
699
|
+
catch { /* header not rendered yet */ }
|
|
700
|
+
}
|
|
701
|
+
/** collectHighlightRanges scoped to one element of the PARENT document. */
|
|
702
|
+
function collectHighlightRangesIn(root, terms) {
|
|
703
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
704
|
+
const ranges = [];
|
|
705
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
706
|
+
const hay = n.data.toLowerCase();
|
|
707
|
+
for (const term of terms) {
|
|
708
|
+
let from = 0;
|
|
709
|
+
for (;;) {
|
|
710
|
+
const at = hay.indexOf(term, from);
|
|
711
|
+
if (at < 0)
|
|
712
|
+
break;
|
|
713
|
+
from = at + term.length;
|
|
714
|
+
const r = document.createRange();
|
|
715
|
+
r.setStart(n, at);
|
|
716
|
+
r.setEnd(n, from);
|
|
717
|
+
ranges.push(r);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return ranges;
|
|
722
|
+
}
|
|
723
|
+
/** Set the terms to highlight in the open message (empty = clear). Called by
|
|
724
|
+
* app.ts whenever the search box changes or a search tab is restored. */
|
|
725
|
+
export function setSearchHighlightTerms(terms) {
|
|
726
|
+
const next = terms.filter(Boolean).map(t => t.toLowerCase());
|
|
727
|
+
const same = next.length === searchHighlightTerms.length
|
|
728
|
+
&& next.every((t, i) => t === searchHighlightTerms[i]);
|
|
729
|
+
if (same)
|
|
730
|
+
return;
|
|
731
|
+
searchHighlightTerms = next;
|
|
732
|
+
applyHeaderHighlight();
|
|
733
|
+
for (const f of Array.from(document.querySelectorAll("iframe"))) {
|
|
734
|
+
applySearchHighlight(f, true);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/** Re-paint after content lands (initial render, progressive text append). */
|
|
738
|
+
export function refreshSearchHighlight(iframe, scrollToFirst = false) {
|
|
739
|
+
if (searchHighlightTerms.length === 0)
|
|
740
|
+
return;
|
|
741
|
+
applySearchHighlight(iframe, scrollToFirst);
|
|
742
|
+
applyHeaderHighlight();
|
|
743
|
+
}
|
|
561
744
|
function installPreviewControls(iframe) {
|
|
562
745
|
const attach = () => {
|
|
563
746
|
const doc = iframe.contentDocument;
|
|
564
747
|
if (!doc)
|
|
565
748
|
return;
|
|
749
|
+
// Idempotent per DOCUMENT. A freshly-appended srcdoc iframe first
|
|
750
|
+
// exposes a throwaway `about:blank` document whose readyState is
|
|
751
|
+
// already "complete" — so binding to it is binding to something that
|
|
752
|
+
// is discarded a frame later, and every path below has to run again
|
|
753
|
+
// against the real one. Marking the document (not the iframe) lets us
|
|
754
|
+
// safely attach from both the immediate and the load path.
|
|
755
|
+
if (doc.__mvControlsBound)
|
|
756
|
+
return;
|
|
757
|
+
doc.__mvControlsBound = true;
|
|
566
758
|
applyZoom(doc);
|
|
759
|
+
// Paint search marks as soon as the text exists — `attach` also runs
|
|
760
|
+
// on `load`, which on a remote-image newsletter is many seconds after
|
|
761
|
+
// the words are readable.
|
|
762
|
+
refreshSearchHighlight(iframe, true);
|
|
567
763
|
doc.addEventListener("keydown", (e) => {
|
|
568
764
|
const target = e.target;
|
|
569
765
|
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)))
|
|
@@ -629,10 +825,29 @@ function installPreviewControls(iframe) {
|
|
|
629
825
|
// host; the doc-level handler missed cases where WebView2's native
|
|
630
826
|
// menu fired before our parent listener got installed.
|
|
631
827
|
};
|
|
828
|
+
// Bind on BOTH paths, not one or the other: `load` is when the real
|
|
829
|
+
// srcdoc document exists, and the immediate call covers an iframe that
|
|
830
|
+
// is genuinely already loaded (re-render of a live preview). The
|
|
831
|
+
// per-document guard in attach() makes the overlap a no-op — where the
|
|
832
|
+
// old `complete ? attach() : onload` choice could bind the whole set to
|
|
833
|
+
// the discarded about:blank document and never run against the letter.
|
|
834
|
+
iframe.addEventListener("load", attach);
|
|
632
835
|
if (iframe.contentDocument?.readyState === "complete")
|
|
633
836
|
attach();
|
|
634
|
-
|
|
635
|
-
|
|
837
|
+
// DOMContentLoaded is the "text is painted" mark (see the _ptick pair at
|
|
838
|
+
// the render site); highlight there too so marks appear with the words
|
|
839
|
+
// rather than after the last tracking pixel resolves.
|
|
840
|
+
queueMicrotask(() => {
|
|
841
|
+
const doc = iframe.contentDocument;
|
|
842
|
+
if (!doc)
|
|
843
|
+
return;
|
|
844
|
+
if (doc.readyState === "loading") {
|
|
845
|
+
doc.addEventListener("DOMContentLoaded", () => refreshSearchHighlight(iframe, true), { once: true });
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
refreshSearchHighlight(iframe, true);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
636
851
|
}
|
|
637
852
|
export function clearViewer() {
|
|
638
853
|
currentMessage = null;
|
|
@@ -2188,6 +2403,10 @@ function appendTextProgressively(iframe, rest, gen) {
|
|
|
2188
2403
|
const span = doc.createElement("span");
|
|
2189
2404
|
span.innerHTML = linkifyText(chunks[i++]);
|
|
2190
2405
|
host.appendChild(span);
|
|
2406
|
+
// Matches in a chunk that arrives after the first paint would stay
|
|
2407
|
+
// unmarked — re-collect once the tail is in.
|
|
2408
|
+
if (i >= chunks.length)
|
|
2409
|
+
refreshSearchHighlight(iframe);
|
|
2191
2410
|
requestAnimationFrame(step);
|
|
2192
2411
|
};
|
|
2193
2412
|
// The iframe document may not exist for a frame or two after srcdoc is
|
|
@@ -2390,6 +2609,12 @@ ${csp}
|
|
|
2390
2609
|
word-break: break-word;
|
|
2391
2610
|
}
|
|
2392
2611
|
blockquote { border-left: 3px solid #ccc; padding-left: 1rem; margin-left: 0; color: #666; }
|
|
2612
|
+
/* Search matches. Painted by the parent through the CSS Custom Highlight
|
|
2613
|
+
API (CSS.highlights) — no <mark> in the message DOM, so the letter the
|
|
2614
|
+
user copies, quotes or re-renders is byte-identical to what arrived.
|
|
2615
|
+
Both colors are explicit: a highlight that inherits the body color is
|
|
2616
|
+
invisible against its own background in one theme or the other. */
|
|
2617
|
+
::highlight(mailx-find) { background: #ffd54a; color: #1a1a2e; }
|
|
2393
2618
|
@media (prefers-color-scheme: dark) {
|
|
2394
2619
|
body { color: #cdd6f4; background: #282840; }
|
|
2395
2620
|
a { color: #89b4fa; }
|