@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,312 @@
1
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
+
3
+ // 2026-08-15 Import styles from the page's own stylesheets.
4
+ //
5
+ // The four Quick Styles dropdowns (inlineStyles / paragraphStyles / imageStyles
6
+ // / linkStyles) have always been hand-configured, and the shipped defaults are
7
+ // placeholders — "my-cls-mark", "my-cls-quote" — that mean nothing in a real
8
+ // integration. Every team wiring this editor into an existing design system
9
+ // therefore retypes their own class list into config before the dropdowns are
10
+ // worth opening. TinyMCE removes that step with `importcss`; this is our
11
+ // equivalent.
12
+ //
13
+ // Design notes:
14
+ // - OPT-IN (`importStyles = true`). Silently rewriting the Styles menu out of
15
+ // whatever CSS happens to be on the page would be a surprising default, and
16
+ // on a utility-CSS site it would be a catastrophic one.
17
+ // - The ELEMENT QUALIFIER decides the bucket: `img.hero` is an image style,
18
+ // `a.cta` a link style, `p.lede` a paragraph style. An unqualified `.foo`
19
+ // cannot be placed, so it goes in both the inline and paragraph menus —
20
+ // the author picks by what they have selected.
21
+ // - Cross-origin stylesheets throw a SecurityError on `.cssRules`, and it is
22
+ // thrown on ACCESS, not returned as null. One un-caught sheet from a CDN
23
+ // kills the whole scan, which is the classic way this feature fails.
24
+ // - `@media` / `@supports` blocks hold their own `cssRules`; a design system's
25
+ // responsive variants live in there, so the walk recurses.
26
+ // - There is a HARD CAP. A Tailwind or Bootstrap page exposes thousands of
27
+ // atomic classes, and a dropdown with 4,000 entries is not a feature. When
28
+ // the cap truncates, it says so via console + `getImportedStyles().truncated`
29
+ // rather than quietly showing the first hundred as if that were all of them.
30
+ RTE_DefaultConfig.plugin_importstyles = RTE_Plugin_ImportStyles;
31
+
32
+ // Master switch.
33
+ if (typeof RTE_DefaultConfig.importStyles === "undefined") RTE_DefaultConfig.importStyles = false;
34
+
35
+ // Which stylesheets to read. null = all readable ones. A string/RegExp is
36
+ // matched against the sheet's href ("site.css", /theme/), a function gets the
37
+ // CSSStyleSheet.
38
+ if (typeof RTE_DefaultConfig.importStylesSheets === "undefined") RTE_DefaultConfig.importStylesSheets = null;
39
+
40
+ // Classes never imported: the editor's own chrome, and the other editors'
41
+ // (a page migrating from CKEditor/TinyMCE still has their CSS loaded).
42
+ if (typeof RTE_DefaultConfig.importStylesExclude === "undefined") {
43
+ RTE_DefaultConfig.importStylesExclude = /^(rte[-_]|ck[-_]|cke[-_]|mce[-_]|tox[-_]|js[-_]|is[-_]|has[-_])/i;
44
+ }
45
+
46
+ // Per-class predicate. Return false to skip, a string to override the label.
47
+ // function (className, selectorText, bucket) {}
48
+ if (typeof RTE_DefaultConfig.importStylesFilter === "undefined") RTE_DefaultConfig.importStylesFilter = null;
49
+
50
+ // Replace the configured styles instead of appending to them.
51
+ if (typeof RTE_DefaultConfig.importStylesReplace === "undefined") RTE_DefaultConfig.importStylesReplace = false;
52
+
53
+ // Ceiling per dropdown.
54
+ if (typeof RTE_DefaultConfig.importStylesLimit === "undefined") RTE_DefaultConfig.importStylesLimit = 100;
55
+
56
+ function RTE_Plugin_ImportStyles() {
57
+ var obj = this;
58
+ var config, editor;
59
+ var imported = null;
60
+ var scannedDocs = [];
61
+
62
+ obj.PluginName = "ImportStyles";
63
+
64
+ obj.InitConfig = function (argconfig) { config = argconfig; };
65
+
66
+ obj.InitEditor = function (argeditor) {
67
+ editor = argeditor;
68
+
69
+ // Public API — usable even with importStyles off, so an integrator can
70
+ // inspect what WOULD be imported before switching it on.
71
+ editor.importStyles = function (options) { return run(options || {}); };
72
+ editor.getImportedStyles = function () { return imported; };
73
+ editor.scanStylesheetClasses = function (options) {
74
+ activeOptions = options || {};
75
+ try { return collect(); } finally { activeOptions = {}; }
76
+ };
77
+
78
+ if (!config.importStyles) return;
79
+
80
+ // The host document's sheets are ready now; the editing document's are
81
+ // not, so scan again once it exists. The dropdown panels read config
82
+ // when they OPEN, so a later scan still lands.
83
+ run({});
84
+ try { editor.attachEvent("ready", function () { run({}); }); } catch (e) {}
85
+ setTimeout(function () { run({}); }, 0);
86
+ };
87
+
88
+ // ---- buckets ---------------------------------------------------------
89
+
90
+ var IMAGE_TAGS = { img: 1, figure: 1, picture: 1 };
91
+ var LINK_TAGS = { a: 1 };
92
+ var INLINE_TAGS = { span: 1, strong: 1, b: 1, em: 1, i: 1, code: 1, mark: 1, small: 1, sub: 1, sup: 1, abbr: 1, q: 1, cite: 1 };
93
+ var BLOCK_TAGS = { p: 1, div: 1, h1: 1, h2: 1, h3: 1, h4: 1, h5: 1, h6: 1, blockquote: 1, li: 1, ul: 1, ol: 1, pre: 1, section: 1, article: 1, aside: 1, figcaption: 1, td: 1, th: 1, table: 1 };
94
+
95
+ var TARGETS = [
96
+ { key: "imageStyles", tags: IMAGE_TAGS },
97
+ { key: "linkStyles", tags: LINK_TAGS },
98
+ { key: "inlineStyles", tags: INLINE_TAGS },
99
+ { key: "paragraphStyles", tags: BLOCK_TAGS }
100
+ ];
101
+
102
+ // The last class in the selector is the one the author would apply; the
103
+ // element qualifier immediately before it says what it applies TO.
104
+ // "article .card p.lede:hover" -> tag "p", class "lede".
105
+ var SELECTOR_RE = /^([a-zA-Z][\w-]*)?\.([\w-]+)$/;
106
+
107
+ function parseSelector(selector) {
108
+ // Only the rightmost compound matters; strip pseudo-classes/elements
109
+ // and attribute filters, which are states of a class, not new classes.
110
+ var simple = String(selector)
111
+ .replace(/\[[^\]]*\]/g, "")
112
+ .replace(/::?[\w-]+(\([^)]*\))?/g, "")
113
+ .trim();
114
+ var parts = simple.split(/[\s>+~]+/);
115
+ var last = parts[parts.length - 1] || "";
116
+ // A compound like "p.lede.wide" has no single applicable class.
117
+ if ((last.match(/\./g) || []).length !== 1) return null;
118
+ var m = last.match(SELECTOR_RE);
119
+ if (!m) return null;
120
+ return { tag: (m[1] || "").toLowerCase(), cls: m[2] };
121
+ }
122
+
123
+ function bucketsFor(tag) {
124
+ if (!tag) return ["inlineStyles", "paragraphStyles"]; // unqualified: offer both
125
+ for (var i = 0; i < TARGETS.length; i++) if (TARGETS[i].tags[tag]) return [TARGETS[i].key];
126
+ return [];
127
+ }
128
+
129
+ // ---- stylesheet walk -------------------------------------------------
130
+
131
+ // The editor's own CSS must never reach the Styles menu, and the reliable
132
+ // axis for that is ORIGIN, not class name. A first cut filtered by class
133
+ // prefix and imported 77 classes — the theme's `richtexteditor`/`colordiv`,
134
+ // and 1,788 rules of `aitoolkit.css` — because plugin CSS does not agree on
135
+ // one prefix. Where a sheet came from is unambiguous:
136
+ // - anything served out of the editor's own asset folder,
137
+ // - any <style> the editor injected (its nodes carry rte-/__rte ids),
138
+ // - any anonymous <style> inside the EDITING document, which is only ever
139
+ // editor-injected — a site's content CSS arrives there via contentCssUrl
140
+ // and therefore has an href.
141
+ function isEditorSheet(sheet, isEditingDoc) {
142
+ var href = sheet.href || "";
143
+ if (/\/richtexteditor\//i.test(href)) return true;
144
+ var node = sheet.ownerNode;
145
+ var id = (node && node.id) || "";
146
+ if (/^(rte[-_]|__rte)/i.test(id)) return true;
147
+ if (isEditingDoc && !href) return true;
148
+ return false;
149
+ }
150
+
151
+ // Options passed to editor.importStyles() win over config. The editor copies
152
+ // config at construction time, so mutating the caller's config object later
153
+ // does NOT reach this plugin — an API that quietly ignored its arguments
154
+ // would be worse than no API at all.
155
+ var activeOptions = {};
156
+ function setting(name) {
157
+ return (activeOptions && name in activeOptions) ? activeOptions[name] : config["importStyles" + name.charAt(0).toUpperCase() + name.slice(1)];
158
+ }
159
+
160
+ function wantSheet(sheet, isEditingDoc) {
161
+ if (isEditorSheet(sheet, isEditingDoc)) return false;
162
+ var want = setting("sheets");
163
+ if (!want) return true;
164
+ var href = sheet.href || "";
165
+ if (typeof want === "function") return !!want(sheet);
166
+ if (want instanceof RegExp) return want.test(href);
167
+ return href.indexOf(String(want)) >= 0;
168
+ }
169
+
170
+ function excluded(cls) {
171
+ var ex = setting("exclude");
172
+ if (!ex) return false;
173
+ if (ex instanceof RegExp) return ex.test(cls);
174
+ if (typeof ex === "function") return !!ex(cls);
175
+ return cls.indexOf(String(ex)) === 0;
176
+ }
177
+
178
+ function walkRules(rules, out) {
179
+ if (!rules) return;
180
+ for (var i = 0; i < rules.length; i++) {
181
+ var rule = rules[i];
182
+ // @media / @supports carry their own rule list.
183
+ if (rule.cssRules && !rule.selectorText) { walkRules(rule.cssRules, out); continue; }
184
+ if (!rule.selectorText) continue;
185
+ var selectors = rule.selectorText.split(",");
186
+ for (var s = 0; s < selectors.length; s++) {
187
+ var parsed = parseSelector(selectors[s]);
188
+ if (!parsed || excluded(parsed.cls)) continue;
189
+ out.push({ cls: parsed.cls, tag: parsed.tag, selector: selectors[s].trim() });
190
+ }
191
+ }
192
+ }
193
+
194
+ function documents() {
195
+ var docs = [];
196
+ if (typeof document !== "undefined") docs.push({ doc: document, editing: false });
197
+ try {
198
+ var d = editor.getDocument && editor.getDocument();
199
+ if (d && d !== document) docs.push({ doc: d, editing: true });
200
+ } catch (e) {}
201
+ return docs;
202
+ }
203
+
204
+ function collect() {
205
+ var found = [];
206
+ var unreadable = 0;
207
+ var docs = documents();
208
+ for (var d = 0; d < docs.length; d++) {
209
+ var sheets = docs[d].doc.styleSheets || [];
210
+ for (var i = 0; i < sheets.length; i++) {
211
+ var sheet = sheets[i];
212
+ if (!wantSheet(sheet, docs[d].editing)) continue;
213
+ var rules = null;
214
+ try {
215
+ // Throws SecurityError for a cross-origin sheet. This must
216
+ // not abort the scan — a single CDN font sheet would
217
+ // otherwise silently empty the whole Styles menu.
218
+ rules = sheet.cssRules || sheet.rules;
219
+ } catch (e) { unreadable++; continue; }
220
+ walkRules(rules, found);
221
+ }
222
+ }
223
+ return { classes: found, unreadableSheets: unreadable, documents: docs.length };
224
+ }
225
+
226
+ // "my-cls-large-center" -> "Large Center"; "lede" -> "Lede".
227
+ function humanize(cls) {
228
+ var text = String(cls).replace(/^(my[-_]cls[-_]|u[-_]|c[-_])/i, "").replace(/[-_]+/g, " ").trim();
229
+ if (!text) text = cls;
230
+ return text.replace(/\b\w/g, function (ch) { return ch.toUpperCase(); });
231
+ }
232
+
233
+ function alreadyListed(list, value) {
234
+ for (var i = 0; i < list.length; i++) if (list[i] && list[i][1] === value) return true;
235
+ return false;
236
+ }
237
+
238
+ // ---- apply -----------------------------------------------------------
239
+
240
+ function run(options) {
241
+ activeOptions = options || {};
242
+ var scan = collect();
243
+ var limit = Math.max(0, parseInt(setting("limit"), 10) || 0) || Infinity;
244
+ var replace = !!setting("replace");
245
+ var filter = setting("filter");
246
+
247
+ var buckets = {};
248
+ var truncated = {};
249
+ for (var t = 0; t < TARGETS.length; t++) buckets[TARGETS[t].key] = [];
250
+
251
+ var seen = {};
252
+ for (var i = 0; i < scan.classes.length; i++) {
253
+ var item = scan.classes[i];
254
+ var targets = bucketsFor(item.tag);
255
+ for (var b = 0; b < targets.length; b++) {
256
+ var key = targets[b];
257
+ var dedupeKey = key + "|" + item.cls;
258
+ if (seen[dedupeKey]) continue;
259
+
260
+ var label = humanize(item.cls);
261
+ if (typeof filter === "function") {
262
+ var verdict = filter(item.cls, item.selector, key);
263
+ if (verdict === false) continue;
264
+ if (typeof verdict === "string" && verdict) label = verdict;
265
+ }
266
+
267
+ seen[dedupeKey] = true;
268
+ if (buckets[key].length >= limit) { truncated[key] = (truncated[key] || 0) + 1; continue; }
269
+ // Two-element form = apply as a class (the three-element form is
270
+ // for inline CSS); this is what the dropdowns already expect.
271
+ buckets[key].push([label, item.cls]);
272
+ }
273
+ }
274
+
275
+ var applied = {};
276
+ for (var k = 0; k < TARGETS.length; k++) {
277
+ var name = TARGETS[k].key;
278
+ var incoming = buckets[name];
279
+ if (replace) {
280
+ config[name] = incoming;
281
+ } else {
282
+ if (!config[name]) config[name] = [];
283
+ for (var j = 0; j < incoming.length; j++) {
284
+ if (!alreadyListed(config[name], incoming[j][1])) config[name].push(incoming[j]);
285
+ }
286
+ }
287
+ applied[name] = incoming.length;
288
+ }
289
+
290
+ // A cap that hides work is a lie about coverage. Say what was dropped.
291
+ var dropped = 0;
292
+ for (var key2 in truncated) if (truncated.hasOwnProperty(key2)) dropped += truncated[key2];
293
+ if (dropped) {
294
+ try {
295
+ console.warn("[RichTextEditor] importStyles: " + dropped + " class(es) omitted — " +
296
+ "importStylesLimit is " + limit + " per dropdown. Raise the limit or narrow " +
297
+ "importStylesSheets / importStylesFilter.");
298
+ } catch (e) {}
299
+ }
300
+
301
+ imported = {
302
+ applied: applied,
303
+ truncated: truncated,
304
+ droppedCount: dropped,
305
+ unreadableSheets: scan.unreadableSheets,
306
+ documentsScanned: scan.documents,
307
+ replaced: !!replace
308
+ };
309
+ activeOptions = {};
310
+ return imported;
311
+ }
312
+ }
@@ -248,6 +248,72 @@ function RTE_Plugin_InsertCode() {
248
248
 
249
249
  obj.DoShowDialog = function () {
250
250
 
251
+ // Capture the caret BEFORE the dialog opens. The dialog focuses its own
252
+ // textarea, which clears the editor's selection - so by the time Insert
253
+ // is clicked there is no caret left to insert at, and the code block was
254
+ // appended as a new root paragraph instead of landing where the user was
255
+ // typing. Same failure mode as the 2026-05-08 image-upload fix in core.
256
+ var savedRange = null;
257
+ try {
258
+ var edoc = editor.getDocument();
259
+ var esel = edoc.defaultView.getSelection();
260
+ if (esel && esel.rangeCount) savedRange = esel.getRangeAt(0).cloneRange();
261
+ } catch (e) { savedRange = null; }
262
+
263
+ function restoreCaret() {
264
+ if (!savedRange) return false;
265
+ try {
266
+ var edoc = editor.getDocument();
267
+ var esel = edoc.defaultView.getSelection();
268
+ esel.removeAllRanges();
269
+ esel.addRange(savedRange);
270
+ return true;
271
+ } catch (e) { return false; }
272
+ }
273
+
274
+ // A <div class="dp-highlighter"> dropped at a caret INSIDE a <p> is
275
+ // invalid nesting. It looks right in the live DOM, but the moment the
276
+ // saved HTML is parsed again the browser hoists the div out and tears the
277
+ // paragraph in two -- 3 blocks become 5, so the document a customer
278
+ // reloads is not the one they saved. Split the paragraph ourselves and
279
+ // place the block between the halves, which round-trips byte-stable.
280
+ function insertCodeBlock(html) {
281
+ if (!restoreCaret()) return false;
282
+ var edoc = editor.getDocument();
283
+ var esel = edoc.defaultView.getSelection();
284
+ if (!esel || !esel.rangeCount) return false;
285
+ var range = esel.getRangeAt(0);
286
+ var n = range.startContainer, block = null;
287
+ while (n && n !== edoc.body) {
288
+ if (n.nodeType === 1 && /^(P|H[1-6]|LI|TD|TH|DIV|BLOCKQUOTE)$/.test(n.tagName)) { block = n; break; }
289
+ n = n.parentNode;
290
+ }
291
+ var holder = edoc.createElement("div");
292
+ holder.innerHTML = html;
293
+ var node = holder.firstChild;
294
+ if (!node) return false;
295
+ // DIV/LI/TD/BLOCKQUOTE may legally contain the block: insert in place.
296
+ if (!block || !/^(P|H[1-6])$/.test(block.tagName)) {
297
+ range.deleteContents();
298
+ range.insertNode(node);
299
+ return true;
300
+ }
301
+ range.deleteContents();
302
+ var tail = range.cloneRange();
303
+ tail.setEndAfter(block.lastChild || block);
304
+ var tailFrag = tail.extractContents();
305
+ block.parentNode.insertBefore(node, block.nextSibling);
306
+ var tailBlock = edoc.createElement(block.tagName);
307
+ tailBlock.appendChild(tailFrag);
308
+ if (tailBlock.textContent.replace(/^\s+|\s+$/g, "") !== "" || tailBlock.querySelector("*")) {
309
+ block.parentNode.insertBefore(tailBlock, node.nextSibling);
310
+ }
311
+ if (block.textContent.replace(/^\s+|\s+$/g, "") === "" && !block.querySelector("img,table")) {
312
+ block.parentNode.removeChild(block);
313
+ }
314
+ return true;
315
+ }
316
+
251
317
  var dialoginner = editor.createDialog(editor.getLangText("insertcode"), "rte-dialog-insertcode");
252
318
 
253
319
  var div2 = __Append(dialoginner, "div", "position:relative;text-align:center;");
@@ -300,12 +366,23 @@ function RTE_Plugin_InsertCode() {
300
366
 
301
367
  var tag = textarea.previousSibling
302
368
 
303
- var p = editor.insertRootParagraph()
304
- p.innerHTML = '<div class="dp-highlighter">' + tag.innerHTML + "</div>";
369
+ // insertHTML honours the caret; insertRootParagraph always
370
+ // appended at document level, which is why the block never
371
+ // landed at the focus position.
372
+ if (insertCodeBlock('<div class="dp-highlighter">' + tag.innerHTML + "</div>")) {
373
+ // placed at the caret, at block level
374
+ } else {
375
+ var p = editor.insertRootParagraph();
376
+ p.innerHTML = '<div class="dp-highlighter">' + tag.innerHTML + "</div>";
377
+ }
305
378
  }
306
379
  else {
307
- var p = editor.insertRootParagraph()
308
- p.innerText = textarea.value;
380
+ if (restoreCaret()) {
381
+ editor.insertText(textarea.value);
382
+ } else {
383
+ var p = editor.insertRootParagraph();
384
+ p.innerText = textarea.value;
385
+ }
309
386
  }
310
387
 
311
388
  editor.focus();