@bobfrankston/rmfmail 1.0.678 → 1.0.679

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.
@@ -1729,52 +1729,120 @@ function addToUserDict(word, sp) {
1729
1729
  }
1730
1730
  sp.add(word);
1731
1731
  }
1732
- function wordAtPoint(doc, x, y) {
1733
- const anyDoc = doc;
1734
- let caretNode = null;
1735
- let caretOffset = 0;
1736
- if (typeof anyDoc.caretRangeFromPoint === "function") {
1737
- const r = anyDoc.caretRangeFromPoint(x, y);
1738
- if (!r)
1739
- return null;
1740
- caretNode = r.startContainer;
1741
- caretOffset = r.startOffset;
1742
- } else if (typeof anyDoc.caretPositionFromPoint === "function") {
1743
- const p = anyDoc.caretPositionFromPoint(x, y);
1744
- if (!p)
1745
- return null;
1746
- caretNode = p.offsetNode;
1747
- caretOffset = p.offset;
1748
- } else {
1749
- return null;
1732
+ function decorate(editor2, sp) {
1733
+ const body = editor2.getBody?.();
1734
+ const doc = editor2.getDoc?.();
1735
+ if (!body || !doc)
1736
+ return;
1737
+ const sel = doc.getSelection();
1738
+ if (sel && sel.rangeCount > 0) {
1739
+ const focus = sel.focusNode;
1740
+ let p = focus;
1741
+ while (p && p !== body) {
1742
+ if (p.nodeType === Node.ELEMENT_NODE && p.hasAttribute?.(MARKER_ATTR)) {
1743
+ return;
1744
+ }
1745
+ p = p.parentNode;
1746
+ }
1750
1747
  }
1751
- if (!caretNode || caretNode.nodeType !== Node.TEXT_NODE)
1752
- return null;
1753
- const text = caretNode.data;
1754
- const isWordChar = (ch) => /[\p{L}\p{N}'’\-]/u.test(ch);
1755
- let start = caretOffset;
1756
- let end = caretOffset;
1757
- while (start > 0 && isWordChar(text[start - 1]))
1758
- start--;
1759
- while (end < text.length && isWordChar(text[end]))
1760
- end++;
1761
- if (start === end)
1762
- return null;
1763
- let word = text.slice(start, end);
1764
- while (word.length && /^['’\-]/.test(word)) {
1765
- word = word.slice(1);
1766
- start++;
1748
+ const bookmark = editor2.selection?.getBookmark?.(2);
1749
+ try {
1750
+ editor2.undoManager?.ignore?.(() => {
1751
+ const old = body.querySelectorAll(`span[${MARKER_ATTR}]`);
1752
+ for (const m of old) {
1753
+ const parent2 = m.parentNode;
1754
+ if (!parent2)
1755
+ continue;
1756
+ while (m.firstChild)
1757
+ parent2.insertBefore(m.firstChild, m);
1758
+ parent2.removeChild(m);
1759
+ }
1760
+ body.normalize();
1761
+ const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
1762
+ acceptNode(node) {
1763
+ let p = node.parentNode;
1764
+ while (p && p !== body) {
1765
+ if (p.nodeType === Node.ELEMENT_NODE && SKIP_TAGS.has(p.tagName)) {
1766
+ return NodeFilter.FILTER_REJECT;
1767
+ }
1768
+ p = p.parentNode;
1769
+ }
1770
+ return NodeFilter.FILTER_ACCEPT;
1771
+ }
1772
+ });
1773
+ const hits = [];
1774
+ let n = walker.nextNode();
1775
+ const WORD_RE = /[\p{L}][\p{L}'’\-]*/gu;
1776
+ while (n) {
1777
+ const tn = n;
1778
+ const text = tn.data;
1779
+ let m;
1780
+ WORD_RE.lastIndex = 0;
1781
+ while ((m = WORD_RE.exec(text)) !== null) {
1782
+ const word = m[0];
1783
+ if (word.length < MIN_WORD_LEN)
1784
+ continue;
1785
+ if (sp.correct(word))
1786
+ continue;
1787
+ hits.push({ node: tn, start: m.index, end: m.index + word.length });
1788
+ }
1789
+ n = walker.nextNode();
1790
+ }
1791
+ hits.reverse();
1792
+ for (const h of hits) {
1793
+ const range = doc.createRange();
1794
+ range.setStart(h.node, h.start);
1795
+ range.setEnd(h.node, h.end);
1796
+ const span = doc.createElement("span");
1797
+ span.setAttribute(MARKER_ATTR, "1");
1798
+ try {
1799
+ range.surroundContents(span);
1800
+ } catch {
1801
+ }
1802
+ }
1803
+ });
1804
+ } finally {
1805
+ if (bookmark)
1806
+ try {
1807
+ editor2.selection?.moveToBookmark?.(bookmark);
1808
+ } catch {
1809
+ }
1767
1810
  }
1768
- while (word.length && /['’\-]$/.test(word)) {
1769
- word = word.slice(0, -1);
1770
- end--;
1811
+ }
1812
+ function installDecorationStyle(editor2) {
1813
+ const doc = editor2.getDoc?.();
1814
+ if (!doc)
1815
+ return;
1816
+ if (doc.getElementById("mailx-spell-style"))
1817
+ return;
1818
+ const style = doc.createElement("style");
1819
+ style.id = "mailx-spell-style";
1820
+ style.textContent = `
1821
+ span[${MARKER_ATTR}] {
1822
+ text-decoration: underline wavy #d33;
1823
+ text-decoration-skip-ink: none;
1824
+ text-underline-offset: 2px;
1825
+ /* No background \u2014 keeps the styling subtle, like a native
1826
+ * spell underline, not a Find-highlight. */
1827
+ background: transparent;
1828
+ }
1829
+ `;
1830
+ doc.head.appendChild(style);
1831
+ }
1832
+ function installSerializerFilter(editor2) {
1833
+ if (editor2.__mailxSpellSerializerWired)
1834
+ return;
1835
+ editor2.__mailxSpellSerializerWired = true;
1836
+ try {
1837
+ editor2.serializer.addAttributeFilter(MARKER_ATTR, (nodes) => {
1838
+ for (const node of nodes) {
1839
+ if (typeof node.unwrap === "function")
1840
+ node.unwrap();
1841
+ }
1842
+ });
1843
+ } catch (e) {
1844
+ console.warn("[spellcheck] serializer filter setup failed:", e);
1771
1845
  }
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
1846
  }
1779
1847
  function showSuggestionsMenu(parentDoc, x, y, items) {
1780
1848
  parentDoc.getElementById("mailx-spell-menu")?.remove();
@@ -1791,11 +1859,11 @@ function showSuggestionsMenu(parentDoc, x, y, items) {
1791
1859
  box-shadow: 0 4px 16px rgba(0,0,0,0.18);
1792
1860
  padding: 4px 0;
1793
1861
  font: 13px system-ui, sans-serif;
1794
- min-width: 160px;
1862
+ min-width: 180px;
1795
1863
  max-width: 320px;
1796
1864
  `;
1797
1865
  for (const it of items) {
1798
- if (it.label === "---") {
1866
+ if (it.separator) {
1799
1867
  const sep = parentDoc.createElement("div");
1800
1868
  sep.style.cssText = "border-top:1px solid var(--color-border,#ddd); margin: 4px 0;";
1801
1869
  menu.appendChild(sep);
@@ -1845,7 +1913,10 @@ function showSuggestionsMenu(parentDoc, x, y, items) {
1845
1913
  parentDoc.addEventListener("keydown", dismiss2, true);
1846
1914
  }, 0);
1847
1915
  }
1848
- function replaceRange(doc, range, replacement) {
1916
+ function replaceMarker(editor2, marker, replacement) {
1917
+ const doc = editor2.getDoc();
1918
+ const range = doc.createRange();
1919
+ range.selectNode(marker);
1849
1920
  const sel = doc.getSelection();
1850
1921
  if (!sel)
1851
1922
  return;
@@ -1865,62 +1936,92 @@ function wireSpellcheck(editor2) {
1865
1936
  if (editor2.__mailxSpellWired)
1866
1937
  return;
1867
1938
  editor2.__mailxSpellWired = true;
1868
- const iframeDoc = editor2.getDoc?.() || null;
1869
- if (!iframeDoc)
1870
- return;
1939
+ let sp = null;
1940
+ let decorateTimer = null;
1941
+ const scheduleDecorate = () => {
1942
+ if (!sp)
1943
+ return;
1944
+ if (decorateTimer)
1945
+ clearTimeout(decorateTimer);
1946
+ decorateTimer = setTimeout(() => {
1947
+ decorateTimer = null;
1948
+ if (sp)
1949
+ decorate(editor2, sp);
1950
+ }, DECORATE_DEBOUNCE_MS);
1951
+ };
1952
+ getSpell().then((loaded) => {
1953
+ sp = loaded;
1954
+ installDecorationStyle(editor2);
1955
+ installSerializerFilter(editor2);
1956
+ decorate(editor2, loaded);
1957
+ }).catch((err) => {
1958
+ console.error("[spellcheck] dict load failed:", err);
1959
+ });
1960
+ editor2.on("input nodechange setcontent paste keyup", scheduleDecorate);
1961
+ const iframeDoc = editor2.getDoc();
1871
1962
  iframeDoc.addEventListener("contextmenu", (ev) => {
1872
1963
  const e = ev;
1873
- const iframeEl = editor2.iframeElement;
1874
- if (!iframeEl)
1964
+ const target = e.target;
1965
+ if (!target)
1875
1966
  return;
1876
- const found = wordAtPoint(iframeDoc, e.clientX, e.clientY);
1877
- if (!found)
1967
+ const marker = target.closest?.(`span[${MARKER_ATTR}]`);
1968
+ if (!marker)
1878
1969
  return;
1879
- getSpell().then((sp) => {
1880
- if (sp.correct(found.word))
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);
1970
+ const word = marker.textContent || "";
1971
+ if (!word || !sp)
1912
1972
  return;
1913
- });
1914
1973
  e.preventDefault();
1915
1974
  e.stopPropagation();
1975
+ const sugs = sp.suggest(word).slice(0, 7);
1976
+ const iframeEl = editor2.iframeElement;
1977
+ const iframeRect = iframeEl ? iframeEl.getBoundingClientRect() : { left: 0, top: 0 };
1978
+ const items = [];
1979
+ if (sugs.length === 0) {
1980
+ items.push({ label: "(no suggestions)", action: () => {
1981
+ } });
1982
+ } else {
1983
+ for (const s of sugs) {
1984
+ items.push({
1985
+ label: s,
1986
+ emphasized: true,
1987
+ action: () => {
1988
+ replaceMarker(editor2, marker, s);
1989
+ scheduleDecorate();
1990
+ }
1991
+ });
1992
+ }
1993
+ }
1994
+ items.push({ label: "", action: () => {
1995
+ }, separator: true });
1996
+ items.push({
1997
+ label: `Add "${word}" to dictionary`,
1998
+ action: () => {
1999
+ if (sp)
2000
+ addToUserDict(word, sp);
2001
+ scheduleDecorate();
2002
+ }
2003
+ });
2004
+ items.push({
2005
+ label: "Ignore (this session)",
2006
+ action: () => {
2007
+ if (sp)
2008
+ sp.add(word);
2009
+ scheduleDecorate();
2010
+ }
2011
+ });
2012
+ showSuggestionsMenu(document, iframeRect.left + e.clientX, iframeRect.top + e.clientY, items);
1916
2013
  }, true);
1917
2014
  }
1918
- var import_nspell, USER_DICT_KEY, spellPromise;
2015
+ var import_nspell, USER_DICT_KEY, MARKER_ATTR, DECORATE_DEBOUNCE_MS, MIN_WORD_LEN, SKIP_TAGS, spellPromise;
1919
2016
  var init_spellcheck = __esm({
1920
2017
  "client/compose/spellcheck.js"() {
1921
2018
  "use strict";
1922
2019
  import_nspell = __toESM(require_lib(), 1);
1923
2020
  USER_DICT_KEY = "mailx-user-dict";
2021
+ MARKER_ATTR = "data-mailx-spellerror";
2022
+ DECORATE_DEBOUNCE_MS = 500;
2023
+ MIN_WORD_LEN = 3;
2024
+ SKIP_TAGS = /* @__PURE__ */ new Set(["BLOCKQUOTE", "CODE", "PRE", "A", "SCRIPT", "STYLE", "KBD", "SAMP", "VAR"]);
1924
2025
  spellPromise = null;
1925
2026
  }
1926
2027
  });