@bobfrankston/rmfmail 1.2.254 → 1.2.257

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.
@@ -173,7 +173,11 @@ export async function createTinyMceEditor(container, opts = {}) {
173
173
  // left. Replaced the generic alignleft/center/right icons, which
174
174
  // don't communicate wrapping (Bob 2026-07-18: "the align is
175
175
  // confusing, better to use the Word icons for the three cases").
176
- quickbars_image_toolbar: "rmfwrapinline rmfwrapleft rmfwrapright",
176
+ // …plus a border control (Bob 2026-08-13: "image editing should have
177
+ // an option to put a border around the image. It could be a check box
178
+ // or a width size"). Split button: click = 1px border on/off, ▾ = pick
179
+ // the width. Registered in setup below.
180
+ quickbars_image_toolbar: "rmfwrapinline rmfwrapleft rmfwrapright | rmfborder",
177
181
  // Code-sample dropdown languages. TinyMCE's default list omits
178
182
  // "Text" / plain — every option triggers syntax highlighting
179
183
  // which mangles unrelated paste content (Bob 2026-05-24).
@@ -192,6 +196,11 @@ export async function createTinyMceEditor(container, opts = {}) {
192
196
  // OS file-picker the compose Attach button uses (<input type=file>,
193
197
  // which works in the msger WebView), then inlines the chosen image as
194
198
  // a base64 data URL — matching the drag-drop-an-image behavior.
199
+ // Insert/Edit Image → Advanced tab: border width + style, and the
200
+ // vertical/horizontal spacing that goes with a framed image. This is
201
+ // the precise-control half of the border feature; the quickbar split
202
+ // button below is the one-click half.
203
+ image_advtab: true,
195
204
  file_picker_types: "image",
196
205
  file_picker_callback: (cb, _value, meta) => {
197
206
  if (meta && meta.filetype && meta.filetype !== "image")
@@ -353,6 +362,74 @@ export async function createTinyMceEditor(container, opts = {}) {
353
362
  wrapButton("rmfwrapinline", "rmf-wrap-inline", "In line with text", "inline");
354
363
  wrapButton("rmfwrapleft", "rmf-wrap-left", "Image left — text wraps around the right", "left");
355
364
  wrapButton("rmfwrapright", "rmf-wrap-right", "Image right — text wraps around the left", "right");
365
+ // ── Image border ──
366
+ // Gray rather than black: a border's job here is to separate the
367
+ // image from the text around it, and #888 stays visible whether
368
+ // the recipient reads on a white or a dark background — a black
369
+ // frame disappears into a dark-mode message body.
370
+ const BORDER_COLOR = "#888888";
371
+ const BORDER_WIDTHS = [1, 2, 3, 5];
372
+ ed.ui.registry.addIcon("rmf-border", `<svg width="24" height="24" viewBox="0 0 24 24">`
373
+ + `<rect x="3.5" y="4.5" width="17" height="15" rx="1" fill="none" stroke="currentColor" stroke-width="2"/>`
374
+ + arch(12, 16.5)
375
+ + `</svg>`);
376
+ /** Current visible border width in px — 0 when there is none.
377
+ * A width with `border-style: none` draws nothing, so it counts
378
+ * as no border. */
379
+ const borderWidthOf = (node) => {
380
+ if (!node || node.nodeName !== "IMG")
381
+ return 0;
382
+ const style = node.style?.borderStyle || "";
383
+ if (!style || style === "none")
384
+ return 0;
385
+ const w = parseFloat(node.style?.borderWidth || "");
386
+ return Number.isFinite(w) ? w : 0;
387
+ };
388
+ const applyBorder = (px) => {
389
+ const node = ed.selection.getNode();
390
+ if (!node || node.nodeName !== "IMG")
391
+ return;
392
+ ed.undoManager.transact(() => {
393
+ ed.dom.setStyles(node, px > 0
394
+ ? { border: `${px}px solid ${BORDER_COLOR}` }
395
+ : { border: "" });
396
+ });
397
+ ed.nodeChanged();
398
+ };
399
+ ed.ui.registry.addSplitButton("rmfborder", {
400
+ icon: "rmf-border",
401
+ tooltip: "Border around the image — click to toggle, ▾ to pick the width",
402
+ // Click toggles: bordered → none, unbordered → 1px hairline.
403
+ onAction: () => applyBorder(borderWidthOf(ed.selection.getNode()) > 0 ? 0 : 1),
404
+ onItemAction: (_api, value) => applyBorder(Number(value)),
405
+ select: (value) => Number(value) === borderWidthOf(ed.selection.getNode()),
406
+ fetch: (cb) => cb([
407
+ { type: "choiceitem", text: "No border", value: "0" },
408
+ ...BORDER_WIDTHS.map((w) => ({ type: "choiceitem", text: `${w} px`, value: String(w) })),
409
+ ]),
410
+ onSetup: (api) => {
411
+ const update = () => api.setActive(borderWidthOf(ed.selection.getNode()) > 0);
412
+ ed.on("NodeChange", update);
413
+ return () => ed.off("NodeChange", update);
414
+ },
415
+ });
416
+ // The image dialog's Advanced tab takes a border WIDTH and a border
417
+ // STYLE as separate fields, and CSS defaults the style to `none` —
418
+ // so typing "2" and leaving the style dropdown alone produced an
419
+ // image with an invisible border and no hint why. A width the user
420
+ // asked for means a border they can see: fill in `solid`.
421
+ ed.on("NodeChange", () => {
422
+ const n = ed.selection.getNode();
423
+ if (!n || n.nodeName !== "IMG")
424
+ return;
425
+ const w = parseFloat(n.style?.borderWidth || "");
426
+ const s = n.style?.borderStyle || "";
427
+ if (Number.isFinite(w) && w > 0 && (!s || s === "none")) {
428
+ ed.dom.setStyle(n, "border-style", "solid");
429
+ if (!n.style.borderColor)
430
+ ed.dom.setStyle(n, "border-color", BORDER_COLOR);
431
+ }
432
+ });
356
433
  // Bitmap-first paste. An image copied from a web app (Gemini,
357
434
  // browser "Copy image") puts BOTH a bitmap file item and an
358
435
  // html flavor on the clipboard — and the html flavor is a
@@ -920,7 +997,29 @@ export async function createTinyMceEditor(container, opts = {}) {
920
997
  function openSourceDialog(editor) {
921
998
  const SENTINEL = ""; // private-use area — never in real mail text
922
999
  let offset = -1;
1000
+ let bookmark = null;
923
1001
  try {
1002
+ // The sentinel must be INSERTED, never SUBSTITUTED. selection.setContent()
1003
+ // replaces whatever is selected — and right-clicking an image selects the
1004
+ // <img>, so "view source" swallowed the image (Bob 2026-08-13: "if I right
1005
+ // mouse view source on an image the image disappears"). Same loss for any
1006
+ // non-collapsed text selection, and undoManager.ignore kept it out of the
1007
+ // undo stack, so Ctrl+Z couldn't bring it back either. Collapse to the
1008
+ // range START first (that's the caret the source view should land on), then
1009
+ // restore the user's original selection once the sentinel is out again.
1010
+ const sel = editor.selection;
1011
+ // Bookmark type 2 = non-intrusive path+offset (no marker nodes). A saved
1012
+ // Range object is NOT good enough: setContent re-parses the block, so the
1013
+ // saved range's container node is detached by the time we restore and the
1014
+ // selection came back as the whole paragraph. The path is resolved at
1015
+ // restore time, against a DOM the sentinel strip has put back as it was.
1016
+ bookmark = sel.getBookmark(2, true);
1017
+ const rng = sel.getRng();
1018
+ if (rng && !rng.collapsed) {
1019
+ const collapsed = rng.cloneRange();
1020
+ collapsed.collapse(true);
1021
+ sel.setRng(collapsed);
1022
+ }
924
1023
  // Insert the sentinel at the caret without polluting undo history.
925
1024
  const insert = () => editor.selection.setContent(SENTINEL);
926
1025
  if (typeof editor.undoManager?.ignore === "function")
@@ -945,6 +1044,14 @@ function openSourceDialog(editor) {
945
1044
  }
946
1045
  }
947
1046
  catch { /* sentinel cleanup is best-effort */ }
1047
+ // Put the selection back where the user left it (the selected image
1048
+ // stays selected, so closing the dialog leaves the quickbar up).
1049
+ if (bookmark) {
1050
+ try {
1051
+ editor.selection.moveToBookmark(bookmark);
1052
+ }
1053
+ catch { /* path no longer resolves — caret stays where the sentinel was */ }
1054
+ }
948
1055
  }
949
1056
  catch { /* fall through to a plain source view at offset 0 */ }
950
1057
  // Display the pristine source (sentinel already stripped from the DOM);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-client",
3
- "version": "1.0.31",
3
+ "version": "1.0.32",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/rmfmail",
3
- "version": "1.2.254",
3
+ "version": "1.2.257",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -34,14 +34,14 @@
34
34
  "dependencies": {
35
35
  "@bobfrankston/iflow-direct": "^0.1.65",
36
36
  "@bobfrankston/mailx-host": "^0.1.15",
37
- "@bobfrankston/mailx-imap": "^0.1.152",
38
- "@bobfrankston/mailx-store-web": "^0.1.79",
37
+ "@bobfrankston/mailx-imap": "^0.1.153",
38
+ "@bobfrankston/mailx-store-web": "^0.1.80",
39
39
  "@bobfrankston/mailx-sync": "^0.1.29",
40
40
  "@bobfrankston/miscinfo": "^1.0.17",
41
- "@bobfrankston/msger": "^0.1.424",
41
+ "@bobfrankston/msger": "^0.1.425",
42
42
  "@bobfrankston/node-tcp-transport": "^0.1.10",
43
43
  "@bobfrankston/oauthsupport": "^1.0.34",
44
- "@bobfrankston/rmf-tiny": "^0.1.46",
44
+ "@bobfrankston/rmf-tiny": "^0.1.48",
45
45
  "@bobfrankston/smtp-direct": "^0.1.9",
46
46
  "@bobfrankston/tcp-transport": "^0.1.8",
47
47
  "@capacitor/android": "^8.3.0",
@@ -114,14 +114,14 @@
114
114
  "dependencies": {
115
115
  "@bobfrankston/iflow-direct": "^0.1.65",
116
116
  "@bobfrankston/mailx-host": "^0.1.15",
117
- "@bobfrankston/mailx-imap": "^0.1.152",
118
- "@bobfrankston/mailx-store-web": "^0.1.79",
117
+ "@bobfrankston/mailx-imap": "^0.1.153",
118
+ "@bobfrankston/mailx-store-web": "^0.1.80",
119
119
  "@bobfrankston/mailx-sync": "^0.1.29",
120
120
  "@bobfrankston/miscinfo": "^1.0.17",
121
- "@bobfrankston/msger": "^0.1.424",
121
+ "@bobfrankston/msger": "^0.1.425",
122
122
  "@bobfrankston/node-tcp-transport": "^0.1.10",
123
123
  "@bobfrankston/oauthsupport": "^1.0.34",
124
- "@bobfrankston/rmf-tiny": "^0.1.46",
124
+ "@bobfrankston/rmf-tiny": "^0.1.48",
125
125
  "@bobfrankston/smtp-direct": "^0.1.9",
126
126
  "@bobfrankston/tcp-transport": "^0.1.8",
127
127
  "@capacitor/android": "^8.3.0",