rte-rich-text-editor-ws 1.0.0

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.
Files changed (3) hide show
  1. package/README.md +159 -0
  2. package/package.json +38 -0
  3. package/rte-ws.js +292 -0
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # RTE-WS — WebSocket Connector for RTE Rich Text Editor
2
+
3
+ Add real-time auto-save and multi-user collaboration to [RTE](https://www.npmjs.com/package/rte-rich-text-editor) with a single script tag. **Zero dependencies.**
4
+
5
+ ![RTE Rich Text Editor](https://rte.whitneys.co/rte.png)
6
+
7
+ ## Install
8
+
9
+ ### npm
10
+
11
+ ```bash
12
+ npm install rte-rich-text-editor-ws
13
+ ```
14
+
15
+ ### Script Tag
16
+
17
+ ```html
18
+ <script src="https://rte.whitneys.co/rte.js"></script>
19
+ <script src="https://rte.whitneys.co/rte-ws.js"></script>
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```html
25
+ <div id="editor"></div>
26
+
27
+ <script src="https://rte.whitneys.co/rte.js"></script>
28
+ <script src="https://rte.whitneys.co/rte-ws.js"></script>
29
+ <script>
30
+ const editor = RTE.init('#editor');
31
+
32
+ const ws = RTEWS.connect(editor, 'wss://yourserver.com/ws', {
33
+ docId: 'doc-123',
34
+ userId: 'user-abc',
35
+ onOpen: () => console.log('Connected'),
36
+ onSaved: (msg) => console.log('Saved, version:', msg.version),
37
+ onRemoteUpdate: (msg) => console.log('Update from:', msg.userId),
38
+ });
39
+ </script>
40
+ ```
41
+
42
+ ### CommonJS
43
+
44
+ ```js
45
+ const RTE = require('rte-rich-text-editor');
46
+ const RTEWS = require('rte-rich-text-editor-ws');
47
+
48
+ const editor = RTE.init('#editor');
49
+ const ws = RTEWS.connect(editor, 'wss://yourserver.com/ws', { docId: 'doc-1', userId: 'user-1' });
50
+ ```
51
+
52
+ ## Features
53
+
54
+ | Feature | Description |
55
+ |---|---|
56
+ | **Auto-Save** | Debounced content sync to backend on every change |
57
+ | **Collaboration** | Broadcast and receive changes between multiple users |
58
+ | **Auto-Reconnect** | Exponential backoff (1s → 2s → 4s → ... up to 30s) |
59
+ | **Heartbeat** | Configurable keep-alive ping (default 30s) |
60
+ | **Cursor Preservation** | Local cursor position saved/restored on remote updates |
61
+
62
+ ## Configuration
63
+
64
+ ```js
65
+ const ws = RTEWS.connect(editor, 'wss://yourserver.com/ws', {
66
+ docId: 'doc-123', // Document identifier
67
+ userId: 'user-abc', // User identifier
68
+ debounceMs: 1000, // Debounce delay before sending changes
69
+ autoSave: true, // Auto-send changes on editor input
70
+ reconnect: true, // Auto-reconnect on disconnect
71
+ reconnectBaseMs: 1000, // Initial reconnect delay
72
+ reconnectMaxMs: 30000, // Max reconnect delay
73
+ heartbeatMs: 30000, // Ping interval (0 to disable)
74
+ onOpen: (ws) => {}, // WebSocket connected
75
+ onClose: (e) => {}, // WebSocket closed
76
+ onError: (e) => {}, // Error occurred
77
+ onSaved: (msg) => {}, // Server confirmed save
78
+ onRemoteUpdate: (msg) => {}, // Remote user change applied
79
+ onMessage: (msg) => {}, // Any incoming message
80
+ });
81
+ ```
82
+
83
+ ## API
84
+
85
+ | Method / Property | Description |
86
+ |---|---|
87
+ | `ws.save()` | Send explicit save request |
88
+ | `ws.send(data)` | Send custom JSON message |
89
+ | `ws.disconnect()` | Close connection, stop reconnecting |
90
+ | `ws.reconnect()` | Manually reconnect |
91
+ | `ws.state` | `"connecting"`, `"open"`, `"closing"`, or `"closed"` |
92
+ | `ws.socket` | Raw WebSocket instance |
93
+
94
+ ## Message Protocol
95
+
96
+ ### Outgoing (Client → Server)
97
+
98
+ | Type | Fields | Description |
99
+ |---|---|---|
100
+ | `"join"` | `docId`, `userId` | Sent on connect |
101
+ | `"change"` | `docId`, `userId`, `html`, `text`, `words`, `chars` | Editor content changed |
102
+ | `"save"` | `docId`, `userId`, `html`, `text`, `words`, `chars` | Explicit save |
103
+ | `"ping"` | — | Heartbeat |
104
+
105
+ ### Incoming (Server → Client)
106
+
107
+ | Type | Fields | Description |
108
+ |---|---|---|
109
+ | `"load"` | `html` | Load initial content |
110
+ | `"update"` | `html`, `userId` | Remote user change |
111
+ | `"saved"` | `version` (optional) | Save confirmed |
112
+ | `"error"` | `message` | Server error |
113
+
114
+ ## Backend Example (Node.js)
115
+
116
+ ```js
117
+ import { WebSocketServer } from 'ws';
118
+
119
+ const wss = new WebSocketServer({ port: 8080 });
120
+ const docs = new Map();
121
+
122
+ wss.on('connection', (socket) => {
123
+ let docId = null, userId = null;
124
+
125
+ socket.on('message', (raw) => {
126
+ const msg = JSON.parse(raw);
127
+
128
+ if (msg.type === 'join') {
129
+ docId = msg.docId; userId = msg.userId;
130
+ if (!docs.has(docId)) docs.set(docId, { html: '', clients: new Set() });
131
+ docs.get(docId).clients.add(socket);
132
+ socket.send(JSON.stringify({ type: 'load', html: docs.get(docId).html }));
133
+ }
134
+
135
+ if (msg.type === 'change' && docId) {
136
+ docs.get(docId).html = msg.html;
137
+ docs.get(docId).clients.forEach(c => {
138
+ if (c !== socket && c.readyState === 1)
139
+ c.send(JSON.stringify({ type: 'update', html: msg.html, userId }));
140
+ });
141
+ }
142
+
143
+ if (msg.type === 'save')
144
+ socket.send(JSON.stringify({ type: 'saved', version: Date.now() }));
145
+ });
146
+
147
+ socket.on('close', () => {
148
+ if (docId && docs.has(docId)) docs.get(docId).clients.delete(socket);
149
+ });
150
+ });
151
+ ```
152
+
153
+ ## Documentation
154
+
155
+ Full documentation with more backend examples at [rte.whitneys.co/websocket](https://rte.whitneys.co/websocket)
156
+
157
+ ## License
158
+
159
+ MIT
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "rte-rich-text-editor-ws",
3
+ "version": "1.0.0",
4
+ "description": "WebSocket connector for RTE Rich Text Editor. Auto-save and real-time collaboration. Zero dependencies.",
5
+ "main": "rte-ws.js",
6
+ "browser": "rte-ws.js",
7
+ "unpkg": "rte-ws.js",
8
+ "jsdelivr": "rte-ws.js",
9
+ "files": [
10
+ "rte-ws.js",
11
+ "README.md"
12
+ ],
13
+ "keywords": [
14
+ "rte",
15
+ "rich-text-editor",
16
+ "websocket",
17
+ "real-time",
18
+ "collaboration",
19
+ "auto-save",
20
+ "wysiwyg",
21
+ "editor",
22
+ "standalone",
23
+ "no-dependencies"
24
+ ],
25
+ "author": "phpMyDEV, LLC",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/MIR-2025/rte.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/MIR-2025/rte/issues"
33
+ },
34
+ "homepage": "https://rte.whitneys.co/websocket",
35
+ "peerDependencies": {
36
+ "rte-rich-text-editor": ">=1.0.0"
37
+ }
38
+ }
package/rte-ws.js ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * RTE-WS — WebSocket Connector for RTE Rich Text Editor
3
+ * Standalone companion file. Works with any WebSocket backend.
4
+ *
5
+ * Features:
6
+ * - Auto-save: debounced content sync to backend on changes
7
+ * - Real-time collaboration: broadcast and receive changes between users
8
+ * - Auto-reconnect with exponential backoff
9
+ * - Heartbeat / keep-alive ping
10
+ *
11
+ * Usage:
12
+ * <script src="rte.js"></script>
13
+ * <script src="rte-ws.js"></script>
14
+ * <script>
15
+ * const editor = RTE.init('#editor');
16
+ * const ws = RTEWS.connect(editor, 'wss://yourserver.com/ws', {
17
+ * docId: 'doc-123',
18
+ * userId: 'user-abc',
19
+ * });
20
+ * </script>
21
+ *
22
+ * Backend messages (JSON):
23
+ * Incoming:
24
+ * { type: "load", html: "<p>...</p>" } — load initial content
25
+ * { type: "update", html: "<p>...</p>", userId: "..." } — remote user change
26
+ * { type: "cursor", userId: "...", position: {...} } — remote cursor (optional)
27
+ * { type: "saved", version: 5 } — server confirmed save
28
+ * { type: "error", message: "..." } — server error
29
+ *
30
+ * Outgoing:
31
+ * { type: "join", docId, userId } — join document
32
+ * { type: "change", docId, userId, html, text, words, chars } — content changed
33
+ * { type: "save", docId, userId, html, text, words, chars } — explicit save
34
+ * { type: "ping" } — heartbeat
35
+ */
36
+ (function (root, factory) {
37
+ "use strict";
38
+ if (typeof define === "function" && define.amd) {
39
+ define([], factory);
40
+ } else if (typeof module === "object" && module.exports) {
41
+ module.exports = factory();
42
+ } else {
43
+ root.RTEWS = factory();
44
+ }
45
+ })(typeof self !== "undefined" ? self : this, function () {
46
+ "use strict";
47
+
48
+ const DEFAULTS = {
49
+ docId: null,
50
+ userId: null,
51
+ debounceMs: 1000,
52
+ reconnect: true,
53
+ reconnectMaxMs: 30000,
54
+ reconnectBaseMs: 1000,
55
+ heartbeatMs: 30000,
56
+ autoSave: true,
57
+ onOpen: null,
58
+ onClose: null,
59
+ onError: null,
60
+ onSaved: null,
61
+ onRemoteUpdate: null,
62
+ onMessage: null,
63
+ };
64
+
65
+ function connect(editor, url, opts) {
66
+ const cfg = Object.assign({}, DEFAULTS, opts);
67
+ let ws = null;
68
+ let reconnectAttempts = 0;
69
+ let reconnectTimer = null;
70
+ let heartbeatTimer = null;
71
+ let debounceTimer = null;
72
+ let isSending = false;
73
+ let destroyed = false;
74
+ let lastSentHTML = "";
75
+
76
+ // ── WebSocket lifecycle ──────────────────────────────
77
+
78
+ function open() {
79
+ if (destroyed) return;
80
+ ws = new WebSocket(url);
81
+
82
+ ws.onopen = function () {
83
+ reconnectAttempts = 0;
84
+ // Join document
85
+ send({ type: "join", docId: cfg.docId, userId: cfg.userId });
86
+ // Start heartbeat
87
+ startHeartbeat();
88
+ if (cfg.onOpen) cfg.onOpen(ws);
89
+ };
90
+
91
+ ws.onmessage = function (e) {
92
+ let msg;
93
+ try {
94
+ msg = JSON.parse(e.data);
95
+ } catch (_) {
96
+ return;
97
+ }
98
+ handleMessage(msg);
99
+ };
100
+
101
+ ws.onclose = function (e) {
102
+ stopHeartbeat();
103
+ if (cfg.onClose) cfg.onClose(e);
104
+ if (cfg.reconnect && !destroyed) scheduleReconnect();
105
+ };
106
+
107
+ ws.onerror = function (e) {
108
+ if (cfg.onError) cfg.onError(e);
109
+ };
110
+ }
111
+
112
+ function send(data) {
113
+ if (ws && ws.readyState === WebSocket.OPEN) {
114
+ ws.send(JSON.stringify(data));
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ // ── Incoming messages ────────────────────────────────
121
+
122
+ function handleMessage(msg) {
123
+ switch (msg.type) {
124
+ case "load":
125
+ if (msg.html != null) {
126
+ editor.setHTML(msg.html);
127
+ lastSentHTML = msg.html;
128
+ }
129
+ break;
130
+
131
+ case "update":
132
+ // Remote user change — only apply if from a different user
133
+ if (msg.userId !== cfg.userId && msg.html != null) {
134
+ // Save cursor position
135
+ const sel = window.getSelection();
136
+ const hadFocus = document.activeElement === editor.element;
137
+ let savedOffset = null;
138
+ if (hadFocus && sel.rangeCount) {
139
+ const range = sel.getRangeAt(0);
140
+ savedOffset = {
141
+ startContainer: range.startContainer,
142
+ startOffset: range.startOffset,
143
+ endContainer: range.endContainer,
144
+ endOffset: range.endOffset,
145
+ };
146
+ }
147
+ isSending = true;
148
+ editor.setHTML(msg.html);
149
+ lastSentHTML = msg.html;
150
+ isSending = false;
151
+ // Restore cursor
152
+ if (hadFocus && savedOffset) {
153
+ try {
154
+ const r = document.createRange();
155
+ r.setStart(savedOffset.startContainer, savedOffset.startOffset);
156
+ r.setEnd(savedOffset.endContainer, savedOffset.endOffset);
157
+ sel.removeAllRanges();
158
+ sel.addRange(r);
159
+ } catch (_) {
160
+ // Node may no longer exist after HTML replacement
161
+ }
162
+ }
163
+ if (cfg.onRemoteUpdate) cfg.onRemoteUpdate(msg);
164
+ }
165
+ break;
166
+
167
+ case "saved":
168
+ if (cfg.onSaved) cfg.onSaved(msg);
169
+ break;
170
+
171
+ case "error":
172
+ if (cfg.onError) cfg.onError(new Error(msg.message));
173
+ break;
174
+ }
175
+
176
+ if (cfg.onMessage) cfg.onMessage(msg);
177
+ }
178
+
179
+ // ── Auto-save on change ──────────────────────────────
180
+
181
+ function onEditorChange(data) {
182
+ if (isSending || !cfg.autoSave) return;
183
+ const html = editor.getHTML();
184
+ if (html === lastSentHTML) return;
185
+
186
+ clearTimeout(debounceTimer);
187
+ debounceTimer = setTimeout(function () {
188
+ lastSentHTML = html;
189
+ send({
190
+ type: "change",
191
+ docId: cfg.docId,
192
+ userId: cfg.userId,
193
+ html: html,
194
+ text: data.text,
195
+ words: data.words,
196
+ chars: data.chars,
197
+ });
198
+ }, cfg.debounceMs);
199
+ }
200
+
201
+ // Hook into editor onChange
202
+ const origOnChange = editor.onChange;
203
+ editor.onChange = function (data) {
204
+ if (origOnChange) origOnChange(data);
205
+ onEditorChange(data);
206
+ };
207
+
208
+ // ── Reconnect ────────────────────────────────────────
209
+
210
+ function scheduleReconnect() {
211
+ if (destroyed) return;
212
+ reconnectAttempts++;
213
+ const delay = Math.min(
214
+ cfg.reconnectBaseMs * Math.pow(2, reconnectAttempts - 1),
215
+ cfg.reconnectMaxMs
216
+ );
217
+ reconnectTimer = setTimeout(open, delay);
218
+ }
219
+
220
+ // ── Heartbeat ────────────────────────────────────────
221
+
222
+ function startHeartbeat() {
223
+ stopHeartbeat();
224
+ if (cfg.heartbeatMs > 0) {
225
+ heartbeatTimer = setInterval(function () {
226
+ send({ type: "ping" });
227
+ }, cfg.heartbeatMs);
228
+ }
229
+ }
230
+
231
+ function stopHeartbeat() {
232
+ if (heartbeatTimer) {
233
+ clearInterval(heartbeatTimer);
234
+ heartbeatTimer = null;
235
+ }
236
+ }
237
+
238
+ // ── Public API ───────────────────────────────────────
239
+
240
+ open();
241
+
242
+ return {
243
+ /** Send an explicit save request */
244
+ save: function () {
245
+ const html = editor.getHTML();
246
+ const text = editor.getText();
247
+ lastSentHTML = html;
248
+ return send({
249
+ type: "save",
250
+ docId: cfg.docId,
251
+ userId: cfg.userId,
252
+ html: html,
253
+ text: text,
254
+ words: text.split(/\s+/).filter(Boolean).length,
255
+ chars: text.length,
256
+ });
257
+ },
258
+
259
+ /** Send a custom message */
260
+ send: send,
261
+
262
+ /** Disconnect and stop reconnecting */
263
+ disconnect: function () {
264
+ destroyed = true;
265
+ clearTimeout(reconnectTimer);
266
+ clearTimeout(debounceTimer);
267
+ stopHeartbeat();
268
+ if (ws) ws.close();
269
+ },
270
+
271
+ /** Reconnect manually */
272
+ reconnect: function () {
273
+ destroyed = false;
274
+ if (ws) ws.close();
275
+ open();
276
+ },
277
+
278
+ /** Get connection state: "connecting" | "open" | "closing" | "closed" */
279
+ get state() {
280
+ if (!ws) return "closed";
281
+ return ["connecting", "open", "closing", "closed"][ws.readyState];
282
+ },
283
+
284
+ /** The raw WebSocket instance */
285
+ get socket() {
286
+ return ws;
287
+ },
288
+ };
289
+ }
290
+
291
+ return { connect: connect };
292
+ });