@bobfrankston/rmfmail 1.0.650 → 1.0.652
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/bin/build-rmf-tiny.js +20 -0
- package/client/compose/compose.ts +20 -0
- package/client/compose/editor.ts +6 -1
- package/client/lib/rmf-tiny.js +141 -0
- package/package.json +2 -2
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copy @bobfrankston/rmf-tiny's adapter.js into client/lib/ so the compose
|
|
3
|
+
// page can dynamic-import it at runtime. msger's custom protocol serves
|
|
4
|
+
// files only from contentDir (client/), and `node_modules/` lives at
|
|
5
|
+
// app/node_modules — outside that root. A build-time copy keeps the public
|
|
6
|
+
// adapter entry reachable without changing msger's served root or messing
|
|
7
|
+
// with junctions that npm install would clobber.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
12
|
+
const src = path.join(root, "node_modules", "@bobfrankston", "rmf-tiny", "src", "adapter.js");
|
|
13
|
+
const dst = path.join(root, "client", "lib", "rmf-tiny.js");
|
|
14
|
+
|
|
15
|
+
if (!fs.existsSync(src)) {
|
|
16
|
+
console.error(`build-rmf-tiny: source not found at ${src} — run npm install first`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
fs.copyFileSync(src, dst);
|
|
20
|
+
console.log(`build-rmf-tiny: copied ${path.relative(root, src)} → ${path.relative(root, dst)}`);
|
|
@@ -130,8 +130,28 @@ function setActiveEditorBadge(type: EditorTypeChoice | "fallback"): void {
|
|
|
130
130
|
tinymce: "TinyMCE",
|
|
131
131
|
fallback: "plain (fallback)",
|
|
132
132
|
};
|
|
133
|
+
const docs: Record<string, string> = {
|
|
134
|
+
quill: "https://quilljs.com/docs/quickstart",
|
|
135
|
+
tiptap: "https://tiptap.dev/docs/editor/introduction",
|
|
136
|
+
tinymce: "https://www.tiny.cloud/docs/tinymce/6/",
|
|
137
|
+
fallback: "https://github.com/BobFrankston/mailx/blob/master/app/docs/editor.md",
|
|
138
|
+
};
|
|
133
139
|
el.textContent = labels[type] || type;
|
|
134
140
|
el.dataset.editor = type;
|
|
141
|
+
const url = docs[type];
|
|
142
|
+
if (url) {
|
|
143
|
+
el.style.cursor = "pointer";
|
|
144
|
+
el.title = `Open ${labels[type]} documentation`;
|
|
145
|
+
el.onclick = () => {
|
|
146
|
+
const api = (window as any).mailxapi;
|
|
147
|
+
if (api?.openExternal) api.openExternal(url);
|
|
148
|
+
else window.open(url, "_blank", "noopener,noreferrer");
|
|
149
|
+
};
|
|
150
|
+
} else {
|
|
151
|
+
el.style.cursor = "";
|
|
152
|
+
el.title = "";
|
|
153
|
+
el.onclick = null;
|
|
154
|
+
}
|
|
135
155
|
}
|
|
136
156
|
|
|
137
157
|
async function tryEditor(type: EditorTypeChoice): Promise<MailxEditor | null> {
|
package/client/compose/editor.ts
CHANGED
|
@@ -651,7 +651,12 @@ async function createTinyMceEditor(container: HTMLElement): Promise<MailxEditor>
|
|
|
651
651
|
cdnUrl = localStorage.getItem("mailx-tinymce-cdn") || DEFAULT_TINYMCE_CDN;
|
|
652
652
|
apiKey = localStorage.getItem("mailx-tinymce-apikey") || undefined;
|
|
653
653
|
} catch { /* private mode — use defaults */ }
|
|
654
|
-
|
|
654
|
+
// Build step copies node_modules/@bobfrankston/rmf-tiny/src/adapter.js
|
|
655
|
+
// → client/lib/rmf-tiny.js. Bare-name `@bobfrankston/rmf-tiny` resolution
|
|
656
|
+
// would target `node_modules/`, which msger's custom protocol can't reach
|
|
657
|
+
// (outside contentDir=client/). The relative path stays inside the
|
|
658
|
+
// served root.
|
|
659
|
+
const m = (await import("../lib/rmf-tiny.js" as any)) as {
|
|
655
660
|
createTinyMceEditor(container: HTMLElement, opts: { cdnUrl?: string; apiKey?: string }): Promise<MailxEditor>;
|
|
656
661
|
};
|
|
657
662
|
const ed = await m.createTinyMceEditor(container, { cdnUrl, apiKey });
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bobfrankston/rmf-tiny — TinyMCE adapter for rmfmail.
|
|
3
|
+
*
|
|
4
|
+
* MIT-licensed adapter glue. Imports TinyMCE at runtime; ships no
|
|
5
|
+
* TinyMCE bytes. The user is responsible for making TinyMCE reachable
|
|
6
|
+
* to their environment:
|
|
7
|
+
*
|
|
8
|
+
* - Desktop / Node-side install: `npm install tinymce`
|
|
9
|
+
* (rmfmail's createEditor("tinymce") branch dynamically imports
|
|
10
|
+
* this adapter, which dynamically imports tinymce from
|
|
11
|
+
* node_modules.)
|
|
12
|
+
*
|
|
13
|
+
* - Android / browser-only WebView: pass `cdnUrl` in the options to
|
|
14
|
+
* have the adapter inject a <script src=…> tag. The bytes are
|
|
15
|
+
* fetched directly from Tiny's CDN (or whatever URL the user
|
|
16
|
+
* configured); the host APK / page never carries them.
|
|
17
|
+
*
|
|
18
|
+
* The adapter implements the same `MailxEditor` shape as the Quill /
|
|
19
|
+
* tiptap factories in rmfmail's editor.ts, so the host code path is
|
|
20
|
+
* identical regardless of which editor is in use.
|
|
21
|
+
*/
|
|
22
|
+
/** Load tinymce. Tries native module import first (desktop / npm-installed);
|
|
23
|
+
* falls back to script-tag injection from cdnUrl. Resolves to whatever's
|
|
24
|
+
* on `window.tinymce`. */
|
|
25
|
+
async function loadTinymce(opts) {
|
|
26
|
+
// 1. Try the npm-installed path. Wrapped in try because in browser-only
|
|
27
|
+
// environments the module specifier won't resolve and we want to
|
|
28
|
+
// fall through to CDN injection.
|
|
29
|
+
try {
|
|
30
|
+
const mod = await import(/* @vite-ignore */ "tinymce");
|
|
31
|
+
const tinymce = mod.default || mod;
|
|
32
|
+
// Pull in the plugins TinyMCE's paste-from-Word handler needs. These
|
|
33
|
+
// are side-effect imports — they register themselves on the global.
|
|
34
|
+
await Promise.all([
|
|
35
|
+
import("tinymce/themes/silver").catch(() => { }),
|
|
36
|
+
import("tinymce/icons/default").catch(() => { }),
|
|
37
|
+
import("tinymce/models/dom").catch(() => { }),
|
|
38
|
+
import("tinymce/plugins/paste").catch(() => { }),
|
|
39
|
+
import("tinymce/plugins/lists").catch(() => { }),
|
|
40
|
+
import("tinymce/plugins/link").catch(() => { }),
|
|
41
|
+
import("tinymce/plugins/table").catch(() => { }),
|
|
42
|
+
import("tinymce/plugins/code").catch(() => { }),
|
|
43
|
+
import("tinymce/plugins/image").catch(() => { }),
|
|
44
|
+
]);
|
|
45
|
+
return tinymce;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Fall through.
|
|
49
|
+
}
|
|
50
|
+
// 2. Already loaded by a prior call.
|
|
51
|
+
const w = window;
|
|
52
|
+
if (w.tinymce)
|
|
53
|
+
return w.tinymce;
|
|
54
|
+
// 3. Inject from CDN if provided.
|
|
55
|
+
if (!opts.cdnUrl) {
|
|
56
|
+
throw new Error("rmf-tiny: tinymce not installed (npm install tinymce) and no cdnUrl supplied. See README for Android setup.");
|
|
57
|
+
}
|
|
58
|
+
const url = opts.apiKey && !opts.cdnUrl.includes("api-key")
|
|
59
|
+
? `${opts.cdnUrl}${opts.cdnUrl.includes("?") ? "&" : "?"}apiKey=${encodeURIComponent(opts.apiKey)}`
|
|
60
|
+
: opts.cdnUrl;
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
const s = document.createElement("script");
|
|
63
|
+
s.src = url;
|
|
64
|
+
s.referrerPolicy = "origin";
|
|
65
|
+
s.onload = () => resolve();
|
|
66
|
+
s.onerror = () => reject(new Error(`rmf-tiny: failed to load TinyMCE from ${url}`));
|
|
67
|
+
document.head.appendChild(s);
|
|
68
|
+
});
|
|
69
|
+
if (!w.tinymce)
|
|
70
|
+
throw new Error("rmf-tiny: TinyMCE script loaded but window.tinymce is missing");
|
|
71
|
+
return w.tinymce;
|
|
72
|
+
}
|
|
73
|
+
/** Create a TinyMCE-backed MailxEditor in `container`. */
|
|
74
|
+
export async function createTinyMceEditor(container, opts = {}) {
|
|
75
|
+
const tinymce = await loadTinymce(opts);
|
|
76
|
+
// TinyMCE attaches to a target element by id selector; create one
|
|
77
|
+
// inside the host container so multiple editors on a page don't
|
|
78
|
+
// collide and so we don't pollute the caller's element with TinyMCE
|
|
79
|
+
// attributes directly.
|
|
80
|
+
const target = document.createElement("div");
|
|
81
|
+
target.id = `rmf-tiny-${Math.random().toString(36).slice(2, 10)}`;
|
|
82
|
+
target.style.cssText = "width:100%;height:100%;";
|
|
83
|
+
container.appendChild(target);
|
|
84
|
+
const editor = await new Promise((resolve) => {
|
|
85
|
+
tinymce.init({
|
|
86
|
+
target,
|
|
87
|
+
// Word-paste fidelity is the entire point of using TinyMCE here.
|
|
88
|
+
// `paste_as_text: false` keeps formatting; powerpaste isn't in
|
|
89
|
+
// OSS but the standard paste plugin handles Word reasonably well.
|
|
90
|
+
plugins: "paste lists link table code image preview searchreplace",
|
|
91
|
+
toolbar: [
|
|
92
|
+
"undo redo | bold italic underline strikethrough | forecolor backcolor",
|
|
93
|
+
"bullist numlist outdent indent | link table image code",
|
|
94
|
+
].join(" | "),
|
|
95
|
+
menubar: "file edit view insert format",
|
|
96
|
+
// Right-click menu — TinyMCE's built-in context menu plugin.
|
|
97
|
+
// Without this set, the WebView's default context menu fires
|
|
98
|
+
// (browser-level cut/copy/paste only). The string lists which
|
|
99
|
+
// contextmenu items show up; "link image table" are stock
|
|
100
|
+
// TinyMCE entries that pick up automatically when the user
|
|
101
|
+
// right-clicks on each kind of element.
|
|
102
|
+
contextmenu: "link image table | cut copy paste | undo redo",
|
|
103
|
+
statusbar: false,
|
|
104
|
+
branding: false,
|
|
105
|
+
license_key: "gpl",
|
|
106
|
+
paste_data_images: true,
|
|
107
|
+
// Permissive valid_elements — preserve as much of the source
|
|
108
|
+
// formatting as possible. The default is more aggressive about
|
|
109
|
+
// stripping. Empty string for `valid_elements` means accept
|
|
110
|
+
// everything that the schema allows.
|
|
111
|
+
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]",
|
|
112
|
+
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",
|
|
113
|
+
content_style: "body { font-family: system-ui, sans-serif; font-size: 14px; }",
|
|
114
|
+
init_instance_callback: (ed) => resolve(ed),
|
|
115
|
+
setup: (ed) => {
|
|
116
|
+
if (opts.initialHtml)
|
|
117
|
+
ed.on("init", () => ed.setContent(opts.initialHtml));
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
setHtml(html) { editor.setContent(html); },
|
|
123
|
+
getHtml() { return editor.getContent(); },
|
|
124
|
+
getText() { return editor.getContent({ format: "text" }); },
|
|
125
|
+
focus() { editor.focus(); },
|
|
126
|
+
setCursor(_pos) {
|
|
127
|
+
// TinyMCE addresses positions through ranges; rmfmail callers
|
|
128
|
+
// mostly use this for "put cursor at end" semantics. Approximate
|
|
129
|
+
// by selecting the end of body content.
|
|
130
|
+
try {
|
|
131
|
+
editor.selection.select(editor.getBody(), true);
|
|
132
|
+
editor.selection.collapse(false);
|
|
133
|
+
}
|
|
134
|
+
catch { /* */ }
|
|
135
|
+
},
|
|
136
|
+
get root() { return editor.getContainer(); },
|
|
137
|
+
on(event, handler) { editor.on(event, handler); },
|
|
138
|
+
off(event, handler) { editor.off(event, handler); },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=adapter.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/rmfmail",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.652",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"client"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "tsc -p bin && node bin/build-icon-ico.js && node bin/build-rmfmailto-exe.js",
|
|
26
|
+
"build": "tsc -p bin && node bin/build-icon-ico.js && node bin/build-rmfmailto-exe.js && node bin/build-rmf-tiny.js",
|
|
27
27
|
"watch": "tsc -w",
|
|
28
28
|
"start": "node --watch packages/mailx-server/index.js",
|
|
29
29
|
"start:prod": "node packages/mailx-server/index.js",
|