@bobfrankston/rmfmail 1.0.678 → 1.0.680
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.map +2 -2
- package/client/app.js +6 -0
- package/client/app.js.map +1 -1
- package/client/app.ts +6 -0
- package/client/compose/compose.bundle.js +210 -88
- package/client/compose/compose.bundle.js.map +3 -3
- package/client/compose/spellcheck.js +257 -150
- package/client/compose/spellcheck.js.map +1 -1
- package/client/compose/spellcheck.ts +252 -136
- package/client/lib/rmf-tiny.js +21 -0
- package/docs/accounts.md +9 -1
- package/package.json +3 -3
- package/packages/mailx-service/index.d.ts +1 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +26 -6
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +24 -4
package/client/app.ts
CHANGED
|
@@ -1071,6 +1071,12 @@ function sanitizeQuotedBody(msg: any): string {
|
|
|
1071
1071
|
// future provider/path that didn't go through that pipeline.
|
|
1072
1072
|
const isPlainText = !msg.bodyHtml;
|
|
1073
1073
|
if (isPlainText) {
|
|
1074
|
+
// Full HTML escape. Leaving `>` unescaped was tempting for source
|
|
1075
|
+
// readability but breaks HTML in edge cases — TinyMCE's normalize-
|
|
1076
|
+
// on-paste re-interprets the input, and stray `>` near sequences
|
|
1077
|
+
// like `<!--` / `-->` / `<!` in plain-text bodies can be misread
|
|
1078
|
+
// by the parser. Per Bob 2026-05-12: "not just ugly, it breaks
|
|
1079
|
+
// the HTML." Trivial source-clutter is the lesser evil.
|
|
1074
1080
|
const escaped = String(msg.bodyText || "")
|
|
1075
1081
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1076
1082
|
return `<div style="white-space:pre-wrap;font-family:inherit;margin:0">${escaped}</div>`;
|
|
@@ -596,6 +596,27 @@ async function createTinyMceEditor(container2, opts = {}) {
|
|
|
596
596
|
// everything that the schema allows.
|
|
597
597
|
paste_word_valid_elements: "@[style|class],-strong/b,-em/i,-u,-s,-sub,-sup,-strike,-p,-ol,-ul,-li,-h1,-h2,-h3,-h4,-h5,-h6,-blockquote,-table[border|cellpadding|cellspacing|width|height|class|style],-tr,-td[colspan|rowspan|width|height|class|style|valign|align|background|bgcolor],-th,-thead,-tbody,-tfoot,-pre,-br,-a[href|target|title],-img[src|alt|width|height|style|class]",
|
|
598
598
|
paste_retain_style_properties: "color background background-color font-family font-size font-weight font-style text-decoration text-align padding padding-top padding-bottom padding-left padding-right margin margin-top margin-bottom margin-left margin-right border border-top border-bottom border-left border-right",
|
|
599
|
+
// Auto-link bare URLs in pasted content. TinyMCE's `autolink`
|
|
600
|
+
// plugin only fires on TYPED space/enter; URLs that arrive via
|
|
601
|
+
// clipboard (browser address bar, terminal copy) come in as
|
|
602
|
+
// plain text and stay un-linked. paste_preprocess runs on the
|
|
603
|
+
// HTML the paste plugin produced.
|
|
604
|
+
//
|
|
605
|
+
// CRITICAL: skip auto-link when the content already contains
|
|
606
|
+
// anchors. Naive regex over the whole content would wrap
|
|
607
|
+
// `<a href="X">X</a>` in ANOTHER anchor (nested anchors are
|
|
608
|
+
// invalid HTML and browsers split them, producing visible
|
|
609
|
+
// junk). For HTML pastes that already have linked URLs (the
|
|
610
|
+
// common case from a browser address bar or another mail
|
|
611
|
+
// client), TinyMCE preserves them — no auto-link pass needed.
|
|
612
|
+
// For plain-text pastes (no anchors present), wrap any bare
|
|
613
|
+
// http(s)://… runs. Trailing sentence punctuation is excluded
|
|
614
|
+
// from the URL.
|
|
615
|
+
paste_preprocess: (_plugin, args) => {
|
|
616
|
+
if (/<a[\s>]/i.test(args.content))
|
|
617
|
+
return;
|
|
618
|
+
args.content = args.content.replace(/(^|[\s(\[])((?:https?|ftp):\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]])/gi, (_m, lead, url) => `${lead}<a href="${url}">${url}</a>`);
|
|
619
|
+
},
|
|
599
620
|
content_style: "body { font-family: system-ui, sans-serif; font-size: 14px; }",
|
|
600
621
|
init_instance_callback: (ed) => resolve(ed),
|
|
601
622
|
setup: (ed) => {
|
|
@@ -1729,52 +1750,120 @@ function addToUserDict(word, sp) {
|
|
|
1729
1750
|
}
|
|
1730
1751
|
sp.add(word);
|
|
1731
1752
|
}
|
|
1732
|
-
function
|
|
1733
|
-
const
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
caretOffset = p.offset;
|
|
1748
|
-
} else {
|
|
1749
|
-
return null;
|
|
1753
|
+
function decorate(editor2, sp) {
|
|
1754
|
+
const body = editor2.getBody?.();
|
|
1755
|
+
const doc = editor2.getDoc?.();
|
|
1756
|
+
if (!body || !doc)
|
|
1757
|
+
return;
|
|
1758
|
+
const sel = doc.getSelection();
|
|
1759
|
+
if (sel && sel.rangeCount > 0) {
|
|
1760
|
+
const focus = sel.focusNode;
|
|
1761
|
+
let p = focus;
|
|
1762
|
+
while (p && p !== body) {
|
|
1763
|
+
if (p.nodeType === Node.ELEMENT_NODE && p.hasAttribute?.(MARKER_ATTR)) {
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
p = p.parentNode;
|
|
1767
|
+
}
|
|
1750
1768
|
}
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1769
|
+
const bookmark = editor2.selection?.getBookmark?.(2);
|
|
1770
|
+
try {
|
|
1771
|
+
editor2.undoManager?.ignore?.(() => {
|
|
1772
|
+
const old = body.querySelectorAll(`span[${MARKER_ATTR}]`);
|
|
1773
|
+
for (const m of old) {
|
|
1774
|
+
const parent2 = m.parentNode;
|
|
1775
|
+
if (!parent2)
|
|
1776
|
+
continue;
|
|
1777
|
+
while (m.firstChild)
|
|
1778
|
+
parent2.insertBefore(m.firstChild, m);
|
|
1779
|
+
parent2.removeChild(m);
|
|
1780
|
+
}
|
|
1781
|
+
body.normalize();
|
|
1782
|
+
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
|
|
1783
|
+
acceptNode(node) {
|
|
1784
|
+
let p = node.parentNode;
|
|
1785
|
+
while (p && p !== body) {
|
|
1786
|
+
if (p.nodeType === Node.ELEMENT_NODE && SKIP_TAGS.has(p.tagName)) {
|
|
1787
|
+
return NodeFilter.FILTER_REJECT;
|
|
1788
|
+
}
|
|
1789
|
+
p = p.parentNode;
|
|
1790
|
+
}
|
|
1791
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
const hits = [];
|
|
1795
|
+
let n = walker.nextNode();
|
|
1796
|
+
const WORD_RE = /[\p{L}][\p{L}'’\-]*/gu;
|
|
1797
|
+
while (n) {
|
|
1798
|
+
const tn = n;
|
|
1799
|
+
const text = tn.data;
|
|
1800
|
+
let m;
|
|
1801
|
+
WORD_RE.lastIndex = 0;
|
|
1802
|
+
while ((m = WORD_RE.exec(text)) !== null) {
|
|
1803
|
+
const word = m[0];
|
|
1804
|
+
if (word.length < MIN_WORD_LEN)
|
|
1805
|
+
continue;
|
|
1806
|
+
if (sp.correct(word))
|
|
1807
|
+
continue;
|
|
1808
|
+
hits.push({ node: tn, start: m.index, end: m.index + word.length });
|
|
1809
|
+
}
|
|
1810
|
+
n = walker.nextNode();
|
|
1811
|
+
}
|
|
1812
|
+
hits.reverse();
|
|
1813
|
+
for (const h of hits) {
|
|
1814
|
+
const range = doc.createRange();
|
|
1815
|
+
range.setStart(h.node, h.start);
|
|
1816
|
+
range.setEnd(h.node, h.end);
|
|
1817
|
+
const span = doc.createElement("span");
|
|
1818
|
+
span.setAttribute(MARKER_ATTR, "1");
|
|
1819
|
+
try {
|
|
1820
|
+
range.surroundContents(span);
|
|
1821
|
+
} catch {
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
} finally {
|
|
1826
|
+
if (bookmark)
|
|
1827
|
+
try {
|
|
1828
|
+
editor2.selection?.moveToBookmark?.(bookmark);
|
|
1829
|
+
} catch {
|
|
1830
|
+
}
|
|
1767
1831
|
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1832
|
+
}
|
|
1833
|
+
function installDecorationStyle(editor2) {
|
|
1834
|
+
const doc = editor2.getDoc?.();
|
|
1835
|
+
if (!doc)
|
|
1836
|
+
return;
|
|
1837
|
+
if (doc.getElementById("mailx-spell-style"))
|
|
1838
|
+
return;
|
|
1839
|
+
const style = doc.createElement("style");
|
|
1840
|
+
style.id = "mailx-spell-style";
|
|
1841
|
+
style.textContent = `
|
|
1842
|
+
span[${MARKER_ATTR}] {
|
|
1843
|
+
text-decoration: underline wavy #d33;
|
|
1844
|
+
text-decoration-skip-ink: none;
|
|
1845
|
+
text-underline-offset: 2px;
|
|
1846
|
+
/* No background \u2014 keeps the styling subtle, like a native
|
|
1847
|
+
* spell underline, not a Find-highlight. */
|
|
1848
|
+
background: transparent;
|
|
1849
|
+
}
|
|
1850
|
+
`;
|
|
1851
|
+
doc.head.appendChild(style);
|
|
1852
|
+
}
|
|
1853
|
+
function installSerializerFilter(editor2) {
|
|
1854
|
+
if (editor2.__mailxSpellSerializerWired)
|
|
1855
|
+
return;
|
|
1856
|
+
editor2.__mailxSpellSerializerWired = true;
|
|
1857
|
+
try {
|
|
1858
|
+
editor2.serializer.addAttributeFilter(MARKER_ATTR, (nodes) => {
|
|
1859
|
+
for (const node of nodes) {
|
|
1860
|
+
if (typeof node.unwrap === "function")
|
|
1861
|
+
node.unwrap();
|
|
1862
|
+
}
|
|
1863
|
+
});
|
|
1864
|
+
} catch (e) {
|
|
1865
|
+
console.warn("[spellcheck] serializer filter setup failed:", e);
|
|
1771
1866
|
}
|
|
1772
|
-
if (!word)
|
|
1773
|
-
return null;
|
|
1774
|
-
const range = doc.createRange();
|
|
1775
|
-
range.setStart(caretNode, start);
|
|
1776
|
-
range.setEnd(caretNode, end);
|
|
1777
|
-
return { word, range };
|
|
1778
1867
|
}
|
|
1779
1868
|
function showSuggestionsMenu(parentDoc, x, y, items) {
|
|
1780
1869
|
parentDoc.getElementById("mailx-spell-menu")?.remove();
|
|
@@ -1791,11 +1880,11 @@ function showSuggestionsMenu(parentDoc, x, y, items) {
|
|
|
1791
1880
|
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
|
|
1792
1881
|
padding: 4px 0;
|
|
1793
1882
|
font: 13px system-ui, sans-serif;
|
|
1794
|
-
min-width:
|
|
1883
|
+
min-width: 180px;
|
|
1795
1884
|
max-width: 320px;
|
|
1796
1885
|
`;
|
|
1797
1886
|
for (const it of items) {
|
|
1798
|
-
if (it.
|
|
1887
|
+
if (it.separator) {
|
|
1799
1888
|
const sep = parentDoc.createElement("div");
|
|
1800
1889
|
sep.style.cssText = "border-top:1px solid var(--color-border,#ddd); margin: 4px 0;";
|
|
1801
1890
|
menu.appendChild(sep);
|
|
@@ -1845,7 +1934,10 @@ function showSuggestionsMenu(parentDoc, x, y, items) {
|
|
|
1845
1934
|
parentDoc.addEventListener("keydown", dismiss2, true);
|
|
1846
1935
|
}, 0);
|
|
1847
1936
|
}
|
|
1848
|
-
function
|
|
1937
|
+
function replaceMarker(editor2, marker, replacement) {
|
|
1938
|
+
const doc = editor2.getDoc();
|
|
1939
|
+
const range = doc.createRange();
|
|
1940
|
+
range.selectNode(marker);
|
|
1849
1941
|
const sel = doc.getSelection();
|
|
1850
1942
|
if (!sel)
|
|
1851
1943
|
return;
|
|
@@ -1865,62 +1957,92 @@ function wireSpellcheck(editor2) {
|
|
|
1865
1957
|
if (editor2.__mailxSpellWired)
|
|
1866
1958
|
return;
|
|
1867
1959
|
editor2.__mailxSpellWired = true;
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1960
|
+
let sp = null;
|
|
1961
|
+
let decorateTimer = null;
|
|
1962
|
+
const scheduleDecorate = () => {
|
|
1963
|
+
if (!sp)
|
|
1964
|
+
return;
|
|
1965
|
+
if (decorateTimer)
|
|
1966
|
+
clearTimeout(decorateTimer);
|
|
1967
|
+
decorateTimer = setTimeout(() => {
|
|
1968
|
+
decorateTimer = null;
|
|
1969
|
+
if (sp)
|
|
1970
|
+
decorate(editor2, sp);
|
|
1971
|
+
}, DECORATE_DEBOUNCE_MS);
|
|
1972
|
+
};
|
|
1973
|
+
getSpell().then((loaded) => {
|
|
1974
|
+
sp = loaded;
|
|
1975
|
+
installDecorationStyle(editor2);
|
|
1976
|
+
installSerializerFilter(editor2);
|
|
1977
|
+
decorate(editor2, loaded);
|
|
1978
|
+
}).catch((err) => {
|
|
1979
|
+
console.error("[spellcheck] dict load failed:", err);
|
|
1980
|
+
});
|
|
1981
|
+
editor2.on("input nodechange setcontent paste keyup", scheduleDecorate);
|
|
1982
|
+
const iframeDoc = editor2.getDoc();
|
|
1871
1983
|
iframeDoc.addEventListener("contextmenu", (ev) => {
|
|
1872
1984
|
const e = ev;
|
|
1873
|
-
const
|
|
1874
|
-
if (!
|
|
1985
|
+
const target = e.target;
|
|
1986
|
+
if (!target)
|
|
1875
1987
|
return;
|
|
1876
|
-
const
|
|
1877
|
-
if (!
|
|
1988
|
+
const marker = target.closest?.(`span[${MARKER_ATTR}]`);
|
|
1989
|
+
if (!marker)
|
|
1878
1990
|
return;
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
return;
|
|
1882
|
-
const sugs = sp.suggest(found.word).slice(0, 7);
|
|
1883
|
-
const iframeRect = iframeEl.getBoundingClientRect();
|
|
1884
|
-
const menuX = iframeRect.left + e.clientX;
|
|
1885
|
-
const menuY = iframeRect.top + e.clientY;
|
|
1886
|
-
const items = [];
|
|
1887
|
-
if (sugs.length === 0) {
|
|
1888
|
-
items.push({ label: "(no suggestions)", action: () => {
|
|
1889
|
-
} });
|
|
1890
|
-
} else {
|
|
1891
|
-
for (const s of sugs) {
|
|
1892
|
-
items.push({
|
|
1893
|
-
label: s,
|
|
1894
|
-
emphasized: true,
|
|
1895
|
-
action: () => replaceRange(iframeDoc, found.range, s)
|
|
1896
|
-
});
|
|
1897
|
-
}
|
|
1898
|
-
}
|
|
1899
|
-
items.push({ label: "---", action: () => {
|
|
1900
|
-
} });
|
|
1901
|
-
items.push({
|
|
1902
|
-
label: `Add "${found.word}" to dictionary`,
|
|
1903
|
-
action: () => addToUserDict(found.word, sp)
|
|
1904
|
-
});
|
|
1905
|
-
items.push({
|
|
1906
|
-
label: "Ignore (this session)",
|
|
1907
|
-
action: () => sp.add(found.word)
|
|
1908
|
-
});
|
|
1909
|
-
showSuggestionsMenu(document, menuX, menuY, items);
|
|
1910
|
-
}).catch((err) => {
|
|
1911
|
-
console.error("[spellcheck] dict load failed:", err);
|
|
1991
|
+
const word = marker.textContent || "";
|
|
1992
|
+
if (!word || !sp)
|
|
1912
1993
|
return;
|
|
1913
|
-
});
|
|
1914
1994
|
e.preventDefault();
|
|
1915
1995
|
e.stopPropagation();
|
|
1996
|
+
const sugs = sp.suggest(word).slice(0, 7);
|
|
1997
|
+
const iframeEl = editor2.iframeElement;
|
|
1998
|
+
const iframeRect = iframeEl ? iframeEl.getBoundingClientRect() : { left: 0, top: 0 };
|
|
1999
|
+
const items = [];
|
|
2000
|
+
if (sugs.length === 0) {
|
|
2001
|
+
items.push({ label: "(no suggestions)", action: () => {
|
|
2002
|
+
} });
|
|
2003
|
+
} else {
|
|
2004
|
+
for (const s of sugs) {
|
|
2005
|
+
items.push({
|
|
2006
|
+
label: s,
|
|
2007
|
+
emphasized: true,
|
|
2008
|
+
action: () => {
|
|
2009
|
+
replaceMarker(editor2, marker, s);
|
|
2010
|
+
scheduleDecorate();
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
items.push({ label: "", action: () => {
|
|
2016
|
+
}, separator: true });
|
|
2017
|
+
items.push({
|
|
2018
|
+
label: `Add "${word}" to dictionary`,
|
|
2019
|
+
action: () => {
|
|
2020
|
+
if (sp)
|
|
2021
|
+
addToUserDict(word, sp);
|
|
2022
|
+
scheduleDecorate();
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
items.push({
|
|
2026
|
+
label: "Ignore (this session)",
|
|
2027
|
+
action: () => {
|
|
2028
|
+
if (sp)
|
|
2029
|
+
sp.add(word);
|
|
2030
|
+
scheduleDecorate();
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
showSuggestionsMenu(document, iframeRect.left + e.clientX, iframeRect.top + e.clientY, items);
|
|
1916
2034
|
}, true);
|
|
1917
2035
|
}
|
|
1918
|
-
var import_nspell, USER_DICT_KEY, spellPromise;
|
|
2036
|
+
var import_nspell, USER_DICT_KEY, MARKER_ATTR, DECORATE_DEBOUNCE_MS, MIN_WORD_LEN, SKIP_TAGS, spellPromise;
|
|
1919
2037
|
var init_spellcheck = __esm({
|
|
1920
2038
|
"client/compose/spellcheck.js"() {
|
|
1921
2039
|
"use strict";
|
|
1922
2040
|
import_nspell = __toESM(require_lib(), 1);
|
|
1923
2041
|
USER_DICT_KEY = "mailx-user-dict";
|
|
2042
|
+
MARKER_ATTR = "data-mailx-spellerror";
|
|
2043
|
+
DECORATE_DEBOUNCE_MS = 500;
|
|
2044
|
+
MIN_WORD_LEN = 3;
|
|
2045
|
+
SKIP_TAGS = /* @__PURE__ */ new Set(["BLOCKQUOTE", "CODE", "PRE", "A", "SCRIPT", "STYLE", "KBD", "SAMP", "VAR"]);
|
|
1924
2046
|
spellPromise = null;
|
|
1925
2047
|
}
|
|
1926
2048
|
});
|