@richscripts/richtexteditor 2.4.1 → 2.5.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.
@@ -0,0 +1,365 @@
1
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
+
3
+ // 2026-08-15 Gap cursor — reach the unreachable places in a document.
4
+ //
5
+ // A document that ENDS with a table cannot be added to. The caret goes into the
6
+ // last cell and stays there: ArrowDown, ArrowRight and End all leave it where it
7
+ // was, because there is no node after the table to put it in. Same story for a
8
+ // document that STARTS with a table, and for two adjacent tables with no
9
+ // paragraph between them — there is no caret position in the gap, so the author
10
+ // cannot type there at all. Verified on the running editor before writing this:
11
+ // the caret stayed at TABLE>TBODY>TR>TD through every key.
12
+ //
13
+ // This is the single most-reported "the editor is broken" issue in every
14
+ // WYSIWYG that lacks it. Froala ships it as `lineBreaker`, Tiptap as
15
+ // `gapcursor`, and ProseMirror has it in core. We had it under no name.
16
+ //
17
+ // Design notes:
18
+ // - ON DEMAND, never eager. The obvious fix — always append a trailing empty
19
+ // paragraph — mutates every document that ends in a table, so getHTMLCode()
20
+ // starts returning markup the author did not write and a round-trip through
21
+ // the editor is no longer lossless. A paragraph is created only when the
22
+ // author actually asks for one by pressing a key or clicking the gap.
23
+ // - Clicking the blank space below the content is the gesture people already
24
+ // have from Google Docs and Notion, and it needs no overlay DOM: a click
25
+ // that lands on the editable ITSELF (rather than on a child) is by
26
+ // definition a click in a gap. The child list then says which gap.
27
+ // - Only TRAPPING blocks count. A trailing <p> is already reachable, so
28
+ // offering to add another one after it would just spam empty paragraphs.
29
+ // - The inserted paragraph carries a <br> filler. An empty <p> has zero
30
+ // height in most engines, so without it the caret lands somewhere invisible
31
+ // and the author thinks nothing happened.
32
+ RTE_DefaultConfig.plugin_gapcursor = RTE_Plugin_GapCursor;
33
+
34
+ // Click the empty space below/around content to create a paragraph there.
35
+ if (typeof RTE_DefaultConfig.gapCursorClick === "undefined") RTE_DefaultConfig.gapCursorClick = true;
36
+ // Arrow/Home/End keys escape a trapping block at the document edge.
37
+ if (typeof RTE_DefaultConfig.gapCursorKeys === "undefined") RTE_DefaultConfig.gapCursorKeys = true;
38
+ // Show a thin insertion hint when hovering a gap between two trapping blocks.
39
+ if (typeof RTE_DefaultConfig.gapCursorHint === "undefined") RTE_DefaultConfig.gapCursorHint = true;
40
+
41
+ function RTE_Plugin_GapCursor() {
42
+ var obj = this;
43
+ var config, editor;
44
+ var boundDoc = null;
45
+
46
+ obj.PluginName = "GapCursor";
47
+
48
+ // Blocks that cannot host a caret directly beside them. A caret can always
49
+ // be placed inside a paragraph or heading, so those are never "trapping".
50
+ var TRAPPING = {
51
+ TABLE: 1, HR: 1, IMG: 1, FIGURE: 1, IFRAME: 1, VIDEO: 1, AUDIO: 1,
52
+ OBJECT: 1, EMBED: 1, CANVAS: 1, SVG: 1, PRE: 1
53
+ };
54
+
55
+ obj.InitConfig = function (argconfig) { config = argconfig; };
56
+
57
+ obj.InitEditor = function (argeditor) {
58
+ editor = argeditor;
59
+
60
+ bind();
61
+ try { editor.attachEvent("ready", bind); } catch (e) {}
62
+ try { editor.attachEvent("aftersethtml", bind); } catch (e) {}
63
+ setTimeout(bind, 0);
64
+
65
+ // Public API.
66
+ editor.insertParagraphAt = function (index) { return insertAt(index); };
67
+ editor.insertParagraphAfter = function (el) { return insertAround(el, true); };
68
+ editor.insertParagraphBefore = function (el) { return insertAround(el, false); };
69
+ editor.getGapPositions = function () { return gaps(); };
70
+ };
71
+
72
+ function getDoc() { try { return editor.getDocument(); } catch (e) { return null; } }
73
+ function getEditable() { try { return editor.getEditable(); } catch (e) { return null; } }
74
+
75
+ function bind() {
76
+ var doc = getDoc();
77
+ var ed = getEditable();
78
+ if (!doc || !ed || doc === boundDoc) return;
79
+ boundDoc = doc;
80
+ injectStyles(doc);
81
+
82
+ if (config.gapCursorKeys !== false) ed.addEventListener("keydown", onKeyDown, true);
83
+ if (config.gapCursorClick !== false) ed.addEventListener("click", onClick, true);
84
+ if (config.gapCursorHint !== false) ed.addEventListener("mousemove", onMove, true);
85
+ }
86
+
87
+ function isTrapping(el) {
88
+ return !!(el && el.nodeType === 1 && TRAPPING[el.nodeName]);
89
+ }
90
+
91
+ function isHint(el) {
92
+ return !!(el && el.nodeType === 1 && el.className === "rte-gap-hint");
93
+ }
94
+
95
+ // The editable's real children, with the hover hint filtered out.
96
+ //
97
+ // The hint is itself a child of the editable, so any code that reasons about
98
+ // positions via ed.children sees it as a block: it shifts every index after
99
+ // it, and because a <div> is not "trapping" its presence as the last child
100
+ // made the trailing gap disappear entirely — hovering below a table removed
101
+ // the very gap the hover was advertising, and the click then did nothing.
102
+ function blocks() {
103
+ var ed = getEditable();
104
+ var out = [];
105
+ if (!ed) return out;
106
+ for (var i = 0; i < ed.children.length; i++) {
107
+ if (!isHint(ed.children[i])) out.push(ed.children[i]);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // Every index where a paragraph could usefully be inserted: before a leading
113
+ // trapping block, after a trailing one, and between two adjacent ones.
114
+ function gaps() {
115
+ var out = [];
116
+ var kids = blocks();
117
+ if (!kids.length) return out;
118
+ if (isTrapping(kids[0])) out.push(0);
119
+ for (var i = 0; i < kids.length - 1; i++) {
120
+ if (isTrapping(kids[i]) && isTrapping(kids[i + 1])) out.push(i + 1);
121
+ }
122
+ if (isTrapping(kids[kids.length - 1])) out.push(kids.length);
123
+ return out;
124
+ }
125
+
126
+ // ---- insertion -------------------------------------------------------
127
+
128
+ function makeParagraph(doc) {
129
+ var p = doc.createElement("p");
130
+ // An empty <p> collapses to zero height, so the caret would land
131
+ // somewhere the author cannot see.
132
+ p.appendChild(doc.createElement("br"));
133
+ return p;
134
+ }
135
+
136
+ function insertAt(index) {
137
+ var ed = getEditable();
138
+ var doc = getDoc();
139
+ if (!ed || !doc) return null;
140
+ // Resolve against the hint-free list, then insert relative to that node.
141
+ hideHint();
142
+ var p = makeParagraph(doc);
143
+ var before = blocks()[index] || null;
144
+ ed.insertBefore(p, before);
145
+ focusParagraph(p);
146
+ fireChange();
147
+ return p;
148
+ }
149
+
150
+ function insertAround(el, after) {
151
+ var ed = getEditable();
152
+ var doc = getDoc();
153
+ if (!ed || !doc || !el) return null;
154
+ var p = makeParagraph(doc);
155
+ if (after) el.parentNode.insertBefore(p, el.nextSibling);
156
+ else el.parentNode.insertBefore(p, el);
157
+ focusParagraph(p);
158
+ fireChange();
159
+ return p;
160
+ }
161
+
162
+ function focusParagraph(p) {
163
+ try {
164
+ var doc = p.ownerDocument;
165
+ var r = doc.createRange();
166
+ r.setStart(p, 0);
167
+ r.collapse(true);
168
+ var sel = editor.getSelection();
169
+ sel.removeAllRanges();
170
+ sel.addRange(r);
171
+ editor.focus();
172
+ } catch (e) {}
173
+ }
174
+
175
+ // ---- keyboard --------------------------------------------------------
176
+
177
+ // The top-level child of the editable that contains this node.
178
+ function topBlockOf(node) {
179
+ var ed = getEditable();
180
+ var n = (node && node.nodeType === 3) ? node.parentNode : node;
181
+ while (n && n.parentNode && n.parentNode !== ed) n = n.parentNode;
182
+ return (n && n.parentNode === ed) ? n : null;
183
+ }
184
+
185
+ function onKeyDown(e) {
186
+ var key = e.key;
187
+ var forward = (key === "ArrowDown" || key === "ArrowRight" || key === "End" || key === "PageDown");
188
+ var back = (key === "ArrowUp" || key === "ArrowLeft" || key === "Home" || key === "PageUp");
189
+ if (!forward && !back) return;
190
+
191
+ var ed = getEditable();
192
+ if (!ed || !ed.children.length) return;
193
+
194
+ var sel = editor.getSelection();
195
+ if (!sel || !sel.rangeCount) return;
196
+ var block = topBlockOf(sel.getRangeAt(0).startContainer);
197
+ if (!isTrapping(block)) return;
198
+
199
+ var kids = blocks();
200
+ var index = kids.indexOf(block);
201
+ if (index < 0) return;
202
+
203
+ if (forward) {
204
+ var next = kids[index + 1];
205
+ // Escape only where the author is genuinely stuck: nothing after the
206
+ // block, or another trapping block immediately after it.
207
+ if (next && !isTrapping(next)) return;
208
+ e.preventDefault();
209
+ e.stopPropagation();
210
+ insertAt(index + 1);
211
+ } else {
212
+ var prev = kids[index - 1];
213
+ if (prev && !isTrapping(prev)) return;
214
+ e.preventDefault();
215
+ e.stopPropagation();
216
+ insertAt(index);
217
+ }
218
+ }
219
+
220
+ // ---- click in a gap --------------------------------------------------
221
+
222
+ // A click that lands on the editable itself, not on any child, is a click in
223
+ // the space between or after blocks.
224
+ function onClick(e) {
225
+ var ed = getEditable();
226
+ if (!ed || e.target !== ed) return;
227
+ // Measure the gaps as they are without the hint, or the hint's own box
228
+ // shifts the geometry the click is being resolved against.
229
+ hideHint();
230
+ var index = gapIndexAt(e.clientY);
231
+ if (index === null) return;
232
+ e.preventDefault();
233
+ e.stopPropagation();
234
+ insertAt(index);
235
+ }
236
+
237
+ // Which gap does this viewport Y fall in? Below every block means the end.
238
+ function gapIndexAt(clientY) {
239
+ var kids = blocks();
240
+ if (!kids.length) return null;
241
+ var available = gaps();
242
+ if (!available.length) return null;
243
+
244
+ for (var i = 0; i < kids.length; i++) {
245
+ var rect = kids[i].getBoundingClientRect();
246
+ if (clientY < rect.top) return allowed(available, i);
247
+ if (clientY <= rect.bottom) return null; // on the block itself
248
+ }
249
+ return allowed(available, kids.length);
250
+ }
251
+
252
+ function allowed(available, index) {
253
+ return available.indexOf(index) >= 0 ? index : null;
254
+ }
255
+
256
+ // ---- hover hint ------------------------------------------------------
257
+
258
+ var hint = null;
259
+ var hintIndex = null;
260
+
261
+ // How close to a block boundary counts as "aiming at the gap".
262
+ var EDGE_TOLERANCE = 10;
263
+
264
+ function onMove(e) {
265
+ var ed = getEditable();
266
+ if (!ed) return;
267
+ if (isHint(e.target)) return; // already on the hint; leave it alone
268
+
269
+ // Blank space below/around the content: the click will land on the
270
+ // editable itself and the geometry alone is enough.
271
+ if (e.target === ed) {
272
+ var index = gapIndexAt(e.clientY);
273
+ if (index === null) { hideHint(); return; }
274
+ showHint(index);
275
+ return;
276
+ }
277
+
278
+ // Two adjacent tables have ZERO pixels between them, so a click there
279
+ // can never reach the editable — it always lands on one of the tables.
280
+ // The gap is unreachable by mouse unless we put something in it, so
281
+ // hovering near the boundary materialises the hint and the hint itself
282
+ // becomes the click target.
283
+ var near = gapNearEdge(e.clientY);
284
+ if (near === null) { hideHint(); return; }
285
+ showHint(near);
286
+ }
287
+
288
+ // A gap whose boundary is within EDGE_TOLERANCE of this Y.
289
+ function gapNearEdge(clientY) {
290
+ var kids = blocks();
291
+ var available = gaps();
292
+ for (var g = 0; g < available.length; g++) {
293
+ var i = available[g];
294
+ var edge;
295
+ if (i === 0) edge = kids[0].getBoundingClientRect().top;
296
+ else edge = kids[i - 1].getBoundingClientRect().bottom;
297
+ if (Math.abs(clientY - edge) <= EDGE_TOLERANCE) return i;
298
+ }
299
+ return null;
300
+ }
301
+
302
+ function showHint(index) {
303
+ var ed = getEditable();
304
+ var doc = getDoc();
305
+ if (!ed || !doc) return;
306
+ if (!hint) {
307
+ hint = doc.createElement("div");
308
+ hint.className = "rte-gap-hint";
309
+ hint.setAttribute("contenteditable", "false");
310
+ hint.setAttribute("aria-hidden", "true");
311
+ // The hint is the only clickable thing in a zero-height gap.
312
+ hint.addEventListener("mousedown", function (ev) {
313
+ ev.preventDefault();
314
+ ev.stopPropagation();
315
+ if (hintIndex !== null) insertAt(hintIndex);
316
+ }, true);
317
+ }
318
+ var before = blocks()[index] || null;
319
+ // Re-inserting in place would thrash the DOM on every mousemove.
320
+ if (hint.parentNode === ed && hint.nextSibling === before) { hintIndex = index; return; }
321
+ ed.insertBefore(hint, before);
322
+ hintIndex = index;
323
+ }
324
+
325
+ function hideHint() {
326
+ if (hint && hint.parentNode) hint.parentNode.removeChild(hint);
327
+ hintIndex = null;
328
+ }
329
+
330
+ function fireChange() {
331
+ // The hint is decoration; it must never be in the document when the
332
+ // content is read.
333
+ hideHint();
334
+ try { if (typeof editor.updateDesign === "function") editor.updateDesign(); } catch (e) {}
335
+ try { if (typeof editor.fireChange === "function") editor.fireChange(); } catch (e) {}
336
+ }
337
+
338
+ // ---- styles ----------------------------------------------------------
339
+
340
+ function css() {
341
+ // A real hit area, not just a line: in a zero-height gap between two
342
+ // tables this element IS the click target, so it needs height.
343
+ return ".rte-gap-hint{height:6px;margin:1px 0;position:relative;cursor:text;" +
344
+ "pointer-events:auto;background:transparent;}" +
345
+ ".rte-gap-hint::before{content:'';position:absolute;left:0;right:0;top:2px;" +
346
+ "border-top:2px solid #2563eb;opacity:.65;}" +
347
+ ".rte-gap-hint::after{content:'';position:absolute;left:-3px;top:0;width:6px;height:6px;" +
348
+ "border-radius:50%;background:#2563eb;}";
349
+ }
350
+
351
+ function injectStyles(doc) {
352
+ if (!doc) return;
353
+ var text = css();
354
+ var existing = doc.getElementById("rte-gapcursor-styles");
355
+ if (existing) {
356
+ if (existing.getAttribute("data-css") === text) return;
357
+ existing.parentNode && existing.parentNode.removeChild(existing);
358
+ }
359
+ var st = doc.createElement("style");
360
+ st.id = "rte-gapcursor-styles";
361
+ st.setAttribute("data-css", text);
362
+ st.appendChild(doc.createTextNode(text));
363
+ (doc.head || doc.getElementsByTagName("head")[0] || doc.documentElement).appendChild(st);
364
+ }
365
+ }
@@ -43,7 +43,22 @@ function RTE_Plugin_ImageEditor() {
43
43
  return tag;
44
44
  }
45
45
 
46
- function dataURLToBlob(dataurl) {
46
+ var MIME_EXTENSION = {
47
+ "image/png": ".png",
48
+ "image/jpeg": ".jpg",
49
+ "image/jpg": ".jpg",
50
+ "image/webp": ".webp",
51
+ "image/gif": ".gif",
52
+ "image/bmp": ".bmp",
53
+ "image/avif": ".avif"
54
+ };
55
+
56
+ // Returns a File, not a bare Blob. Upload handlers identify the file by its
57
+ // .name - a Blob has none, so the edited image was posted with an undefined
58
+ // name and every server rejected it ("Invalid file extension"), surfacing as
59
+ // "Upload failed. Check your connection and try again." when the network was
60
+ // perfectly fine.
61
+ function dataURLToBlob(dataurl, filename) {
47
62
  var arr = dataurl.split(',');
48
63
  var mime = arr[0].match(/:(.*?);/)[1];
49
64
  var bstr = atob(arr[1]);
@@ -52,7 +67,16 @@ function RTE_Plugin_ImageEditor() {
52
67
  while (n--) {
53
68
  u8arr[n] = bstr.charCodeAt(n);
54
69
  }
55
- return new Blob([u8arr], { type: mime });
70
+ var name = filename || ("image-" + new Date().getTime() + (MIME_EXTENSION[mime] || ".png"));
71
+ try {
72
+ return new File([u8arr], name, { type: mime });
73
+ } catch (e) {
74
+ // Very old browsers have no File constructor. Keep the name reachable
75
+ // so a handler reading file.name still works.
76
+ var blob = new Blob([u8arr], { type: mime });
77
+ blob.name = name;
78
+ return blob;
79
+ }
56
80
  }
57
81
 
58
82
  obj.DoImageEditor = function () {
@@ -159,8 +183,11 @@ function RTE_Plugin_ImageEditor() {
159
183
  }
160
184
  savebtn.disabled = false;
161
185
  savebtn.innerText = "Save";
186
+ // Show what the server actually said. Blaming the connection sent
187
+ // people chasing a network problem when the real answer was a
188
+ // rejected file type or a size limit.
162
189
  status.innerText = error
163
- ? "Upload failed. Check your connection and try again."
190
+ ? ("Upload failed: " + error)
164
191
  : "The upload did not return a file URL. Please try again.";
165
192
  });
166
193