@bobfrankston/rmfmail 1.2.152 → 1.2.154

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.
@@ -134,7 +134,7 @@ export async function createTinyMceEditor(container, opts = {}) {
134
134
  // window; TinyMCE's fullscreen plugin re-positions the container in
135
135
  // ways that produce a blank body, so it's not in the plugin list.
136
136
  menu: {
137
- view: { title: "View", items: "code | visualaid visualchars visualblocks | preview | zoomIn zoomOut zoomReset" },
137
+ view: { title: "View", items: "rmfsource | visualaid visualchars visualblocks | preview | zoomIn zoomOut zoomReset" },
138
138
  },
139
139
  // Disable the "Get all features" upsell badge that TinyMCE 6+
140
140
  // injects automatically when no premium plugins are listed.
@@ -557,6 +557,17 @@ export async function createTinyMceEditor(container, opts = {}) {
557
557
  text: "Code block…",
558
558
  onAction: openCodeDialog,
559
559
  });
560
+ // View HTML source, caret-positioned (Bob 2026-07-18).
561
+ // Registered as a menu item (View menu) AND reachable from the
562
+ // compose NATIVE right-click menu via editor.showSource() —
563
+ // TinyMCE's own contextmenu is off (native menu carries
564
+ // spellcheck suggestions), so the right-click path is wired in
565
+ // mailx compose.ts, not here. openSourceDialog is module-scope.
566
+ ed.ui.registry.addMenuItem("rmfsource", {
567
+ icon: "sourcecode",
568
+ text: "HTML source…",
569
+ onAction: () => openSourceDialog(ed),
570
+ });
560
571
  ed.ui.registry.addMenuItem("zoomIn", {
561
572
  text: "Zoom in", shortcut: "Ctrl+=",
562
573
  onAction: () => bumpZoom(ZOOM_STEP),
@@ -873,7 +884,90 @@ export async function createTinyMceEditor(container, opts = {}) {
873
884
  get root() { return editor.getContainer(); },
874
885
  on(event, handler) { editor.on(event, handler); },
875
886
  off(event, handler) { editor.off(event, handler); },
887
+ showSource() { openSourceDialog(editor); },
876
888
  nativeEditor: editor,
877
889
  };
878
890
  }
891
+ /** View/edit the full HTML source with the textarea caret placed at the
892
+ * same spot as the current DOM caret (Bob 2026-07-18: "show source that
893
+ * positions me at the current position in the source"). Drops a private-use
894
+ * sentinel char at the caret, serializes, finds the sentinel's byte offset,
895
+ * strips it from both the DOM (pristine again) and the displayed source,
896
+ * then opens a textarea dialog scrolled + selected to that offset. Save
897
+ * round-trips the edited source back through setContent. */
898
+ function openSourceDialog(editor) {
899
+ const SENTINEL = ""; // private-use area — never in real mail text
900
+ let offset = -1;
901
+ try {
902
+ // Insert the sentinel at the caret without polluting undo history.
903
+ const insert = () => editor.selection.setContent(SENTINEL);
904
+ if (typeof editor.undoManager?.ignore === "function")
905
+ editor.undoManager.ignore(insert);
906
+ else
907
+ insert();
908
+ const marked = editor.getContent();
909
+ offset = marked.indexOf(SENTINEL);
910
+ // Remove the sentinel char from the DOM directly — a targeted
911
+ // nodeValue edit keeps the rest of the document (and the user's real
912
+ // caret context) untouched, unlike a full setContent.
913
+ try {
914
+ const body = editor.getBody();
915
+ const doc = editor.getDoc();
916
+ const walk = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, null);
917
+ let n;
918
+ while ((n = walk.nextNode())) {
919
+ if (n.nodeValue && n.nodeValue.indexOf(SENTINEL) >= 0) {
920
+ n.nodeValue = n.nodeValue.replace(SENTINEL, "");
921
+ break;
922
+ }
923
+ }
924
+ }
925
+ catch { /* sentinel cleanup is best-effort */ }
926
+ }
927
+ catch { /* fall through to a plain source view at offset 0 */ }
928
+ // Display the pristine source (sentinel already stripped from the DOM);
929
+ // `offset` is where the sentinel sat, i.e. the caret position.
930
+ const source = editor.getContent();
931
+ const caretAt = offset >= 0 ? Math.min(offset, source.length) : source.length;
932
+ editor.windowManager.open({
933
+ title: "HTML source",
934
+ size: "large",
935
+ body: {
936
+ type: "panel",
937
+ items: [{ type: "textarea", name: "src", label: "", maximized: true }],
938
+ },
939
+ initialData: { src: source },
940
+ buttons: [
941
+ { type: "cancel", text: "Cancel" },
942
+ { type: "submit", text: "Save", primary: true },
943
+ ],
944
+ onSubmit: (api) => {
945
+ const data = api.getData();
946
+ editor.undoManager.transact(() => editor.setContent(data.src || ""));
947
+ editor.nodeChanged();
948
+ api.close();
949
+ },
950
+ });
951
+ // Place the textarea caret at the DOM-caret offset. The dialog's textarea
952
+ // isn't exposed through the dialog API, so reach it via the DOM once the
953
+ // dialog has painted (rAF ×2 — TinyMCE focuses it after OpenWindow).
954
+ const win = (editor.getWin && editor.getWin()) || window;
955
+ const raf = win.requestAnimationFrame || ((f) => setTimeout(f, 16));
956
+ raf(() => raf(() => {
957
+ const tas = Array.from(document.querySelectorAll(".tox-dialog textarea"));
958
+ const ta = tas[tas.length - 1]; // the dialog just opened is the last
959
+ if (!ta)
960
+ return;
961
+ try {
962
+ ta.focus();
963
+ ta.setSelectionRange(caretAt, caretAt);
964
+ // Scroll the caret line into view: approximate by line count.
965
+ const before = ta.value.slice(0, caretAt);
966
+ const line = before.split("\n").length - 1;
967
+ const lineH = parseFloat(getComputedStyle(ta).lineHeight) || 18;
968
+ ta.scrollTop = Math.max(0, (line * lineH) - ta.clientHeight / 2);
969
+ }
970
+ catch { /* not the source textarea */ }
971
+ }));
972
+ }
879
973
  //# sourceMappingURL=adapter.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/rmfmail",
3
- "version": "1.2.152",
3
+ "version": "1.2.154",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -43,7 +43,7 @@
43
43
  "@bobfrankston/msger": "^0.1.400",
44
44
  "@bobfrankston/node-tcp-transport": "^0.1.10",
45
45
  "@bobfrankston/oauthsupport": "^1.0.34",
46
- "@bobfrankston/rmf-tiny": "^0.1.40",
46
+ "@bobfrankston/rmf-tiny": "^0.1.41",
47
47
  "@bobfrankston/smtp-direct": "^0.1.9",
48
48
  "@bobfrankston/tcp-transport": "^0.1.8",
49
49
  "@capacitor/android": "^8.3.0",
@@ -121,7 +121,7 @@
121
121
  "@bobfrankston/msger": "^0.1.400",
122
122
  "@bobfrankston/node-tcp-transport": "^0.1.10",
123
123
  "@bobfrankston/oauthsupport": "^1.0.34",
124
- "@bobfrankston/rmf-tiny": "^0.1.40",
124
+ "@bobfrankston/rmf-tiny": "^0.1.41",
125
125
  "@bobfrankston/smtp-direct": "^0.1.9",
126
126
  "@bobfrankston/tcp-transport": "^0.1.8",
127
127
  "@capacitor/android": "^8.3.0",