@richscripts/richtexteditor 2.4.2 → 2.5.1

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.
@@ -323,7 +323,14 @@ function RTE_Plugin_AccessibilityChecker() {
323
323
  function repairIssue(issueIndex, options) {
324
324
  if (!lastResult || !lastResult.issues || !lastResult.issues[issueIndex]) return runAudit();
325
325
  var ctor = window.RichTextEditor;
326
- if (!ctor || typeof ctor.repairAccessibilityIssue !== "function") return repairDomIssue(lastResult.issues[issueIndex], options || {});
326
+ // An issue carrying `_target` came from the DOM audit and is addressed
327
+ // by a DOM node, not by a JSON path. Handing it to the structured-content
328
+ // repairer looks like it works — no error, a document comes back — but
329
+ // that repairer cannot resolve the node and returns the input unchanged,
330
+ // so the fix silently does nothing.
331
+ var issue = lastResult.issues[issueIndex];
332
+ if (issue._target || lastResult.source === "dom") return repairDomIssue(issue, options || {});
333
+ if (!ctor || typeof ctor.repairAccessibilityIssue !== "function") return repairDomIssue(issue, options || {});
327
334
  var nextDocument = ctor.repairAccessibilityIssue(editor.getJSON(), lastResult.issues[issueIndex], options || {});
328
335
  editor.setJSON(nextDocument);
329
336
  selectedIssueIndex = issueIndex;
@@ -355,6 +362,101 @@ function RTE_Plugin_AccessibilityChecker() {
355
362
  editor.focus();
356
363
  }
357
364
 
365
+ // ---- WCAG 3.1.2 Language of Parts -----------------------------------
366
+ //
367
+ // A passage in a language other than the document's must be marked, or a
368
+ // screen reader pronounces it with the wrong phoneme set. Detecting
369
+ // "French inside English" is a language-identification problem we are not
370
+ // going to pretend to solve; detecting a different WRITING SYSTEM is
371
+ // deterministic, and it catches the cases that actually break speech
372
+ // synthesis outright (Hebrew, Arabic, Greek, Cyrillic, CJK in a Latin
373
+ // document, and the reverse). We flag only what we can be certain of —
374
+ // a checker that cries wolf gets switched off.
375
+ var SCRIPTS = [
376
+ { name: "Hebrew", lang: "he", re: /[֐-׿]/g },
377
+ { name: "Arabic", lang: "ar", re: /[؀-ۿݐ-ݿﭐ-﷿ﹰ-]/g },
378
+ { name: "Greek", lang: "el", re: /[Ͱ-Ͽἀ-῿]/g },
379
+ { name: "Cyrillic", lang: "ru", re: /[Ѐ-ӿ]/g },
380
+ { name: "Han", lang: "zh", re: /[㐀-䶿一-鿿]/g },
381
+ { name: "Kana", lang: "ja", re: /[぀-ゟ゠-ヿ]/g },
382
+ { name: "Hangul", lang: "ko", re: /[가-힯ᄀ-ᇿ]/g },
383
+ { name: "Devanagari", lang: "hi", re: /[ऀ-ॿ]/g },
384
+ { name: "Thai", lang: "th", re: /[฀-๿]/g },
385
+ { name: "Latin", lang: "en", re: /[A-Za-zÀ-ɏ]/g }
386
+ ];
387
+
388
+ // Below this, a "run" is more likely a stray glyph, a maths symbol or a
389
+ // single borrowed character than a passage worth annotating.
390
+ var MIN_SCRIPT_RUN = 4;
391
+
392
+ // Every script present in the text with enough characters to be a passage.
393
+ //
394
+ // Deliberately NOT "the dominant script": a foreign phrase is by definition
395
+ // the minority of its paragraph, so picking the most common script finds
396
+ // the document's own language and reports nothing. "A Hebrew proverb,
397
+ // שלום עליכם, appears mid-sentence" is 40 Latin characters and 9 Hebrew —
398
+ // the 9 are the entire point.
399
+ function scriptsIn(text) {
400
+ var found = [];
401
+ for (var i = 0; i < SCRIPTS.length; i++) {
402
+ var m = String(text).match(SCRIPTS[i].re);
403
+ if (m && m.length >= MIN_SCRIPT_RUN) found.push(SCRIPTS[i]);
404
+ }
405
+ return found;
406
+ }
407
+
408
+ function documentScript() {
409
+ var editable = editor.getEditable ? editor.getEditable() : null;
410
+ var code = (editable && editable.getAttribute("lang")) ||
411
+ (typeof document !== "undefined" && document.documentElement.getAttribute("lang")) || "en";
412
+ code = String(code).toLowerCase().split("-")[0];
413
+ for (var i = 0; i < SCRIPTS.length; i++) if (SCRIPTS[i].lang === code) return SCRIPTS[i];
414
+ // An unlisted language tag (nl, pt, sv…) is Latin-script; defaulting to
415
+ // Latin keeps us from flagging every English word in a Dutch document.
416
+ return SCRIPTS[SCRIPTS.length - 1];
417
+ }
418
+
419
+ function hasLangAncestor(node, editable) {
420
+ var n = (node && node.nodeType === 3) ? node.parentNode : node;
421
+ while (n && n !== editable && n.nodeType === 1) {
422
+ if (n.hasAttribute && n.hasAttribute("lang")) return true;
423
+ n = n.parentNode;
424
+ }
425
+ return false;
426
+ }
427
+
428
+ function collectUnmarkedLanguageRuns(block, editable, path, issues) {
429
+ var docScript = documentScript();
430
+ var doc = block.ownerDocument;
431
+ if (!doc || !doc.createTreeWalker) return;
432
+ var walker = doc.createTreeWalker(block, 4 /* SHOW_TEXT */, null, false);
433
+ var textNode, seen = {};
434
+ while ((textNode = walker.nextNode())) {
435
+ var value = textNode.nodeValue || "";
436
+ if (!value.replace(/\s+/g, "")) continue;
437
+ if (hasLangAncestor(textNode, editable)) continue;
438
+ var scripts = scriptsIn(value);
439
+ for (var s = 0; s < scripts.length; s++) {
440
+ var script = scripts[s];
441
+ if (script === docScript) continue;
442
+ // One issue per script per block: a paragraph with six Hebrew
443
+ // words is one thing to fix, not six.
444
+ if (seen[script.name]) continue;
445
+ seen[script.name] = true;
446
+ issues.push({
447
+ code: "language-of-parts",
448
+ severity: "warning",
449
+ message: script.name + " text is not marked with a language. Select the phrase and set its " +
450
+ "language so assistive technology pronounces it correctly (WCAG 3.1.2).",
451
+ path: path + ".lang",
452
+ _target: block,
453
+ _lang: script.lang,
454
+ _script: script.name
455
+ });
456
+ }
457
+ }
458
+ }
459
+
358
460
  function auditDomAccessibility() {
359
461
  var editable = editor.getEditable ? editor.getEditable() : null;
360
462
  var issues = [];
@@ -424,6 +526,8 @@ function RTE_Plugin_AccessibilityChecker() {
424
526
  });
425
527
  }
426
528
  }
529
+
530
+ collectUnmarkedLanguageRuns(node, editable, path, issues);
427
531
  }
428
532
 
429
533
  return { document: null, issues: issues, valid: !issues.length, source: "dom" };
@@ -449,12 +553,74 @@ function RTE_Plugin_AccessibilityChecker() {
449
553
  else if (issue.code === "table-missing-header") {
450
554
  promoteFirstTableRow(target);
451
555
  }
556
+ else if (issue.code === "language-of-parts") {
557
+ markLanguageRuns(target, options && options.lang ? options.lang : issue._lang, issue._script);
558
+ }
452
559
 
453
560
  selectedIssueIndex = 0;
454
561
  scheduleEditorChange();
455
562
  return runAudit();
456
563
  }
457
564
 
565
+ // Wrap the offending script's runs in <span lang> — the same markup
566
+ // textpartlanguage.js produces, so the two features agree on one shape and
567
+ // the author can retarget the result from the Language dropdown afterwards.
568
+ //
569
+ // Only the matching characters (plus the spaces and punctuation BETWEEN
570
+ // them) are wrapped: wrapping the whole text node would relabel the
571
+ // surrounding English as Hebrew, which is a worse lie than leaving it
572
+ // unmarked.
573
+ function markLanguageRuns(block, lang, scriptName) {
574
+ if (!block || !lang) return;
575
+ var script = null;
576
+ for (var i = 0; i < SCRIPTS.length; i++) if (SCRIPTS[i].name === scriptName) script = SCRIPTS[i];
577
+ if (!script) return;
578
+ var doc = block.ownerDocument;
579
+ var editable = editor.getEditable ? editor.getEditable() : null;
580
+ var rtl = (lang === "he" || lang === "ar" || lang === "fa" || lang === "ur");
581
+
582
+ var walker = doc.createTreeWalker(block, 4 /* SHOW_TEXT */, null, false);
583
+ var pending = [], n;
584
+ while ((n = walker.nextNode())) {
585
+ if (hasLangAncestor(n, editable)) continue;
586
+ pending.push(n);
587
+ }
588
+
589
+ for (var t = 0; t < pending.length; t++) {
590
+ var node = pending[t];
591
+ var value = node.nodeValue || "";
592
+ // Character classes are per-char; build a run matcher that also
593
+ // absorbs the neutral characters sitting inside a passage.
594
+ var runRe = new RegExp("(?:" + script.re.source + "|[\\s.,;:!?'\"()\\u2010-\\u2027\\u00AB\\u00BB\\u201C\\u201D])*" +
595
+ script.re.source +
596
+ "(?:" + script.re.source + "|[\\s.,;:!?'\"()\\u2010-\\u2027\\u00AB\\u00BB\\u201C\\u201D])*", "g");
597
+ var frag = doc.createDocumentFragment();
598
+ var last = 0, m, wrapped = false;
599
+ while ((m = runRe.exec(value))) {
600
+ var text = m[0];
601
+ // Trim the neutrals the greedy match pulled in at the edges;
602
+ // they belong to the surrounding sentence, not the phrase.
603
+ var lead = text.length - text.replace(/^[\s.,;:!?'"()‐-‧«»“”]+/, "").length;
604
+ var trail = text.length - text.replace(/[\s.,;:!?'"()‐-‧«»“”]+$/, "").length;
605
+ var start = m.index + lead;
606
+ var end = m.index + text.length - trail;
607
+ if (end - start < MIN_SCRIPT_RUN) continue;
608
+ if (start > last) frag.appendChild(doc.createTextNode(value.slice(last, start)));
609
+ var span = doc.createElement("span");
610
+ span.setAttribute("lang", lang);
611
+ if (rtl) span.setAttribute("dir", "rtl");
612
+ span.appendChild(doc.createTextNode(value.slice(start, end)));
613
+ frag.appendChild(span);
614
+ last = end;
615
+ wrapped = true;
616
+ if (runRe.lastIndex === m.index) runRe.lastIndex++; // zero-width guard
617
+ }
618
+ if (!wrapped) continue;
619
+ if (last < value.length) frag.appendChild(doc.createTextNode(value.slice(last)));
620
+ node.parentNode.replaceChild(frag, node);
621
+ }
622
+ }
623
+
458
624
  function promoteFirstTableRow(table) {
459
625
  var firstRow = table && table.rows && table.rows.length ? table.rows[0] : null;
460
626
  if (!firstRow) return;
@@ -603,6 +769,25 @@ function RTE_Plugin_AccessibilityChecker() {
603
769
  return;
604
770
  }
605
771
 
772
+ if (issue.code === "language-of-parts") {
773
+ var langRow = detail.ownerDocument.createElement("div");
774
+ langRow.className = "rte-a11y-row";
775
+ var mark = detail.ownerDocument.createElement("button");
776
+ mark.type = "button";
777
+ mark.className = "rte-a11y-button";
778
+ mark.innerText = "Mark as " + issue._script;
779
+ mark.onclick = function () { repairIssue(selectedIssueIndex, { lang: issue._lang }); };
780
+ var look = detail.ownerDocument.createElement("button");
781
+ look.type = "button";
782
+ look.className = "rte-a11y-button rte-a11y-button-secondary";
783
+ look.innerText = "Focus issue";
784
+ look.onclick = function () { focusIssue(issue); };
785
+ langRow.appendChild(mark);
786
+ langRow.appendChild(look);
787
+ detail.appendChild(langRow);
788
+ return;
789
+ }
790
+
606
791
  detail.innerHTML += '<div class="rte-a11y-detail-copy">This issue is visible here, but does not have an automatic repair action yet.</div>';
607
792
  }
608
793
 
@@ -711,7 +896,7 @@ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
711
896
 
712
897
  if (!RTE_DefaultConfig.svgCode_aiassist) {
713
898
  RTE_DefaultConfig.svgCode_aiassist = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true"><defs><linearGradient id="rte-ai-robot-shell" x1="5" y1="6" x2="19" y2="18" gradientUnits="userSpaceOnUse"><stop stop-color="#1fb6ff"/><stop offset=".52" stop-color="#2563eb"/><stop offset="1" stop-color="#7c3aed"/></linearGradient><linearGradient id="rte-ai-robot-glow" x1="8" y1="10" x2="16" y2="15" gradientUnits="userSpaceOnUse"><stop stop-color="#fef9c3"/><stop offset="1" stop-color="#fde68a"/></linearGradient></defs><path d="M12 2.4v2.4" stroke="#f59e0b" stroke-width="1.7" stroke-linecap="round"/><circle cx="12" cy="2.4" r="1" fill="#fbbf24"/><rect x="5.1" y="6.1" width="13.8" height="11.2" rx="3.4" fill="url(#rte-ai-robot-shell)" stroke="#1e40af" stroke-width="1.2"/><path d="M8.2 6.1V5.8A1.8 1.8 0 0110 4h4a1.8 1.8 0 011.8 1.8v.3" stroke="#93c5fd" stroke-width="1.2" stroke-linecap="round"/><rect x="7.7" y="9.2" width="8.6" height="4.8" rx="2.4" fill="#0f172a" opacity=".26"/><circle cx="10" cy="11.6" r="1.15" fill="url(#rte-ai-robot-glow)"/><circle cx="14" cy="11.6" r="1.15" fill="url(#rte-ai-robot-glow)"/><path d="M9.4 14.9c.8.55 1.66.83 2.6.83s1.8-.28 2.6-.83" stroke="#ffffff" stroke-width="1.45" stroke-linecap="round"/><path d="M8.1 19.2l1-2.1M15.9 19.2l-1-2.1" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round"/><path d="M18.8 6.4l.55-1.25.55 1.25 1.25.55-1.25.55-.55 1.25-.55-1.25-1.25-.55z" fill="#fef08a"/></svg>';
714
- RTE_DefaultConfig.svgCode_aiassist = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true"><defs><linearGradient id="rte-ai-bot-v4-shell" x1="4.2" y1="5.8" x2="19.8" y2="18.4" gradientUnits="userSpaceOnUse"><stop stop-color="#22d3ee"/><stop offset=".52" stop-color="#2563eb"/><stop offset="1" stop-color="#4338ca"/></linearGradient><linearGradient id="rte-ai-bot-v4-visor" x1="7.8" y1="9" x2="16.2" y2="13.8" gradientUnits="userSpaceOnUse"><stop stop-color="#f8fafc"/><stop offset="1" stop-color="#bfdbfe"/></linearGradient></defs><path d="M12 2.6v2.3" stroke="#f59e0b" stroke-width="1.7" stroke-linecap="round"/><circle cx="12" cy="2.6" r=".9" fill="#fbbf24"/><path d="M4.9 12.2h-1a1.4 1.4 0 010-2.8h1M19.1 9.4h1a1.4 1.4 0 010 2.8h-1" stroke="#2563eb" stroke-width="1.25" stroke-linecap="round"/><rect x="5.2" y="5.9" width="13.6" height="12.2" rx="4" fill="url(#rte-ai-bot-v4-shell)" stroke="#1e40af" stroke-width="1.15"/><rect x="7.6" y="8.8" width="8.8" height="5.2" rx="2.6" fill="url(#rte-ai-bot-v4-visor)" opacity=".98"/><circle cx="10.2" cy="11.4" r=".9" fill="#0f172a"/><circle cx="13.8" cy="11.4" r=".9" fill="#0f172a"/><path d="M9.5 15.3c.78.52 1.6.78 2.5.78s1.72-.26 2.5-.78" stroke="#eff6ff" stroke-width="1.35" stroke-linecap="round"/><path d="M7.7 20.2l1.15-2.15M16.3 20.2l-1.15-2.15" stroke="#2563eb" stroke-width="1.45" stroke-linecap="round"/><path d="M18.6 5.1l.45-.95.45.95.95.45-.95.45-.45.95-.45-.95-.95-.45z" fill="#fde68a"/></svg>';
899
+ RTE_DefaultConfig.svgCode_aiassist = '<svg viewBox="1.78 0.3 21.8 21.8" width="20" height="20" fill="none" aria-hidden="true"><defs><linearGradient id="rte-ai-bot-v5-shell" x1="3" y1="5" x2="21" y2="20" gradientUnits="userSpaceOnUse"><stop stop-color="#22d3ee"/><stop offset=".52" stop-color="#2563eb"/><stop offset="1" stop-color="#4338ca"/></linearGradient><linearGradient id="rte-ai-bot-v5-visor" x1="7" y1="8" x2="17" y2="15" gradientUnits="userSpaceOnUse"><stop stop-color="#f8fafc"/><stop offset="1" stop-color="#bfdbfe"/></linearGradient></defs><rect x="2.6" y="5.2" width="16.4" height="16" rx="4.8" fill="url(#rte-ai-bot-v5-shell)" stroke="#1e40af" stroke-width="1.6"/><rect x="5.9" y="9.1" width="9.8" height="6.8" rx="3.4" fill="url(#rte-ai-bot-v5-visor)"/><circle cx="8.6" cy="12.5" r="1.35" fill="#0f172a"/><circle cx="13" cy="12.5" r="1.35" fill="#0f172a"/><path d="M8.3 18.2c.9.7 1.8 1 2.5 1s1.6-.3 2.5-1" stroke="#eff6ff" stroke-width="1.7" stroke-linecap="round"/><path d="M19.6 .4l1.25 2.7 2.7 1.25-2.7 1.25-1.25 2.7-1.25-2.7-2.7-1.25 2.7-1.25z" fill="#fbbf24"/></svg>';
715
900
  }
716
901
 
717
902
  if (!RTE_DefaultConfig.svgCode_aiassist_open_dialog) {
@@ -27519,6 +27704,372 @@ function RTE_Plugin_FormattingMarks() {
27519
27704
 
27520
27705
  if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
27521
27706
 
27707
+ // 2026-08-15 Gap cursor — reach the unreachable places in a document.
27708
+ //
27709
+ // A document that ENDS with a table cannot be added to. The caret goes into the
27710
+ // last cell and stays there: ArrowDown, ArrowRight and End all leave it where it
27711
+ // was, because there is no node after the table to put it in. Same story for a
27712
+ // document that STARTS with a table, and for two adjacent tables with no
27713
+ // paragraph between them — there is no caret position in the gap, so the author
27714
+ // cannot type there at all. Verified on the running editor before writing this:
27715
+ // the caret stayed at TABLE>TBODY>TR>TD through every key.
27716
+ //
27717
+ // This is the single most-reported "the editor is broken" issue in every
27718
+ // WYSIWYG that lacks it. Froala ships it as `lineBreaker`, Tiptap as
27719
+ // `gapcursor`, and ProseMirror has it in core. We had it under no name.
27720
+ //
27721
+ // Design notes:
27722
+ // - ON DEMAND, never eager. The obvious fix — always append a trailing empty
27723
+ // paragraph — mutates every document that ends in a table, so getHTMLCode()
27724
+ // starts returning markup the author did not write and a round-trip through
27725
+ // the editor is no longer lossless. A paragraph is created only when the
27726
+ // author actually asks for one by pressing a key or clicking the gap.
27727
+ // - Clicking the blank space below the content is the gesture people already
27728
+ // have from Google Docs and Notion, and it needs no overlay DOM: a click
27729
+ // that lands on the editable ITSELF (rather than on a child) is by
27730
+ // definition a click in a gap. The child list then says which gap.
27731
+ // - Only TRAPPING blocks count. A trailing <p> is already reachable, so
27732
+ // offering to add another one after it would just spam empty paragraphs.
27733
+ // - The inserted paragraph carries a <br> filler. An empty <p> has zero
27734
+ // height in most engines, so without it the caret lands somewhere invisible
27735
+ // and the author thinks nothing happened.
27736
+ RTE_DefaultConfig.plugin_gapcursor = RTE_Plugin_GapCursor;
27737
+
27738
+ // Click the empty space below/around content to create a paragraph there.
27739
+ if (typeof RTE_DefaultConfig.gapCursorClick === "undefined") RTE_DefaultConfig.gapCursorClick = true;
27740
+ // Arrow/Home/End keys escape a trapping block at the document edge.
27741
+ if (typeof RTE_DefaultConfig.gapCursorKeys === "undefined") RTE_DefaultConfig.gapCursorKeys = true;
27742
+ // Show a thin insertion hint when hovering a gap between two trapping blocks.
27743
+ if (typeof RTE_DefaultConfig.gapCursorHint === "undefined") RTE_DefaultConfig.gapCursorHint = true;
27744
+
27745
+ function RTE_Plugin_GapCursor() {
27746
+ var obj = this;
27747
+ var config, editor;
27748
+ var boundDoc = null;
27749
+
27750
+ obj.PluginName = "GapCursor";
27751
+
27752
+ // Blocks that cannot host a caret directly beside them. A caret can always
27753
+ // be placed inside a paragraph or heading, so those are never "trapping".
27754
+ var TRAPPING = {
27755
+ TABLE: 1, HR: 1, IMG: 1, FIGURE: 1, IFRAME: 1, VIDEO: 1, AUDIO: 1,
27756
+ OBJECT: 1, EMBED: 1, CANVAS: 1, SVG: 1, PRE: 1
27757
+ };
27758
+
27759
+ obj.InitConfig = function (argconfig) { config = argconfig; };
27760
+
27761
+ obj.InitEditor = function (argeditor) {
27762
+ editor = argeditor;
27763
+
27764
+ bind();
27765
+ try { editor.attachEvent("ready", bind); } catch (e) {}
27766
+ try { editor.attachEvent("aftersethtml", bind); } catch (e) {}
27767
+ setTimeout(bind, 0);
27768
+
27769
+ // Public API.
27770
+ editor.insertParagraphAt = function (index) { return insertAt(index); };
27771
+ editor.insertParagraphAfter = function (el) { return insertAround(el, true); };
27772
+ editor.insertParagraphBefore = function (el) { return insertAround(el, false); };
27773
+ editor.getGapPositions = function () { return gaps(); };
27774
+ };
27775
+
27776
+ function getDoc() { try { return editor.getDocument(); } catch (e) { return null; } }
27777
+ function getEditable() { try { return editor.getEditable(); } catch (e) { return null; } }
27778
+
27779
+ function bind() {
27780
+ var doc = getDoc();
27781
+ var ed = getEditable();
27782
+ if (!doc || !ed || doc === boundDoc) return;
27783
+ boundDoc = doc;
27784
+ injectStyles(doc);
27785
+
27786
+ if (config.gapCursorKeys !== false) ed.addEventListener("keydown", onKeyDown, true);
27787
+ if (config.gapCursorClick !== false) ed.addEventListener("click", onClick, true);
27788
+ if (config.gapCursorHint !== false) ed.addEventListener("mousemove", onMove, true);
27789
+ }
27790
+
27791
+ function isTrapping(el) {
27792
+ return !!(el && el.nodeType === 1 && TRAPPING[el.nodeName]);
27793
+ }
27794
+
27795
+ function isHint(el) {
27796
+ return !!(el && el.nodeType === 1 && el.className === "rte-gap-hint");
27797
+ }
27798
+
27799
+ // The editable's real children, with the hover hint filtered out.
27800
+ //
27801
+ // The hint is itself a child of the editable, so any code that reasons about
27802
+ // positions via ed.children sees it as a block: it shifts every index after
27803
+ // it, and because a <div> is not "trapping" its presence as the last child
27804
+ // made the trailing gap disappear entirely — hovering below a table removed
27805
+ // the very gap the hover was advertising, and the click then did nothing.
27806
+ function blocks() {
27807
+ var ed = getEditable();
27808
+ var out = [];
27809
+ if (!ed) return out;
27810
+ for (var i = 0; i < ed.children.length; i++) {
27811
+ if (!isHint(ed.children[i])) out.push(ed.children[i]);
27812
+ }
27813
+ return out;
27814
+ }
27815
+
27816
+ // Every index where a paragraph could usefully be inserted: before a leading
27817
+ // trapping block, after a trailing one, and between two adjacent ones.
27818
+ function gaps() {
27819
+ var out = [];
27820
+ var kids = blocks();
27821
+ if (!kids.length) return out;
27822
+ if (isTrapping(kids[0])) out.push(0);
27823
+ for (var i = 0; i < kids.length - 1; i++) {
27824
+ if (isTrapping(kids[i]) && isTrapping(kids[i + 1])) out.push(i + 1);
27825
+ }
27826
+ if (isTrapping(kids[kids.length - 1])) out.push(kids.length);
27827
+ return out;
27828
+ }
27829
+
27830
+ // ---- insertion -------------------------------------------------------
27831
+
27832
+ function makeParagraph(doc) {
27833
+ var p = doc.createElement("p");
27834
+ // An empty <p> collapses to zero height, so the caret would land
27835
+ // somewhere the author cannot see.
27836
+ p.appendChild(doc.createElement("br"));
27837
+ return p;
27838
+ }
27839
+
27840
+ function insertAt(index) {
27841
+ var ed = getEditable();
27842
+ var doc = getDoc();
27843
+ if (!ed || !doc) return null;
27844
+ // Resolve against the hint-free list, then insert relative to that node.
27845
+ hideHint();
27846
+ var p = makeParagraph(doc);
27847
+ var before = blocks()[index] || null;
27848
+ ed.insertBefore(p, before);
27849
+ focusParagraph(p);
27850
+ fireChange();
27851
+ return p;
27852
+ }
27853
+
27854
+ function insertAround(el, after) {
27855
+ var ed = getEditable();
27856
+ var doc = getDoc();
27857
+ if (!ed || !doc || !el) return null;
27858
+ var p = makeParagraph(doc);
27859
+ if (after) el.parentNode.insertBefore(p, el.nextSibling);
27860
+ else el.parentNode.insertBefore(p, el);
27861
+ focusParagraph(p);
27862
+ fireChange();
27863
+ return p;
27864
+ }
27865
+
27866
+ function focusParagraph(p) {
27867
+ try {
27868
+ var doc = p.ownerDocument;
27869
+ var r = doc.createRange();
27870
+ r.setStart(p, 0);
27871
+ r.collapse(true);
27872
+ var sel = editor.getSelection();
27873
+ sel.removeAllRanges();
27874
+ sel.addRange(r);
27875
+ editor.focus();
27876
+ } catch (e) {}
27877
+ }
27878
+
27879
+ // ---- keyboard --------------------------------------------------------
27880
+
27881
+ // The top-level child of the editable that contains this node.
27882
+ function topBlockOf(node) {
27883
+ var ed = getEditable();
27884
+ var n = (node && node.nodeType === 3) ? node.parentNode : node;
27885
+ while (n && n.parentNode && n.parentNode !== ed) n = n.parentNode;
27886
+ return (n && n.parentNode === ed) ? n : null;
27887
+ }
27888
+
27889
+ function onKeyDown(e) {
27890
+ var key = e.key;
27891
+ var forward = (key === "ArrowDown" || key === "ArrowRight" || key === "End" || key === "PageDown");
27892
+ var back = (key === "ArrowUp" || key === "ArrowLeft" || key === "Home" || key === "PageUp");
27893
+ if (!forward && !back) return;
27894
+
27895
+ var ed = getEditable();
27896
+ if (!ed || !ed.children.length) return;
27897
+
27898
+ var sel = editor.getSelection();
27899
+ if (!sel || !sel.rangeCount) return;
27900
+ var block = topBlockOf(sel.getRangeAt(0).startContainer);
27901
+ if (!isTrapping(block)) return;
27902
+
27903
+ var kids = blocks();
27904
+ var index = kids.indexOf(block);
27905
+ if (index < 0) return;
27906
+
27907
+ if (forward) {
27908
+ var next = kids[index + 1];
27909
+ // Escape only where the author is genuinely stuck: nothing after the
27910
+ // block, or another trapping block immediately after it.
27911
+ if (next && !isTrapping(next)) return;
27912
+ e.preventDefault();
27913
+ e.stopPropagation();
27914
+ insertAt(index + 1);
27915
+ } else {
27916
+ var prev = kids[index - 1];
27917
+ if (prev && !isTrapping(prev)) return;
27918
+ e.preventDefault();
27919
+ e.stopPropagation();
27920
+ insertAt(index);
27921
+ }
27922
+ }
27923
+
27924
+ // ---- click in a gap --------------------------------------------------
27925
+
27926
+ // A click that lands on the editable itself, not on any child, is a click in
27927
+ // the space between or after blocks.
27928
+ function onClick(e) {
27929
+ var ed = getEditable();
27930
+ if (!ed || e.target !== ed) return;
27931
+ // Measure the gaps as they are without the hint, or the hint's own box
27932
+ // shifts the geometry the click is being resolved against.
27933
+ hideHint();
27934
+ var index = gapIndexAt(e.clientY);
27935
+ if (index === null) return;
27936
+ e.preventDefault();
27937
+ e.stopPropagation();
27938
+ insertAt(index);
27939
+ }
27940
+
27941
+ // Which gap does this viewport Y fall in? Below every block means the end.
27942
+ function gapIndexAt(clientY) {
27943
+ var kids = blocks();
27944
+ if (!kids.length) return null;
27945
+ var available = gaps();
27946
+ if (!available.length) return null;
27947
+
27948
+ for (var i = 0; i < kids.length; i++) {
27949
+ var rect = kids[i].getBoundingClientRect();
27950
+ if (clientY < rect.top) return allowed(available, i);
27951
+ if (clientY <= rect.bottom) return null; // on the block itself
27952
+ }
27953
+ return allowed(available, kids.length);
27954
+ }
27955
+
27956
+ function allowed(available, index) {
27957
+ return available.indexOf(index) >= 0 ? index : null;
27958
+ }
27959
+
27960
+ // ---- hover hint ------------------------------------------------------
27961
+
27962
+ var hint = null;
27963
+ var hintIndex = null;
27964
+
27965
+ // How close to a block boundary counts as "aiming at the gap".
27966
+ var EDGE_TOLERANCE = 10;
27967
+
27968
+ function onMove(e) {
27969
+ var ed = getEditable();
27970
+ if (!ed) return;
27971
+ if (isHint(e.target)) return; // already on the hint; leave it alone
27972
+
27973
+ // Blank space below/around the content: the click will land on the
27974
+ // editable itself and the geometry alone is enough.
27975
+ if (e.target === ed) {
27976
+ var index = gapIndexAt(e.clientY);
27977
+ if (index === null) { hideHint(); return; }
27978
+ showHint(index);
27979
+ return;
27980
+ }
27981
+
27982
+ // Two adjacent tables have ZERO pixels between them, so a click there
27983
+ // can never reach the editable — it always lands on one of the tables.
27984
+ // The gap is unreachable by mouse unless we put something in it, so
27985
+ // hovering near the boundary materialises the hint and the hint itself
27986
+ // becomes the click target.
27987
+ var near = gapNearEdge(e.clientY);
27988
+ if (near === null) { hideHint(); return; }
27989
+ showHint(near);
27990
+ }
27991
+
27992
+ // A gap whose boundary is within EDGE_TOLERANCE of this Y.
27993
+ function gapNearEdge(clientY) {
27994
+ var kids = blocks();
27995
+ var available = gaps();
27996
+ for (var g = 0; g < available.length; g++) {
27997
+ var i = available[g];
27998
+ var edge;
27999
+ if (i === 0) edge = kids[0].getBoundingClientRect().top;
28000
+ else edge = kids[i - 1].getBoundingClientRect().bottom;
28001
+ if (Math.abs(clientY - edge) <= EDGE_TOLERANCE) return i;
28002
+ }
28003
+ return null;
28004
+ }
28005
+
28006
+ function showHint(index) {
28007
+ var ed = getEditable();
28008
+ var doc = getDoc();
28009
+ if (!ed || !doc) return;
28010
+ if (!hint) {
28011
+ hint = doc.createElement("div");
28012
+ hint.className = "rte-gap-hint";
28013
+ hint.setAttribute("contenteditable", "false");
28014
+ hint.setAttribute("aria-hidden", "true");
28015
+ // The hint is the only clickable thing in a zero-height gap.
28016
+ hint.addEventListener("mousedown", function (ev) {
28017
+ ev.preventDefault();
28018
+ ev.stopPropagation();
28019
+ if (hintIndex !== null) insertAt(hintIndex);
28020
+ }, true);
28021
+ }
28022
+ var before = blocks()[index] || null;
28023
+ // Re-inserting in place would thrash the DOM on every mousemove.
28024
+ if (hint.parentNode === ed && hint.nextSibling === before) { hintIndex = index; return; }
28025
+ ed.insertBefore(hint, before);
28026
+ hintIndex = index;
28027
+ }
28028
+
28029
+ function hideHint() {
28030
+ if (hint && hint.parentNode) hint.parentNode.removeChild(hint);
28031
+ hintIndex = null;
28032
+ }
28033
+
28034
+ function fireChange() {
28035
+ // The hint is decoration; it must never be in the document when the
28036
+ // content is read.
28037
+ hideHint();
28038
+ try { if (typeof editor.updateDesign === "function") editor.updateDesign(); } catch (e) {}
28039
+ try { if (typeof editor.fireChange === "function") editor.fireChange(); } catch (e) {}
28040
+ }
28041
+
28042
+ // ---- styles ----------------------------------------------------------
28043
+
28044
+ function css() {
28045
+ // A real hit area, not just a line: in a zero-height gap between two
28046
+ // tables this element IS the click target, so it needs height.
28047
+ return ".rte-gap-hint{height:6px;margin:1px 0;position:relative;cursor:text;" +
28048
+ "pointer-events:auto;background:transparent;}" +
28049
+ ".rte-gap-hint::before{content:'';position:absolute;left:0;right:0;top:2px;" +
28050
+ "border-top:2px solid #2563eb;opacity:.65;}" +
28051
+ ".rte-gap-hint::after{content:'';position:absolute;left:-3px;top:0;width:6px;height:6px;" +
28052
+ "border-radius:50%;background:#2563eb;}";
28053
+ }
28054
+
28055
+ function injectStyles(doc) {
28056
+ if (!doc) return;
28057
+ var text = css();
28058
+ var existing = doc.getElementById("rte-gapcursor-styles");
28059
+ if (existing) {
28060
+ if (existing.getAttribute("data-css") === text) return;
28061
+ existing.parentNode && existing.parentNode.removeChild(existing);
28062
+ }
28063
+ var st = doc.createElement("style");
28064
+ st.id = "rte-gapcursor-styles";
28065
+ st.setAttribute("data-css", text);
28066
+ st.appendChild(doc.createTextNode(text));
28067
+ (doc.head || doc.getElementsByTagName("head")[0] || doc.documentElement).appendChild(st);
28068
+ }
28069
+ }
28070
+
28071
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
28072
+
27522
28073
  // 2026-06-06 AI ghost-text autocomplete. GitHub-Copilot / Notion-AI-style inline
27523
28074
  // completion: after you pause typing, a greyed suggestion appears after the caret
27524
28075
  // continuing your sentence; press Tab to accept it, Esc (or just keep typing) to
@@ -28123,7 +28674,22 @@ function RTE_Plugin_ImageEditor() {
28123
28674
  return tag;
28124
28675
  }
28125
28676
 
28126
- function dataURLToBlob(dataurl) {
28677
+ var MIME_EXTENSION = {
28678
+ "image/png": ".png",
28679
+ "image/jpeg": ".jpg",
28680
+ "image/jpg": ".jpg",
28681
+ "image/webp": ".webp",
28682
+ "image/gif": ".gif",
28683
+ "image/bmp": ".bmp",
28684
+ "image/avif": ".avif"
28685
+ };
28686
+
28687
+ // Returns a File, not a bare Blob. Upload handlers identify the file by its
28688
+ // .name - a Blob has none, so the edited image was posted with an undefined
28689
+ // name and every server rejected it ("Invalid file extension"), surfacing as
28690
+ // "Upload failed. Check your connection and try again." when the network was
28691
+ // perfectly fine.
28692
+ function dataURLToBlob(dataurl, filename) {
28127
28693
  var arr = dataurl.split(',');
28128
28694
  var mime = arr[0].match(/:(.*?);/)[1];
28129
28695
  var bstr = atob(arr[1]);
@@ -28132,7 +28698,16 @@ function RTE_Plugin_ImageEditor() {
28132
28698
  while (n--) {
28133
28699
  u8arr[n] = bstr.charCodeAt(n);
28134
28700
  }
28135
- return new Blob([u8arr], { type: mime });
28701
+ var name = filename || ("image-" + new Date().getTime() + (MIME_EXTENSION[mime] || ".png"));
28702
+ try {
28703
+ return new File([u8arr], name, { type: mime });
28704
+ } catch (e) {
28705
+ // Very old browsers have no File constructor. Keep the name reachable
28706
+ // so a handler reading file.name still works.
28707
+ var blob = new Blob([u8arr], { type: mime });
28708
+ blob.name = name;
28709
+ return blob;
28710
+ }
28136
28711
  }
28137
28712
 
28138
28713
  obj.DoImageEditor = function () {
@@ -28239,8 +28814,11 @@ function RTE_Plugin_ImageEditor() {
28239
28814
  }
28240
28815
  savebtn.disabled = false;
28241
28816
  savebtn.innerText = "Save";
28817
+ // Show what the server actually said. Blaming the connection sent
28818
+ // people chasing a network problem when the real answer was a
28819
+ // rejected file type or a size limit.
28242
28820
  status.innerText = error
28243
- ? "Upload failed. Check your connection and try again."
28821
+ ? ("Upload failed: " + error)
28244
28822
  : "The upload did not return a file URL. Please try again.";
28245
28823
  });
28246
28824
 
@@ -28268,6 +28846,319 @@ function RTE_Plugin_ImageEditor() {
28268
28846
 
28269
28847
 
28270
28848
 
28849
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
28850
+
28851
+ // 2026-08-15 Import styles from the page's own stylesheets.
28852
+ //
28853
+ // The four Quick Styles dropdowns (inlineStyles / paragraphStyles / imageStyles
28854
+ // / linkStyles) have always been hand-configured, and the shipped defaults are
28855
+ // placeholders — "my-cls-mark", "my-cls-quote" — that mean nothing in a real
28856
+ // integration. Every team wiring this editor into an existing design system
28857
+ // therefore retypes their own class list into config before the dropdowns are
28858
+ // worth opening. TinyMCE removes that step with `importcss`; this is our
28859
+ // equivalent.
28860
+ //
28861
+ // Design notes:
28862
+ // - OPT-IN (`importStyles = true`). Silently rewriting the Styles menu out of
28863
+ // whatever CSS happens to be on the page would be a surprising default, and
28864
+ // on a utility-CSS site it would be a catastrophic one.
28865
+ // - The ELEMENT QUALIFIER decides the bucket: `img.hero` is an image style,
28866
+ // `a.cta` a link style, `p.lede` a paragraph style. An unqualified `.foo`
28867
+ // cannot be placed, so it goes in both the inline and paragraph menus —
28868
+ // the author picks by what they have selected.
28869
+ // - Cross-origin stylesheets throw a SecurityError on `.cssRules`, and it is
28870
+ // thrown on ACCESS, not returned as null. One un-caught sheet from a CDN
28871
+ // kills the whole scan, which is the classic way this feature fails.
28872
+ // - `@media` / `@supports` blocks hold their own `cssRules`; a design system's
28873
+ // responsive variants live in there, so the walk recurses.
28874
+ // - There is a HARD CAP. A Tailwind or Bootstrap page exposes thousands of
28875
+ // atomic classes, and a dropdown with 4,000 entries is not a feature. When
28876
+ // the cap truncates, it says so via console + `getImportedStyles().truncated`
28877
+ // rather than quietly showing the first hundred as if that were all of them.
28878
+ RTE_DefaultConfig.plugin_importstyles = RTE_Plugin_ImportStyles;
28879
+
28880
+ // Master switch.
28881
+ if (typeof RTE_DefaultConfig.importStyles === "undefined") RTE_DefaultConfig.importStyles = false;
28882
+
28883
+ // Which stylesheets to read. null = all readable ones. A string/RegExp is
28884
+ // matched against the sheet's href ("site.css", /theme/), a function gets the
28885
+ // CSSStyleSheet.
28886
+ if (typeof RTE_DefaultConfig.importStylesSheets === "undefined") RTE_DefaultConfig.importStylesSheets = null;
28887
+
28888
+ // Classes never imported: the editor's own chrome, and the other editors'
28889
+ // (a page migrating from CKEditor/TinyMCE still has their CSS loaded).
28890
+ if (typeof RTE_DefaultConfig.importStylesExclude === "undefined") {
28891
+ RTE_DefaultConfig.importStylesExclude = /^(rte[-_]|ck[-_]|cke[-_]|mce[-_]|tox[-_]|js[-_]|is[-_]|has[-_])/i;
28892
+ }
28893
+
28894
+ // Per-class predicate. Return false to skip, a string to override the label.
28895
+ // function (className, selectorText, bucket) {}
28896
+ if (typeof RTE_DefaultConfig.importStylesFilter === "undefined") RTE_DefaultConfig.importStylesFilter = null;
28897
+
28898
+ // Replace the configured styles instead of appending to them.
28899
+ if (typeof RTE_DefaultConfig.importStylesReplace === "undefined") RTE_DefaultConfig.importStylesReplace = false;
28900
+
28901
+ // Ceiling per dropdown.
28902
+ if (typeof RTE_DefaultConfig.importStylesLimit === "undefined") RTE_DefaultConfig.importStylesLimit = 100;
28903
+
28904
+ function RTE_Plugin_ImportStyles() {
28905
+ var obj = this;
28906
+ var config, editor;
28907
+ var imported = null;
28908
+ var scannedDocs = [];
28909
+
28910
+ obj.PluginName = "ImportStyles";
28911
+
28912
+ obj.InitConfig = function (argconfig) { config = argconfig; };
28913
+
28914
+ obj.InitEditor = function (argeditor) {
28915
+ editor = argeditor;
28916
+
28917
+ // Public API — usable even with importStyles off, so an integrator can
28918
+ // inspect what WOULD be imported before switching it on.
28919
+ editor.importStyles = function (options) { return run(options || {}); };
28920
+ editor.getImportedStyles = function () { return imported; };
28921
+ editor.scanStylesheetClasses = function (options) {
28922
+ activeOptions = options || {};
28923
+ try { return collect(); } finally { activeOptions = {}; }
28924
+ };
28925
+
28926
+ if (!config.importStyles) return;
28927
+
28928
+ // The host document's sheets are ready now; the editing document's are
28929
+ // not, so scan again once it exists. The dropdown panels read config
28930
+ // when they OPEN, so a later scan still lands.
28931
+ run({});
28932
+ try { editor.attachEvent("ready", function () { run({}); }); } catch (e) {}
28933
+ setTimeout(function () { run({}); }, 0);
28934
+ };
28935
+
28936
+ // ---- buckets ---------------------------------------------------------
28937
+
28938
+ var IMAGE_TAGS = { img: 1, figure: 1, picture: 1 };
28939
+ var LINK_TAGS = { a: 1 };
28940
+ 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 };
28941
+ 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 };
28942
+
28943
+ var TARGETS = [
28944
+ { key: "imageStyles", tags: IMAGE_TAGS },
28945
+ { key: "linkStyles", tags: LINK_TAGS },
28946
+ { key: "inlineStyles", tags: INLINE_TAGS },
28947
+ { key: "paragraphStyles", tags: BLOCK_TAGS }
28948
+ ];
28949
+
28950
+ // The last class in the selector is the one the author would apply; the
28951
+ // element qualifier immediately before it says what it applies TO.
28952
+ // "article .card p.lede:hover" -> tag "p", class "lede".
28953
+ var SELECTOR_RE = /^([a-zA-Z][\w-]*)?\.([\w-]+)$/;
28954
+
28955
+ function parseSelector(selector) {
28956
+ // Only the rightmost compound matters; strip pseudo-classes/elements
28957
+ // and attribute filters, which are states of a class, not new classes.
28958
+ var simple = String(selector)
28959
+ .replace(/\[[^\]]*\]/g, "")
28960
+ .replace(/::?[\w-]+(\([^)]*\))?/g, "")
28961
+ .trim();
28962
+ var parts = simple.split(/[\s>+~]+/);
28963
+ var last = parts[parts.length - 1] || "";
28964
+ // A compound like "p.lede.wide" has no single applicable class.
28965
+ if ((last.match(/\./g) || []).length !== 1) return null;
28966
+ var m = last.match(SELECTOR_RE);
28967
+ if (!m) return null;
28968
+ return { tag: (m[1] || "").toLowerCase(), cls: m[2] };
28969
+ }
28970
+
28971
+ function bucketsFor(tag) {
28972
+ if (!tag) return ["inlineStyles", "paragraphStyles"]; // unqualified: offer both
28973
+ for (var i = 0; i < TARGETS.length; i++) if (TARGETS[i].tags[tag]) return [TARGETS[i].key];
28974
+ return [];
28975
+ }
28976
+
28977
+ // ---- stylesheet walk -------------------------------------------------
28978
+
28979
+ // The editor's own CSS must never reach the Styles menu, and the reliable
28980
+ // axis for that is ORIGIN, not class name. A first cut filtered by class
28981
+ // prefix and imported 77 classes — the theme's `richtexteditor`/`colordiv`,
28982
+ // and 1,788 rules of `aitoolkit.css` — because plugin CSS does not agree on
28983
+ // one prefix. Where a sheet came from is unambiguous:
28984
+ // - anything served out of the editor's own asset folder,
28985
+ // - any <style> the editor injected (its nodes carry rte-/__rte ids),
28986
+ // - any anonymous <style> inside the EDITING document, which is only ever
28987
+ // editor-injected — a site's content CSS arrives there via contentCssUrl
28988
+ // and therefore has an href.
28989
+ function isEditorSheet(sheet, isEditingDoc) {
28990
+ var href = sheet.href || "";
28991
+ if (/\/richtexteditor\//i.test(href)) return true;
28992
+ var node = sheet.ownerNode;
28993
+ var id = (node && node.id) || "";
28994
+ if (/^(rte[-_]|__rte)/i.test(id)) return true;
28995
+ if (isEditingDoc && !href) return true;
28996
+ return false;
28997
+ }
28998
+
28999
+ // Options passed to editor.importStyles() win over config. The editor copies
29000
+ // config at construction time, so mutating the caller's config object later
29001
+ // does NOT reach this plugin — an API that quietly ignored its arguments
29002
+ // would be worse than no API at all.
29003
+ var activeOptions = {};
29004
+ function setting(name) {
29005
+ return (activeOptions && name in activeOptions) ? activeOptions[name] : config["importStyles" + name.charAt(0).toUpperCase() + name.slice(1)];
29006
+ }
29007
+
29008
+ function wantSheet(sheet, isEditingDoc) {
29009
+ if (isEditorSheet(sheet, isEditingDoc)) return false;
29010
+ var want = setting("sheets");
29011
+ if (!want) return true;
29012
+ var href = sheet.href || "";
29013
+ if (typeof want === "function") return !!want(sheet);
29014
+ if (want instanceof RegExp) return want.test(href);
29015
+ return href.indexOf(String(want)) >= 0;
29016
+ }
29017
+
29018
+ function excluded(cls) {
29019
+ var ex = setting("exclude");
29020
+ if (!ex) return false;
29021
+ if (ex instanceof RegExp) return ex.test(cls);
29022
+ if (typeof ex === "function") return !!ex(cls);
29023
+ return cls.indexOf(String(ex)) === 0;
29024
+ }
29025
+
29026
+ function walkRules(rules, out) {
29027
+ if (!rules) return;
29028
+ for (var i = 0; i < rules.length; i++) {
29029
+ var rule = rules[i];
29030
+ // @media / @supports carry their own rule list.
29031
+ if (rule.cssRules && !rule.selectorText) { walkRules(rule.cssRules, out); continue; }
29032
+ if (!rule.selectorText) continue;
29033
+ var selectors = rule.selectorText.split(",");
29034
+ for (var s = 0; s < selectors.length; s++) {
29035
+ var parsed = parseSelector(selectors[s]);
29036
+ if (!parsed || excluded(parsed.cls)) continue;
29037
+ out.push({ cls: parsed.cls, tag: parsed.tag, selector: selectors[s].trim() });
29038
+ }
29039
+ }
29040
+ }
29041
+
29042
+ function documents() {
29043
+ var docs = [];
29044
+ if (typeof document !== "undefined") docs.push({ doc: document, editing: false });
29045
+ try {
29046
+ var d = editor.getDocument && editor.getDocument();
29047
+ if (d && d !== document) docs.push({ doc: d, editing: true });
29048
+ } catch (e) {}
29049
+ return docs;
29050
+ }
29051
+
29052
+ function collect() {
29053
+ var found = [];
29054
+ var unreadable = 0;
29055
+ var docs = documents();
29056
+ for (var d = 0; d < docs.length; d++) {
29057
+ var sheets = docs[d].doc.styleSheets || [];
29058
+ for (var i = 0; i < sheets.length; i++) {
29059
+ var sheet = sheets[i];
29060
+ if (!wantSheet(sheet, docs[d].editing)) continue;
29061
+ var rules = null;
29062
+ try {
29063
+ // Throws SecurityError for a cross-origin sheet. This must
29064
+ // not abort the scan — a single CDN font sheet would
29065
+ // otherwise silently empty the whole Styles menu.
29066
+ rules = sheet.cssRules || sheet.rules;
29067
+ } catch (e) { unreadable++; continue; }
29068
+ walkRules(rules, found);
29069
+ }
29070
+ }
29071
+ return { classes: found, unreadableSheets: unreadable, documents: docs.length };
29072
+ }
29073
+
29074
+ // "my-cls-large-center" -> "Large Center"; "lede" -> "Lede".
29075
+ function humanize(cls) {
29076
+ var text = String(cls).replace(/^(my[-_]cls[-_]|u[-_]|c[-_])/i, "").replace(/[-_]+/g, " ").trim();
29077
+ if (!text) text = cls;
29078
+ return text.replace(/\b\w/g, function (ch) { return ch.toUpperCase(); });
29079
+ }
29080
+
29081
+ function alreadyListed(list, value) {
29082
+ for (var i = 0; i < list.length; i++) if (list[i] && list[i][1] === value) return true;
29083
+ return false;
29084
+ }
29085
+
29086
+ // ---- apply -----------------------------------------------------------
29087
+
29088
+ function run(options) {
29089
+ activeOptions = options || {};
29090
+ var scan = collect();
29091
+ var limit = Math.max(0, parseInt(setting("limit"), 10) || 0) || Infinity;
29092
+ var replace = !!setting("replace");
29093
+ var filter = setting("filter");
29094
+
29095
+ var buckets = {};
29096
+ var truncated = {};
29097
+ for (var t = 0; t < TARGETS.length; t++) buckets[TARGETS[t].key] = [];
29098
+
29099
+ var seen = {};
29100
+ for (var i = 0; i < scan.classes.length; i++) {
29101
+ var item = scan.classes[i];
29102
+ var targets = bucketsFor(item.tag);
29103
+ for (var b = 0; b < targets.length; b++) {
29104
+ var key = targets[b];
29105
+ var dedupeKey = key + "|" + item.cls;
29106
+ if (seen[dedupeKey]) continue;
29107
+
29108
+ var label = humanize(item.cls);
29109
+ if (typeof filter === "function") {
29110
+ var verdict = filter(item.cls, item.selector, key);
29111
+ if (verdict === false) continue;
29112
+ if (typeof verdict === "string" && verdict) label = verdict;
29113
+ }
29114
+
29115
+ seen[dedupeKey] = true;
29116
+ if (buckets[key].length >= limit) { truncated[key] = (truncated[key] || 0) + 1; continue; }
29117
+ // Two-element form = apply as a class (the three-element form is
29118
+ // for inline CSS); this is what the dropdowns already expect.
29119
+ buckets[key].push([label, item.cls]);
29120
+ }
29121
+ }
29122
+
29123
+ var applied = {};
29124
+ for (var k = 0; k < TARGETS.length; k++) {
29125
+ var name = TARGETS[k].key;
29126
+ var incoming = buckets[name];
29127
+ if (replace) {
29128
+ config[name] = incoming;
29129
+ } else {
29130
+ if (!config[name]) config[name] = [];
29131
+ for (var j = 0; j < incoming.length; j++) {
29132
+ if (!alreadyListed(config[name], incoming[j][1])) config[name].push(incoming[j]);
29133
+ }
29134
+ }
29135
+ applied[name] = incoming.length;
29136
+ }
29137
+
29138
+ // A cap that hides work is a lie about coverage. Say what was dropped.
29139
+ var dropped = 0;
29140
+ for (var key2 in truncated) if (truncated.hasOwnProperty(key2)) dropped += truncated[key2];
29141
+ if (dropped) {
29142
+ try {
29143
+ console.warn("[RichTextEditor] importStyles: " + dropped + " class(es) omitted — " +
29144
+ "importStylesLimit is " + limit + " per dropdown. Raise the limit or narrow " +
29145
+ "importStylesSheets / importStylesFilter.");
29146
+ } catch (e) {}
29147
+ }
29148
+
29149
+ imported = {
29150
+ applied: applied,
29151
+ truncated: truncated,
29152
+ droppedCount: dropped,
29153
+ unreadableSheets: scan.unreadableSheets,
29154
+ documentsScanned: scan.documents,
29155
+ replaced: !!replace
29156
+ };
29157
+ activeOptions = {};
29158
+ return imported;
29159
+ }
29160
+ }
29161
+
28271
29162
 
28272
29163
  if (!RTE_DefaultConfig.svgCode_insertcode) {
28273
29164
  RTE_DefaultConfig.svgCode_insertcode = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 7 3 12 8 17"/><polyline points="16 7 21 12 16 17"/><line x1="14" y1="5" x2="10" y2="19"/></svg>';
@@ -28518,6 +29409,72 @@ function RTE_Plugin_InsertCode() {
28518
29409
 
28519
29410
  obj.DoShowDialog = function () {
28520
29411
 
29412
+ // Capture the caret BEFORE the dialog opens. The dialog focuses its own
29413
+ // textarea, which clears the editor's selection - so by the time Insert
29414
+ // is clicked there is no caret left to insert at, and the code block was
29415
+ // appended as a new root paragraph instead of landing where the user was
29416
+ // typing. Same failure mode as the 2026-05-08 image-upload fix in core.
29417
+ var savedRange = null;
29418
+ try {
29419
+ var edoc = editor.getDocument();
29420
+ var esel = edoc.defaultView.getSelection();
29421
+ if (esel && esel.rangeCount) savedRange = esel.getRangeAt(0).cloneRange();
29422
+ } catch (e) { savedRange = null; }
29423
+
29424
+ function restoreCaret() {
29425
+ if (!savedRange) return false;
29426
+ try {
29427
+ var edoc = editor.getDocument();
29428
+ var esel = edoc.defaultView.getSelection();
29429
+ esel.removeAllRanges();
29430
+ esel.addRange(savedRange);
29431
+ return true;
29432
+ } catch (e) { return false; }
29433
+ }
29434
+
29435
+ // A <div class="dp-highlighter"> dropped at a caret INSIDE a <p> is
29436
+ // invalid nesting. It looks right in the live DOM, but the moment the
29437
+ // saved HTML is parsed again the browser hoists the div out and tears the
29438
+ // paragraph in two -- 3 blocks become 5, so the document a customer
29439
+ // reloads is not the one they saved. Split the paragraph ourselves and
29440
+ // place the block between the halves, which round-trips byte-stable.
29441
+ function insertCodeBlock(html) {
29442
+ if (!restoreCaret()) return false;
29443
+ var edoc = editor.getDocument();
29444
+ var esel = edoc.defaultView.getSelection();
29445
+ if (!esel || !esel.rangeCount) return false;
29446
+ var range = esel.getRangeAt(0);
29447
+ var n = range.startContainer, block = null;
29448
+ while (n && n !== edoc.body) {
29449
+ if (n.nodeType === 1 && /^(P|H[1-6]|LI|TD|TH|DIV|BLOCKQUOTE)$/.test(n.tagName)) { block = n; break; }
29450
+ n = n.parentNode;
29451
+ }
29452
+ var holder = edoc.createElement("div");
29453
+ holder.innerHTML = html;
29454
+ var node = holder.firstChild;
29455
+ if (!node) return false;
29456
+ // DIV/LI/TD/BLOCKQUOTE may legally contain the block: insert in place.
29457
+ if (!block || !/^(P|H[1-6])$/.test(block.tagName)) {
29458
+ range.deleteContents();
29459
+ range.insertNode(node);
29460
+ return true;
29461
+ }
29462
+ range.deleteContents();
29463
+ var tail = range.cloneRange();
29464
+ tail.setEndAfter(block.lastChild || block);
29465
+ var tailFrag = tail.extractContents();
29466
+ block.parentNode.insertBefore(node, block.nextSibling);
29467
+ var tailBlock = edoc.createElement(block.tagName);
29468
+ tailBlock.appendChild(tailFrag);
29469
+ if (tailBlock.textContent.replace(/^\s+|\s+$/g, "") !== "" || tailBlock.querySelector("*")) {
29470
+ block.parentNode.insertBefore(tailBlock, node.nextSibling);
29471
+ }
29472
+ if (block.textContent.replace(/^\s+|\s+$/g, "") === "" && !block.querySelector("img,table")) {
29473
+ block.parentNode.removeChild(block);
29474
+ }
29475
+ return true;
29476
+ }
29477
+
28521
29478
  var dialoginner = editor.createDialog(editor.getLangText("insertcode"), "rte-dialog-insertcode");
28522
29479
 
28523
29480
  var div2 = __Append(dialoginner, "div", "position:relative;text-align:center;");
@@ -28570,12 +29527,23 @@ function RTE_Plugin_InsertCode() {
28570
29527
 
28571
29528
  var tag = textarea.previousSibling
28572
29529
 
28573
- var p = editor.insertRootParagraph()
28574
- p.innerHTML = '<div class="dp-highlighter">' + tag.innerHTML + "</div>";
29530
+ // insertHTML honours the caret; insertRootParagraph always
29531
+ // appended at document level, which is why the block never
29532
+ // landed at the focus position.
29533
+ if (insertCodeBlock('<div class="dp-highlighter">' + tag.innerHTML + "</div>")) {
29534
+ // placed at the caret, at block level
29535
+ } else {
29536
+ var p = editor.insertRootParagraph();
29537
+ p.innerHTML = '<div class="dp-highlighter">' + tag.innerHTML + "</div>";
29538
+ }
28575
29539
  }
28576
29540
  else {
28577
- var p = editor.insertRootParagraph()
28578
- p.innerText = textarea.value;
29541
+ if (restoreCaret()) {
29542
+ editor.insertText(textarea.value);
29543
+ } else {
29544
+ var p = editor.insertRootParagraph();
29545
+ p.innerText = textarea.value;
29546
+ }
28579
29547
  }
28580
29548
 
28581
29549
  editor.focus();
@@ -28588,235 +29556,308 @@ function RTE_Plugin_InsertCode() {
28588
29556
 
28589
29557
 
28590
29558
 
28591
-
28592
- RTE_DefaultConfig.plugin_insertemoji = RTE_Plugin_InsertEmoji;
28593
-
28594
- function RTE_Plugin_InsertEmoji() {
28595
-
28596
- function CharToHTMLCode(ch) {
28597
- if (ch.length > 2)
28598
- return ch;
28599
-
28600
- if (ch.length == 1 || ch.charCodeAt(1) == 0xfe0f)
28601
- return "&#" + ch.charCodeAt(0) + ";"
28602
-
28603
- if (ch.charCodeAt(1) == 0xfe0f) {
28604
- return "&#" + ch.charCodeAt(0) + ";"
28605
- }
28606
-
28607
- var offset = ch.charCodeAt(0) - 0xd83c;
28608
- if (offset < 0 && offset > 3)//not support
28609
- return ch;
28610
-
28611
- var second = ch.charCodeAt(1) - 0xdc04;
28612
- var f = offset * 0x400 + second + 0x1f004;
28613
- return "&#x" + f.toString(16) + ";"
28614
- }
28615
-
28616
-
28617
- var groupnames = ["smileys", "people", "animals", "food", "travel", "activities", "objects", "symbols"]
28618
- var emojistrs = ["😀#grinning face|😃#grinning face with big eyes|😄#grinning face with smiling eyes|😁#beaming face with smiling eyes|😆#grinning squinting face|😅#grinning face with sweat|🤣#rolling on the floor laughing|😂#face with tears of joy|🙂#slightly smiling face|🙃#upside-down face|😉#winking face|😊#smiling face with smiling eyes|😇#smiling face with halo|😍#smiling face with heart-eyes|🤩#star-struck|😘#face blowing a kiss|😗#kissing face|😚#kissing face with closed eyes|😙#kissing face with smiling eyes|😋#face savoring food|😛#face with tongue|😜#winking face with tongue|🤪#zany face|😝#squinting face with tongue|🤑#money-mouth face|🤗#hugging face|🤭#face with hand over mouth|🤫#shushing face|🤔#thinking face|🤐#zipper-mouth face|🤨#face with raised eyebrow|😐#neutral face|😑#expressionless face|😶#face without mouth|😏#smirking face|😒#unamused face|🙄#face with rolling eyes|😬#grimacing face|🤥#lying face|😌#relieved face|😔#pensive face|😪#sleepy face|🤤#drooling face|😴#sleeping face|😷#face with medical mask|🤒#face with thermometer|🤕#face with head-bandage|🤢#nauseated face|🤮#face vomiting|🤧#sneezing face|😵#dizzy face|🤯#exploding head|🤠#cowboy hat face|😎#smiling face with sunglasses|🤓#nerd face|🧐#face with monocle|😕#confused face|😟#worried face|🙁#slightly frowning face|☹️#frowning face|😮#face with open mouth|😯#hushed face|😲#astonished face|😳#flushed face|😦#frowning face with open mouth|😧#anguished face|😨#fearful face|😰#anxious face with sweat|😥#sad but relieved face|😢#crying face|😭#loudly crying face|😱#face screaming in fear|😖#confounded face|😣#persevering face|😞#disappointed face|😓#downcast face with sweat|😩#weary face|😫#tired face|😤#face with steam from nose|😡#pouting face|😠#angry face|🤬#face with symbols on mouth|😈#smiling face with horns|👿#angry face with horns|💀#skull|☠️#skull and crossbones|💩#pile of poo|🤡#clown face|👹#ogre|👺#goblin|👻#ghost|👽#alien|👾#alien monster|🤖#robot|😺#grinning cat|😸#grinning cat with smiling eyes|😹#cat with tears of joy|😻#smiling cat with heart-eyes|😼#cat with wry smile|😽#kissing cat|🙀#weary cat|😿#crying cat|😾#pouting cat|🙈#see-no-evil monkey|🙉#hear-no-evil monkey|🙊#speak-no-evil monkey|💋#kiss mark|💌#love letter|💘#heart with arrow|💝#heart with ribbon|💖#sparkling heart|💗#growing heart|💓#beating heart|💞#revolving hearts|💕#two hearts|💟#heart decoration|❣️#heart exclamation|💔#broken heart|❤️#red heart|🧡#orange heart|💛#yellow heart|💚#green heart|💙#blue heart|💜#purple heart|🖤#black heart|💯#hundred points|💢#anger symbol|💥#collision|💫#dizzy|💦#sweat droplets|💨#dashing away|💣#bomb|💬#speech balloon|💭#thought balloon|💤#zzz", "👋#waving hand|🤚#raised back of hand|✋#raised hand|🖖#vulcan salute|👌#OK hand|✌️#victory hand|🤞#crossed fingers|🤟#love-you gesture|🤘#sign of the horns|🤙#call me hand|👈#backhand index pointing left|👉#backhand index pointing right|👆#backhand index pointing up|🖕#middle finger|👇#backhand index pointing down|☝️#index pointing up|👍#thumbs up|👎#thumbs down|✊#raised fist|👊#oncoming fist|🤛#left-facing fist|🤜#right-facing fist|👏#clapping hands|🙌#raising hands|👐#open hands|🤲#palms up together|🤝#handshake|🙏#folded hands|✍️#writing hand|💅#nail polish|🤳#selfie|💪#flexed biceps|👂#ear|👃#nose|🧠#brain|👀#eyes|👅#tongue|👄#mouth|👶#baby|🧒#child|👦#boy|👧#girl|🧑#person|👱#person with blond hair|👨#man|🧔#man with beard|👩#woman|🧓#older person|👴#old man|👵#old woman|🙍#person frowning|🙎#person pouting|🙅#person gesturing NO|🙆#person gesturing OK|💁#person tipping hand|🙋#person raising hand|🙇#person bowing|🤦#person facepalming|🤷#person shrugging|👮#police officer|💂#guard|👷#construction worker|🤴#prince|👸#princess|👳#person wearing turban|👲#person with skullcap|🧕#woman with headscarf|🤵#person in tuxedo|👰#person with veil|🤰#pregnant woman|🤱#breast-feeding|👼#baby angel|🎅#Santa Claus|🤶#Mrs. Claus|🧙#mage|🧚#fairy|🧛#vampire|🧜#merperson|🧝#elf|🧞#genie|🧟#zombie|💆#person getting massage|💇#person getting haircut|🚶#person walking|🏃#person running|💃#woman dancing|🕺#man dancing|👯#people with bunny ears|🧖#person in steamy room|🧗#person climbing|🤺#person fencing|🏇#horse racing|⛷️#skier|🏂#snowboarder|🏄#person surfing|🚣#person rowing boat|🏊#person swimming|⛹️#person bouncing ball|🚴#person biking|🚵#person mountain biking|🤸#person cartwheeling|🤼#people wrestling|🤽#person playing water polo|🤾#person playing handball|🤹#person juggling|🧘#person in lotus position|🛀#person taking bath|🛌#person in bed|👭#women holding hands|👫#woman and man holding hands|👬#men holding hands|💏#kiss|💑#couple with heart|👪#family|👤#bust in silhouette|👥#busts in silhouette|👣#footprints", "🐵#monkey face|🐒#monkey|🦍#gorilla|🐶#dog face|🐕#dog|🐩#poodle|🐺#wolf|🦊#fox|🐱#cat face|🐈#cat|🦁#lion|🐯#tiger face|🐅#tiger|🐆#leopard|🐴#horse face|🐎#horse|🦄#unicorn|🦓#zebra|🦌#deer|🐮#cow face|🐂#ox|🐃#water buffalo|🐄#cow|🐷#pig face|🐖#pig|🐗#boar|🐽#pig nose|🐏#ram|🐑#ewe|🐐#goat|🐪#camel|🐫#two-hump camel|🦒#giraffe|🐘#elephant|🦏#rhinoceros|🐭#mouse face|🐁#mouse|🐀#rat|🐹#hamster|🐰#rabbit face|🐇#rabbit|🦔#hedgehog|🦇#bat|🐻#bear|🐨#koala|🐼#panda|🐾#paw prints|🦃#turkey|🐔#chicken|🐓#rooster|🐣#hatching chick|🐤#baby chick|🐥#front-facing baby chick|🐦#bird|🐧#penguin|🦅#eagle|🦆#duck|🦉#owl|🐸#frog|🐊#crocodile|🐢#turtle|🦎#lizard|🐍#snake|🐲#dragon face|🐉#dragon|🦕#sauropod|🦖#T-Rex|🐳#spouting whale|🐋#whale|🐬#dolphin|🐟#fish|🐠#tropical fish|🐡#blowfish|🦈#shark|🐙#octopus|🐚#spiral shell|🐌#snail|🦋#butterfly|🐛#bug|🐜#ant|🐝#honeybee|🐞#lady beetle|🦗#cricket|🦂#scorpion|💐#bouquet|🌸#cherry blossom|💮#white flower|🌹#rose|🥀#wilted flower|🌺#hibiscus|🌻#sunflower|🌼#blossom|🌷#tulip|🌱#seedling|🌲#evergreen tree|🌳#deciduous tree|🌴#palm tree|🌵#cactus|🌾#sheaf of rice|🌿#herb|☘️#shamrock|🍀#four leaf clover|🍁#maple leaf|🍂#fallen leaf|🍃#leaf fluttering in wind", "🍇#grapes|🍈#melon|🍉#watermelon|🍊#tangerine|🍋#lemon|🍌#banana|🍍#pineapple|🍎#red apple|🍏#green apple|🍐#pear|🍑#peach|🍒#cherries|🍓#strawberry|🥝#kiwi fruit|🍅#tomato|🥥#coconut|🥑#avocado|🍆#eggplant|🥔#potato|🥕#carrot|🌽#ear of corn|🥒#cucumber|🥦#broccoli|🍄#mushroom|🥜#peanuts|🌰#chestnut|🍞#bread|🥐#croissant|🥖#baguette bread|🥨#pretzel|🥞#pancakes|🧀#cheese wedge|🍖#meat on bone|🍗#poultry leg|🥩#cut of meat|🥓#bacon|🍔#hamburger|🍟#french fries|🍕#pizza|🌭#hot dog|🥪#sandwich|🌮#taco|🌯#burrito|🥙#stuffed flatbread|🥚#egg|🍳#cooking|🥘#shallow pan of food|🍲#pot of food|🥣#bowl with spoon|🥗#green salad|🍿#popcorn|🥫#canned food|🍱#bento box|🍘#rice cracker|🍙#rice ball|🍚#cooked rice|🍛#curry rice|🍜#steaming bowl|🍝#spaghetti|🍠#roasted sweet potato|🍢#oden|🍣#sushi|🍤#fried shrimp|🍥#fish cake with swirl|🍡#dango|🥟#dumpling|🥠#fortune cookie|🥡#takeout box|🦀#crab|🦐#shrimp|🦑#squid|🍦#soft ice cream|🍧#shaved ice|🍨#ice cream|🍩#doughnut|🍪#cookie|🎂#birthday cake|🍰#shortcake|🥧#pie|🍫#chocolate bar|🍬#candy|🍭#lollipop|🍮#custard|🍯#honey pot|🍼#baby bottle|🥛#glass of milk|☕#hot beverage|🍵#teacup without handle|🍶#sake|🍾#bottle with popping cork|🍷#wine glass|🍸#cocktail glass|🍹#tropical drink|🍺#beer mug|🍻#clinking beer mugs|🥂#clinking glasses|🥃#tumbler glass|🥤#cup with straw|🥢#chopsticks|🍴#fork and knife|🥄#spoon|🔪#kitchen knife|🏺#amphora", "🌍#globe showing Europe-Africa|🌎#globe showing Americas|🌏#globe showing Asia-Australia|🌐#globe with meridians|🗾#map of Japan|⛰️#mountain|🌋#volcano|🗻#mount fuji|🏠#house|🏡#house with garden|🏢#office building|🏣#Japanese post office|🏤#post office|🏥#hospital|🏦#bank|🏨#hotel|🏩#love hotel|🏪#convenience store|🏫#school|🏬#department store|🏭#factory|🏯#Japanese castle|🏰#castle|💒#wedding|🗼#Tokyo tower|🗽#Statue of Liberty|⛪#church|🕌#mosque|🕍#synagogue|⛩️#shinto shrine|🕋#kaaba|⛲#fountain|⛺#tent|🌁#foggy|🌃#night with stars|🌄#sunrise over mountains|🌅#sunrise|🌆#cityscape at dusk|🌇#sunset|🌉#bridge at night|♨️#hot springs|🎠#carousel horse|🎡#ferris wheel|🎢#roller coaster|💈#barber pole|🎪#circus tent|🚂#locomotive|🚃#railway car|🚄#high-speed train|🚅#bullet train|🚆#train|🚇#metro|🚈#light rail|🚉#station|🚊#tram|🚝#monorail|🚞#mountain railway|🚋#tram car|🚌#bus|🚍#oncoming bus|🚎#trolleybus|🚐#minibus|🚑#ambulance|🚒#fire engine|🚓#police car|🚔#oncoming police car|🚕#taxi|🚖#oncoming taxi|🚗#automobile|🚘#oncoming automobile|🚙#sport utility vehicle|🚚#delivery truck|🚛#articulated lorry|🚜#tractor|🛵#motor scooter|🚲#bicycle|🛴#kick scooter|🚏#bus stop|⛽#fuel pump|🚨#police car light|🚥#horizontal traffic light|🚦#vertical traffic light|🛑#stop sign|🚧#construction|⚓#anchor|⛵#sailboat|🛶#canoe|🚤#speedboat|⛴️#ferry|🚢#ship|✈️#airplane|🛫#airplane departure|🛬#airplane arrival|💺#seat|🚁#helicopter|🚟#suspension railway|🚠#mountain cableway|🚡#aerial tramway|🚀#rocket|🛸#flying saucer|⌛#hourglass done|⏳#hourglass not done|⌚#watch|⏰#alarm clock|⏱️#stopwatch|⏲️#timer clock|🕛#twelve o’clock|🕧#twelve-thirty|🕐#one o’clock|🕜#one-thirty|🕑#two o’clock|🕝#two-thirty|🕒#three o’clock|🕞#three-thirty|🕓#four o’clock|🕟#four-thirty|🕔#five o’clock|🕠#five-thirty|🕕#six o’clock|🕡#six-thirty|🕖#seven o’clock|🕢#seven-thirty|🕗#eight o’clock|🕣#eight-thirty|🕘#nine o’clock|🕤#nine-thirty|🕙#ten o’clock|🕥#ten-thirty|🕚#eleven o’clock|🕦#eleven-thirty|🌑#new moon|🌒#waxing crescent moon|🌓#first quarter moon|🌔#waxing gibbous moon|🌕#full moon|🌖#waning gibbous moon|🌗#last quarter moon|🌘#waning crescent moon|🌙#crescent moon|🌚#new moon face|🌛#first quarter moon face|🌜#last quarter moon face|☀️#sun|🌝#full moon face|🌞#sun with face|⭐#star|🌟#glowing star|🌠#shooting star|🌌#milky way|☁️#cloud|⛅#sun behind cloud|⛈️#cloud with lightning and rain|🌀#cyclone|🌈#rainbow|🌂#closed umbrella|☂️#umbrella|☔#umbrella with rain drops|⛱️#umbrella on ground|⚡#high voltage|❄️#snowflake|☃️#snowman|⛄#snowman without snow|☄️#comet|🔥#fire|💧#droplet|🌊#water wave", "🎃#jack-o-lantern|🎄#Christmas tree|🎆#fireworks|🎇#sparkler|✨#sparkles|🎈#balloon|🎉#party popper|🎊#confetti ball|🎋#tanabata tree|🎍#pine decoration|🎎#Japanese dolls|🎏#carp streamer|🎐#wind chime|🎑#moon viewing ceremony|🎀#ribbon|🎁#wrapped gift|🎫#ticket|🏆#trophy|🏅#sports medal|🥇#1st place medal|🥈#2nd place medal|🥉#3rd place medal|⚽#soccer ball|⚾#baseball|🏀#basketball|🏐#volleyball|🏈#american football|🏉#rugby football|🎾#tennis|🎳#bowling|🏏#cricket game|🏑#field hockey|🏒#ice hockey|🏓#ping pong|🏸#badminton|🥊#boxing glove|🥋#martial arts uniform|🥅#goal net|⛳#flag in hole|⛸️#ice skate|🎣#fishing pole|🎽#running shirt|🎿#skis|🛷#sled|🥌#curling stone|🎯#direct hit|🎱#pool 8 ball|🔮#crystal ball|🎮#video game|🎰#slot machine|🎲#game die|♠️#spade suit|♥️#heart suit|♦️#diamond suit|♣️#club suit|🃏#joker|🀄#mahjong red dragon|🎴#flower playing cards|🎭#performing arts|🎨#artist palette", "👓#glasses|👔#necktie|👕#t-shirt|👖#jeans|🧣#scarf|🧤#gloves|🧥#coat|🧦#socks|👗#dress|👘#kimono|👙#bikini|👚#woman’s clothes|👛#purse|👜#handbag|👝#clutch bag|🎒#backpack|👞#man’s shoe|👟#running shoe|👠#high-heeled shoe|👡#woman’s sandal|👢#woman’s boot|👑#crown|👒#woman’s hat|🎩#top hat|🎓#graduation cap|🧢#billed cap|⛑️#rescue worker’s helmet|📿#prayer beads|💄#lipstick|💍#ring|💎#gem stone|🔇#muted speaker|🔈#speaker low volume|🔉#speaker medium volume|🔊#speaker high volume|📢#loudspeaker|📣#megaphone|📯#postal horn|🔔#bell|🔕#bell with slash|🎼#musical score|🎵#musical note|🎶#musical notes|🎤#microphone|🎧#headphone|📻#radio|🎷#saxophone|🎸#guitar|🎹#musical keyboard|🎺#trumpet|🎻#violin|🥁#drum|📱#mobile phone|📲#mobile phone with arrow|☎️#telephone|📞#telephone receiver|📟#pager|📠#fax machine|🔋#battery|🔌#electric plug|💻#laptop|⌨️#keyboard|💽#computer disk|💾#floppy disk|💿#optical disk|📀#dvd|🎥#movie camera|🎬#clapper board|📺#television|📷#camera|📸#camera with flash|📹#video camera|📼#videocassette|🔍#magnifying glass tilted left|🔎#magnifying glass tilted right|💡#light bulb|🔦#flashlight|🏮#red paper lantern|📔#notebook with decorative cover|📕#closed book|📖#open book|📗#green book|📘#blue book|📙#orange book|📚#books|📓#notebook|📒#ledger|📃#page with curl|📜#scroll|📄#page facing up|📰#newspaper|📑#bookmark tabs|🔖#bookmark|💰#money bag|💴#yen banknote|💵#dollar banknote|💶#euro banknote|💷#pound banknote|💸#money with wings|💳#credit card|💹#chart increasing with yen|✉️#envelope|📧#e-mail|📨#incoming envelope|📩#envelope with arrow|📤#outbox tray|📥#inbox tray|📦#package|📫#closed mailbox with raised flag|📪#closed mailbox with lowered flag|📬#open mailbox with raised flag|📭#open mailbox with lowered flag|📮#postbox|✏️#pencil|✒️#black nib|📝#memo|💼#briefcase|📁#file folder|📂#open file folder|📅#calendar|📆#tear-off calendar|📇#card index|📈#chart increasing|📉#chart decreasing|📊#bar chart|📋#clipboard|📌#pushpin|📍#round pushpin|📎#paperclip|📏#straight ruler|📐#triangular ruler|✂️#scissors|🔒#locked|🔓#unlocked|🔏#locked with pen|🔐#locked with key|🔑#key|🔨#hammer|⛏️#pick|⚒️#hammer and pick|⚔️#crossed swords|🔫#pistol|🏹#bow and arrow|🔧#wrench|🔩#nut and bolt|⚙️#gear|⚖️#balance scale|🔗#link|⛓️#chains|⚗️#alembic|🔬#microscope|🔭#telescope|📡#satellite antenna|💉#syringe|💊#pill|🚪#door|🚽#toilet|🚿#shower|🛁#bathtub|🛒#shopping cart|🚬#cigarette|⚰️#coffin|⚱️#funeral urn|🗿#moai", "🏧#ATM sign|🚮#litter in bin sign|🚰#potable water|♿#wheelchair symbol|🚹#men’s room|🚺#women’s room|🚻#restroom|🚼#baby symbol|🚾#water closet|🛂#passport control|🛃#customs|🛄#baggage claim|🛅#left luggage|⚠️#warning|🚸#children crossing|⛔#no entry|🚫#prohibited|🚳#no bicycles|🚭#no smoking|🚯#no littering|🚱#non-potable water|🚷#no pedestrians|📵#no mobile phones|🔞#no one under eighteen|☢️#radioactive|☣️#biohazard|⬆️#up arrow|↗️#up-right arrow|➡️#right arrow|↘️#down-right arrow|⬇️#down arrow|↙️#down-left arrow|⬅️#left arrow|↖️#up-left arrow|↕️#up-down arrow|↔️#left-right arrow|↩️#right arrow curving left|↪️#left arrow curving right|⤴️#right arrow curving up|⤵️#right arrow curving down|🔃#clockwise vertical arrows|🔄#counterclockwise arrows button|🔙#BACK arrow|🔚#END arrow|🔛#ON! arrow|🔜#SOON arrow|🔝#TOP arrow|🛐#place of worship|⚛️#atom symbol|✡️#star of David|☸️#wheel of dharma|☯️#yin yang|✝️#latin cross|☦️#orthodox cross|☪️#star and crescent|☮️#peace symbol|🕎#menorah|🔯#dotted six-pointed star|♈#Aries|♉#Taurus|♊#Gemini|♋#Cancer|♌#Leo|♍#Virgo|♎#Libra|♏#Scorpio|♐#Sagittarius|♑#Capricorn|♒#Aquarius|♓#Pisces|⛎#Ophiuchus|🔀#shuffle tracks button|🔁#repeat button|🔂#repeat single button|▶️#play button|⏩#fast-forward button|⏭️#next track button|⏯️#play or pause button|◀️#reverse button|⏪#fast reverse button|⏮️#last track button|🔼#upwards button|⏫#fast up button|🔽#downwards button|⏬#fast down button|⏸️#pause button|⏹️#stop button|⏺️#record button|⏏️#eject button|🎦#cinema|🔅#dim button|🔆#bright button|📶#antenna bars|📳#vibration mode|📴#mobile phone off|♀️#female sign|♂️#male sign|✖️#multiply|➕#plus|➖#minus|➗#divide|‼️#double exclamation mark|⁉️#exclamation question mark|❓#question mark|❔#white question mark|❕#white exclamation mark|❗#exclamation mark|〰️#wavy dash|💱#currency exchange|💲#heavy dollar sign|⚕️#medical symbol|♻️#recycling symbol|⚜️#fleur-de-lis|🔱#trident emblem|📛#name badge|🔰#Japanese symbol for beginner|⭕#hollow red circle|✅#check mark button|☑️#check box with check|✔️#check mark|❌#cross mark|❎#cross mark button|➰#curly loop|➿#double curly loop|〽️#part alternation mark|✳️#eight-spoked asterisk|✴️#eight-pointed star|❇️#sparkle|©️#copyright|®️#registered|™️#trade mark|🔟#keycap: 10|🔠#input latin uppercase|🔡#input latin lowercase|🔢#input numbers|🔣#input symbols|🔤#input latin letters|🆎#AB button (blood type)|🆑#CL button|🆒#COOL button|🆓#FREE button|ℹ️#information|🆔#ID button|Ⓜ️#circled M|🆕#NEW button|🆖#NG button|🆗#OK button|🆘#SOS button|🆙#UP! button|🆚#VS button|🈁#Japanese “here” button|🈶#Japanese “not free of charge” button|🈯#Japanese “reserved” button|🉐#Japanese “bargain” button|🈹#Japanese “discount” button|🈚#Japanese “free of charge” button|🈲#Japanese “prohibited” button|🉑#Japanese “acceptable” button|🈸#Japanese “application” button|🈴#Japanese “passing grade” button|🈳#Japanese “vacancy” button|㊗️#Japanese “congratulations” button|㊙️#Japanese “secret” button|🈺#Japanese “open for business” button|🈵#Japanese “no vacancy” button|🔴#red circle|🔵#blue circle|⚫#black circle|⚪#white circle|⬛#black large square|⬜#white large square|◼️#black medium square|◻️#white medium square|◾#black medium-small square|◽#white medium-small square|▪️#black small square|▫️#white small square|🔶#large orange diamond|🔷#large blue diamond|🔸#small orange diamond|🔹#small blue diamond|🔺#red triangle pointed up|🔻#red triangle pointed down|💠#diamond with a dot|🔘#radio button|🔳#white square button|🔲#black square button"]
28619
- var emojidata = null;
28620
-
28621
- function MakeEmojiData() {
28622
- if (emojidata)
28623
- return;
28624
- emojidata = [];
28625
- for (var groupindex = 0; groupindex < groupnames.length; groupindex++) {
28626
- var emojiitems = [];
28627
- var emojigroup = { index: groupindex, name: groupnames[groupindex], items: emojiitems };
28628
- var emojiarr = emojistrs[groupindex].split('|');
28629
- for (var ei = 0; ei < emojiarr.length; ei++) {
28630
- var emojistr = emojiarr[ei];
28631
- var pair = emojistr.split('#')
28632
- emojiitems.push({ emoji: pair[0], keyword: pair[1] });
28633
- }
28634
- emojidata.push(emojigroup);
28635
- }
28636
- //console.log(emojidata);
28637
- }
28638
-
28639
- var obj = this;
28640
-
28641
- var config, editor;
28642
-
28643
- obj.PluginName = "InsertEmoji";
28644
-
28645
- obj.InitConfig = function (argconfig) {
28646
- config = argconfig;
28647
- }
28648
- obj.InitEditor = function (argeditor) {
28649
- editor = argeditor;
28650
-
28651
- editor.toolbarFactoryMap["insertemoji"] = function (cmd) {
28652
- return editor.createToolbarItemDropDownPanel(cmd, function (panel) {
28653
-
28654
- MakeEmojiData()
28655
-
28656
- panel.style.width = "360px";
28657
- panel.style.height = "420px";
28658
- panel.style.display = "flex";
28659
- panel.style.flexDirection = "column";
28660
-
28661
- panel.onclick = function (e) {
28662
- if (e.target.nodeName == "GSPAN") {
28663
- editor.closeCurrentPopup();
28664
- var htmlcode = e.target.getAttribute("htmlcode");
28665
- editor.insertHTML(htmlcode);
28666
- editor.collapse(false);
28667
- editor.focus();
28668
- }
28669
- }
28670
-
28671
- var selecteditem = null;
28672
- var toselectitem = null;
28673
- function clear_selecteditem() {
28674
- if (selecteditem != null) {
28675
- selecteditem.style.backgroundColor = "";
28676
- selecteditem = null;
28677
- }
28678
- }
28679
- function set_selecteditem() {
28680
- clear_selecteditem();
28681
- selecteditem = toselectitem; selecteditem.style.backgroundColor = "#e6e6e6";
28682
- toselectitem = null;
28683
- }
28684
- var tid_sel = 0;
28685
-
28686
- panel.onmouseover = function (e) {
28687
- for (var node = e.target; node != panel; node = node.parentNode) {
28688
- if (node.nodeName == "GITEM") {
28689
- if (node == toselectitem)
28690
- return;
28691
- toselectitem = node;
28692
- clearTimeout(tid_sel);
28693
- tid_sel = setTimeout(set_selecteditem, 10);
28694
- return;
28695
- }
28696
- }
28697
- }
28698
- panel.onmouseout = function () {
28699
- clearTimeout(tid_sel);
28700
- tid_sel = setTimeout(clear_selecteditem, 10)
28701
- }
28702
-
28703
- var searchbar = __Append(panel, "label", "margin:5px;position:relative;");
28704
- searchbar.setAttribute("id", "emojis_searchbar");
28705
- var searchbox = __Append(searchbar, "input", "width:100%;padding:5px 20px;border:solid 1px #ccc;border-radius:5px;");
28706
- searchbox.setAttribute("placeholder", editor.getLangText("searchemojis"));
28707
-
28708
- var tid_key = 0;
28709
- searchbox.onchange = searchbox.onkeyup = searchbox.onkeypress = searchbox.onpaste = function () {
28710
- clearTimeout(tid_key);
28711
- tid_key = setTimeout(show_result, 100);
28712
- }
28713
- // 2026-05-11 quick-load rewrite: bulk-build via innerHTML and
28714
- // only render the active category. Previous version eagerly
28715
- // built ~4000 DOM nodes for 1,037 emojis on every panel open,
28716
- // noticeably slow on low-end machines. New flow:
28717
- // - panel opens → render first category only (~125 nodes)
28718
- // - tab click → swap to that category's HTML
28719
- // - search → bulk innerHTML build of filtered results
28720
- function buildCategoryHTML(group) {
28721
- var parts = [];
28722
- parts.push('<div style="padding:3px;margin-top:5px;color:darkblue;">' + group.name[0].toUpperCase() + group.name.substring(1) + '</div>');
28723
- parts.push('<div style="display:flex;flex-direction:row;flex-wrap:wrap;">');
28724
- for (var i = 0; i < group.items.length; i++) {
28725
- var item = group.items[i];
28726
- var htmlcode = CharToHTMLCode(item.emoji);
28727
- parts.push('<gitem class="rte-flex-column-center" style="width:32px;height:32px;margin:2px"><gspan htmlcode="' + htmlcode + '" title="' + item.emoji + ' ' + (item.keyword || '').replace(/"/g, '') + '">' + htmlcode + '</gspan></gitem>');
28728
- }
28729
- parts.push('</div>');
28730
- return parts.join('');
28731
- }
28732
-
28733
- function show_result() {
28734
- var keyword = searchbox.value.trim().toLowerCase();
28735
- if (!keyword) {
28736
- tabpanel.style.display =
28737
- grouppanel.style.display = "";
28738
- resultpanel.style.display = "none";
28739
- return;
28740
- }
28741
-
28742
- tabpanel.style.display =
28743
- grouppanel.style.display = "none";
28744
- resultpanel.style.display = "flex";
28745
-
28746
- var hitsHtml = [];
28747
- var itemindex = 0;
28748
- for (var gi = 0; gi < emojidata.length; gi++) {
28749
- var group = emojidata[gi];
28750
- for (var ii = 0; ii < group.items.length; ii++) {
28751
- var item = group.items[ii];
28752
- if (!item.keyword || item.keyword.indexOf(keyword) == -1)
28753
- continue;
28754
- itemindex++;
28755
- var htmlcode = CharToHTMLCode(item.emoji);
28756
- hitsHtml.push('<gitem class="rte-flex-column-center" style="width:32px;height:32px;margin:2px"><gspan htmlcode="' + htmlcode + '" title="' + item.emoji + ' ' + (item.keyword || '').replace(/"/g, '') + '">' + htmlcode + '</gspan></gitem>');
28757
- }
28758
- }
28759
- resultpanel.innerHTML = '<div style="width:100%;padding:3px;margin-top:5px;color:darkblue;text-align:center;">' + itemindex + ' items</div>' + hitsHtml.join('');
28760
- }
28761
-
28762
- panel.setAttribute("id", "emoji-picker");
28763
-
28764
- var tabpanel = __Append(panel, "div");
28765
-
28766
- var resultpanel = __Append(panel, "div", "display:none;flex-direction:row;flex-wrap:wrap;overflow-y:scroll;padding-bottom:55px");
28767
-
28768
- var grouppanel = __Append(panel, "div", "overflow-y:scroll;padding-bottom:55px;flex:999");
28769
-
28770
- // Lazy-render: only build the active category. Each tab click swaps the html.
28771
- var activeGroupIndex = 0;
28772
- function renderCategory(gi) {
28773
- activeGroupIndex = gi;
28774
- grouppanel.innerHTML = buildCategoryHTML(emojidata[gi]);
28775
- grouppanel.scrollTop = 0;
28776
- for (var bi = 0; bi < tabuibtns.length; bi++) {
28777
- tabuibtns[bi].className = bi === gi ? "rte-ui-active" : "";
28778
- }
28779
- }
28780
-
28781
- var tabui = __Append(tabpanel, "rte-tabui");
28782
- tabui.setAttribute("id", "emoji-picker");
28783
- var tabuitoolbar = __Append(tabui, "rte-tabui-toolbar");
28784
- var tabuibtns = [];
28785
- for (var gi = 0; gi < emojidata.length; gi++) {
28786
- (function (group) {
28787
- var btn = __Append(tabuitoolbar, "rte-tabui-toolbar-button", "width:32px;text-align:center;margin:4px");
28788
- btn.setAttribute("title", group.name);
28789
- btn.innerHTML = group.items[0].emoji;
28790
- btn.onclick = function () { renderCategory(group.index); };
28791
- tabuibtns.push(btn);
28792
- })(emojidata[gi]);
28793
- }
28794
-
28795
- // Initial render: just the first category (~125 emojis instead of all 1037).
28796
- renderCategory(0);
28797
-
28798
- searchbox.focus();
28799
-
28800
-
28801
- })
28802
- };
28803
-
28804
- }
28805
-
28806
- function __Append(parent, tagname, csstext, cssclass) {
28807
- var tag = parent.ownerDocument.createElement(tagname);
28808
- if (csstext) tag.style.cssText = csstext;
28809
- if (cssclass) tag.className = cssclass;
28810
- parent.appendChild(tag);
28811
- return tag;
28812
- }
28813
-
28814
-
28815
- }
28816
-
28817
-
28818
-
28819
-
29559
+
29560
+ RTE_DefaultConfig.plugin_insertemoji = RTE_Plugin_InsertEmoji;
29561
+
29562
+ function RTE_Plugin_InsertEmoji() {
29563
+
29564
+ function CharToHTMLCode(ch) {
29565
+ if (ch.length > 2)
29566
+ return ch;
29567
+
29568
+ if (ch.length == 1 || ch.charCodeAt(1) == 0xfe0f)
29569
+ return "&#" + ch.charCodeAt(0) + ";"
29570
+
29571
+ if (ch.charCodeAt(1) == 0xfe0f) {
29572
+ return "&#" + ch.charCodeAt(0) + ";"
29573
+ }
29574
+
29575
+ var offset = ch.charCodeAt(0) - 0xd83c;
29576
+ if (offset < 0 && offset > 3)//not support
29577
+ return ch;
29578
+
29579
+ var second = ch.charCodeAt(1) - 0xdc04;
29580
+ var f = offset * 0x400 + second + 0x1f004;
29581
+ return "&#x" + f.toString(16) + ";"
29582
+ }
29583
+
29584
+
29585
+ var groupnames = ["smileys", "people", "animals", "food", "travel", "activities", "objects", "symbols"]
29586
+ var emojistrs = ["😀#grinning face|😃#grinning face with big eyes|😄#grinning face with smiling eyes|😁#beaming face with smiling eyes|😆#grinning squinting face|😅#grinning face with sweat|🤣#rolling on the floor laughing|😂#face with tears of joy|🙂#slightly smiling face|🙃#upside-down face|😉#winking face|😊#smiling face with smiling eyes|😇#smiling face with halo|😍#smiling face with heart-eyes|🤩#star-struck|😘#face blowing a kiss|😗#kissing face|😚#kissing face with closed eyes|😙#kissing face with smiling eyes|😋#face savoring food|😛#face with tongue|😜#winking face with tongue|🤪#zany face|😝#squinting face with tongue|🤑#money-mouth face|🤗#hugging face|🤭#face with hand over mouth|🤫#shushing face|🤔#thinking face|🤐#zipper-mouth face|🤨#face with raised eyebrow|😐#neutral face|😑#expressionless face|😶#face without mouth|😏#smirking face|😒#unamused face|🙄#face with rolling eyes|😬#grimacing face|🤥#lying face|😌#relieved face|😔#pensive face|😪#sleepy face|🤤#drooling face|😴#sleeping face|😷#face with medical mask|🤒#face with thermometer|🤕#face with head-bandage|🤢#nauseated face|🤮#face vomiting|🤧#sneezing face|😵#dizzy face|🤯#exploding head|🤠#cowboy hat face|😎#smiling face with sunglasses|🤓#nerd face|🧐#face with monocle|😕#confused face|😟#worried face|🙁#slightly frowning face|☹️#frowning face|😮#face with open mouth|😯#hushed face|😲#astonished face|😳#flushed face|😦#frowning face with open mouth|😧#anguished face|😨#fearful face|😰#anxious face with sweat|😥#sad but relieved face|😢#crying face|😭#loudly crying face|😱#face screaming in fear|😖#confounded face|😣#persevering face|😞#disappointed face|😓#downcast face with sweat|😩#weary face|😫#tired face|😤#face with steam from nose|😡#pouting face|😠#angry face|🤬#face with symbols on mouth|😈#smiling face with horns|👿#angry face with horns|💀#skull|☠️#skull and crossbones|💩#pile of poo|🤡#clown face|👹#ogre|👺#goblin|👻#ghost|👽#alien|👾#alien monster|🤖#robot|😺#grinning cat|😸#grinning cat with smiling eyes|😹#cat with tears of joy|😻#smiling cat with heart-eyes|😼#cat with wry smile|😽#kissing cat|🙀#weary cat|😿#crying cat|😾#pouting cat|🙈#see-no-evil monkey|🙉#hear-no-evil monkey|🙊#speak-no-evil monkey|💋#kiss mark|💌#love letter|💘#heart with arrow|💝#heart with ribbon|💖#sparkling heart|💗#growing heart|💓#beating heart|💞#revolving hearts|💕#two hearts|💟#heart decoration|❣️#heart exclamation|💔#broken heart|❤️#red heart|🧡#orange heart|💛#yellow heart|💚#green heart|💙#blue heart|💜#purple heart|🖤#black heart|💯#hundred points|💢#anger symbol|💥#collision|💫#dizzy|💦#sweat droplets|💨#dashing away|💣#bomb|💬#speech balloon|💭#thought balloon|💤#zzz", "👋#waving hand|🤚#raised back of hand|✋#raised hand|🖖#vulcan salute|👌#OK hand|✌️#victory hand|🤞#crossed fingers|🤟#love-you gesture|🤘#sign of the horns|🤙#call me hand|👈#backhand index pointing left|👉#backhand index pointing right|👆#backhand index pointing up|🖕#middle finger|👇#backhand index pointing down|☝️#index pointing up|👍#thumbs up|👎#thumbs down|✊#raised fist|👊#oncoming fist|🤛#left-facing fist|🤜#right-facing fist|👏#clapping hands|🙌#raising hands|👐#open hands|🤲#palms up together|🤝#handshake|🙏#folded hands|✍️#writing hand|💅#nail polish|🤳#selfie|💪#flexed biceps|👂#ear|👃#nose|🧠#brain|👀#eyes|👅#tongue|👄#mouth|👶#baby|🧒#child|👦#boy|👧#girl|🧑#person|👱#person with blond hair|👨#man|🧔#man with beard|👩#woman|🧓#older person|👴#old man|👵#old woman|🙍#person frowning|🙎#person pouting|🙅#person gesturing NO|🙆#person gesturing OK|💁#person tipping hand|🙋#person raising hand|🙇#person bowing|🤦#person facepalming|🤷#person shrugging|👮#police officer|💂#guard|👷#construction worker|🤴#prince|👸#princess|👳#person wearing turban|👲#person with skullcap|🧕#woman with headscarf|🤵#person in tuxedo|👰#person with veil|🤰#pregnant woman|🤱#breast-feeding|👼#baby angel|🎅#Santa Claus|🤶#Mrs. Claus|🧙#mage|🧚#fairy|🧛#vampire|🧜#merperson|🧝#elf|🧞#genie|🧟#zombie|💆#person getting massage|💇#person getting haircut|🚶#person walking|🏃#person running|💃#woman dancing|🕺#man dancing|👯#people with bunny ears|🧖#person in steamy room|🧗#person climbing|🤺#person fencing|🏇#horse racing|⛷️#skier|🏂#snowboarder|🏄#person surfing|🚣#person rowing boat|🏊#person swimming|⛹️#person bouncing ball|🚴#person biking|🚵#person mountain biking|🤸#person cartwheeling|🤼#people wrestling|🤽#person playing water polo|🤾#person playing handball|🤹#person juggling|🧘#person in lotus position|🛀#person taking bath|🛌#person in bed|👭#women holding hands|👫#woman and man holding hands|👬#men holding hands|💏#kiss|💑#couple with heart|👪#family|👤#bust in silhouette|👥#busts in silhouette|👣#footprints", "🐵#monkey face|🐒#monkey|🦍#gorilla|🐶#dog face|🐕#dog|🐩#poodle|🐺#wolf|🦊#fox|🐱#cat face|🐈#cat|🦁#lion|🐯#tiger face|🐅#tiger|🐆#leopard|🐴#horse face|🐎#horse|🦄#unicorn|🦓#zebra|🦌#deer|🐮#cow face|🐂#ox|🐃#water buffalo|🐄#cow|🐷#pig face|🐖#pig|🐗#boar|🐽#pig nose|🐏#ram|🐑#ewe|🐐#goat|🐪#camel|🐫#two-hump camel|🦒#giraffe|🐘#elephant|🦏#rhinoceros|🐭#mouse face|🐁#mouse|🐀#rat|🐹#hamster|🐰#rabbit face|🐇#rabbit|🦔#hedgehog|🦇#bat|🐻#bear|🐨#koala|🐼#panda|🐾#paw prints|🦃#turkey|🐔#chicken|🐓#rooster|🐣#hatching chick|🐤#baby chick|🐥#front-facing baby chick|🐦#bird|🐧#penguin|🦅#eagle|🦆#duck|🦉#owl|🐸#frog|🐊#crocodile|🐢#turtle|🦎#lizard|🐍#snake|🐲#dragon face|🐉#dragon|🦕#sauropod|🦖#T-Rex|🐳#spouting whale|🐋#whale|🐬#dolphin|🐟#fish|🐠#tropical fish|🐡#blowfish|🦈#shark|🐙#octopus|🐚#spiral shell|🐌#snail|🦋#butterfly|🐛#bug|🐜#ant|🐝#honeybee|🐞#lady beetle|🦗#cricket|🦂#scorpion|💐#bouquet|🌸#cherry blossom|💮#white flower|🌹#rose|🥀#wilted flower|🌺#hibiscus|🌻#sunflower|🌼#blossom|🌷#tulip|🌱#seedling|🌲#evergreen tree|🌳#deciduous tree|🌴#palm tree|🌵#cactus|🌾#sheaf of rice|🌿#herb|☘️#shamrock|🍀#four leaf clover|🍁#maple leaf|🍂#fallen leaf|🍃#leaf fluttering in wind", "🍇#grapes|🍈#melon|🍉#watermelon|🍊#tangerine|🍋#lemon|🍌#banana|🍍#pineapple|🍎#red apple|🍏#green apple|🍐#pear|🍑#peach|🍒#cherries|🍓#strawberry|🥝#kiwi fruit|🍅#tomato|🥥#coconut|🥑#avocado|🍆#eggplant|🥔#potato|🥕#carrot|🌽#ear of corn|🥒#cucumber|🥦#broccoli|🍄#mushroom|🥜#peanuts|🌰#chestnut|🍞#bread|🥐#croissant|🥖#baguette bread|🥨#pretzel|🥞#pancakes|🧀#cheese wedge|🍖#meat on bone|🍗#poultry leg|🥩#cut of meat|🥓#bacon|🍔#hamburger|🍟#french fries|🍕#pizza|🌭#hot dog|🥪#sandwich|🌮#taco|🌯#burrito|🥙#stuffed flatbread|🥚#egg|🍳#cooking|🥘#shallow pan of food|🍲#pot of food|🥣#bowl with spoon|🥗#green salad|🍿#popcorn|🥫#canned food|🍱#bento box|🍘#rice cracker|🍙#rice ball|🍚#cooked rice|🍛#curry rice|🍜#steaming bowl|🍝#spaghetti|🍠#roasted sweet potato|🍢#oden|🍣#sushi|🍤#fried shrimp|🍥#fish cake with swirl|🍡#dango|🥟#dumpling|🥠#fortune cookie|🥡#takeout box|🦀#crab|🦐#shrimp|🦑#squid|🍦#soft ice cream|🍧#shaved ice|🍨#ice cream|🍩#doughnut|🍪#cookie|🎂#birthday cake|🍰#shortcake|🥧#pie|🍫#chocolate bar|🍬#candy|🍭#lollipop|🍮#custard|🍯#honey pot|🍼#baby bottle|🥛#glass of milk|☕#hot beverage|🍵#teacup without handle|🍶#sake|🍾#bottle with popping cork|🍷#wine glass|🍸#cocktail glass|🍹#tropical drink|🍺#beer mug|🍻#clinking beer mugs|🥂#clinking glasses|🥃#tumbler glass|🥤#cup with straw|🥢#chopsticks|🍴#fork and knife|🥄#spoon|🔪#kitchen knife|🏺#amphora", "🌍#globe showing Europe-Africa|🌎#globe showing Americas|🌏#globe showing Asia-Australia|🌐#globe with meridians|🗾#map of Japan|⛰️#mountain|🌋#volcano|🗻#mount fuji|🏠#house|🏡#house with garden|🏢#office building|🏣#Japanese post office|🏤#post office|🏥#hospital|🏦#bank|🏨#hotel|🏩#love hotel|🏪#convenience store|🏫#school|🏬#department store|🏭#factory|🏯#Japanese castle|🏰#castle|💒#wedding|🗼#Tokyo tower|🗽#Statue of Liberty|⛪#church|🕌#mosque|🕍#synagogue|⛩️#shinto shrine|🕋#kaaba|⛲#fountain|⛺#tent|🌁#foggy|🌃#night with stars|🌄#sunrise over mountains|🌅#sunrise|🌆#cityscape at dusk|🌇#sunset|🌉#bridge at night|♨️#hot springs|🎠#carousel horse|🎡#ferris wheel|🎢#roller coaster|💈#barber pole|🎪#circus tent|🚂#locomotive|🚃#railway car|🚄#high-speed train|🚅#bullet train|🚆#train|🚇#metro|🚈#light rail|🚉#station|🚊#tram|🚝#monorail|🚞#mountain railway|🚋#tram car|🚌#bus|🚍#oncoming bus|🚎#trolleybus|🚐#minibus|🚑#ambulance|🚒#fire engine|🚓#police car|🚔#oncoming police car|🚕#taxi|🚖#oncoming taxi|🚗#automobile|🚘#oncoming automobile|🚙#sport utility vehicle|🚚#delivery truck|🚛#articulated lorry|🚜#tractor|🛵#motor scooter|🚲#bicycle|🛴#kick scooter|🚏#bus stop|⛽#fuel pump|🚨#police car light|🚥#horizontal traffic light|🚦#vertical traffic light|🛑#stop sign|🚧#construction|⚓#anchor|⛵#sailboat|🛶#canoe|🚤#speedboat|⛴️#ferry|🚢#ship|✈️#airplane|🛫#airplane departure|🛬#airplane arrival|💺#seat|🚁#helicopter|🚟#suspension railway|🚠#mountain cableway|🚡#aerial tramway|🚀#rocket|🛸#flying saucer|⌛#hourglass done|⏳#hourglass not done|⌚#watch|⏰#alarm clock|⏱️#stopwatch|⏲️#timer clock|🕛#twelve o’clock|🕧#twelve-thirty|🕐#one o’clock|🕜#one-thirty|🕑#two o’clock|🕝#two-thirty|🕒#three o’clock|🕞#three-thirty|🕓#four o’clock|🕟#four-thirty|🕔#five o’clock|🕠#five-thirty|🕕#six o’clock|🕡#six-thirty|🕖#seven o’clock|🕢#seven-thirty|🕗#eight o’clock|🕣#eight-thirty|🕘#nine o’clock|🕤#nine-thirty|🕙#ten o’clock|🕥#ten-thirty|🕚#eleven o’clock|🕦#eleven-thirty|🌑#new moon|🌒#waxing crescent moon|🌓#first quarter moon|🌔#waxing gibbous moon|🌕#full moon|🌖#waning gibbous moon|🌗#last quarter moon|🌘#waning crescent moon|🌙#crescent moon|🌚#new moon face|🌛#first quarter moon face|🌜#last quarter moon face|☀️#sun|🌝#full moon face|🌞#sun with face|⭐#star|🌟#glowing star|🌠#shooting star|🌌#milky way|☁️#cloud|⛅#sun behind cloud|⛈️#cloud with lightning and rain|🌀#cyclone|🌈#rainbow|🌂#closed umbrella|☂️#umbrella|☔#umbrella with rain drops|⛱️#umbrella on ground|⚡#high voltage|❄️#snowflake|☃️#snowman|⛄#snowman without snow|☄️#comet|🔥#fire|💧#droplet|🌊#water wave", "🎃#jack-o-lantern|🎄#Christmas tree|🎆#fireworks|🎇#sparkler|✨#sparkles|🎈#balloon|🎉#party popper|🎊#confetti ball|🎋#tanabata tree|🎍#pine decoration|🎎#Japanese dolls|🎏#carp streamer|🎐#wind chime|🎑#moon viewing ceremony|🎀#ribbon|🎁#wrapped gift|🎫#ticket|🏆#trophy|🏅#sports medal|🥇#1st place medal|🥈#2nd place medal|🥉#3rd place medal|⚽#soccer ball|⚾#baseball|🏀#basketball|🏐#volleyball|🏈#american football|🏉#rugby football|🎾#tennis|🎳#bowling|🏏#cricket game|🏑#field hockey|🏒#ice hockey|🏓#ping pong|🏸#badminton|🥊#boxing glove|🥋#martial arts uniform|🥅#goal net|⛳#flag in hole|⛸️#ice skate|🎣#fishing pole|🎽#running shirt|🎿#skis|🛷#sled|🥌#curling stone|🎯#direct hit|🎱#pool 8 ball|🔮#crystal ball|🎮#video game|🎰#slot machine|🎲#game die|♠️#spade suit|♥️#heart suit|♦️#diamond suit|♣️#club suit|🃏#joker|🀄#mahjong red dragon|🎴#flower playing cards|🎭#performing arts|🎨#artist palette", "👓#glasses|👔#necktie|👕#t-shirt|👖#jeans|🧣#scarf|🧤#gloves|🧥#coat|🧦#socks|👗#dress|👘#kimono|👙#bikini|👚#woman’s clothes|👛#purse|👜#handbag|👝#clutch bag|🎒#backpack|👞#man’s shoe|👟#running shoe|👠#high-heeled shoe|👡#woman’s sandal|👢#woman’s boot|👑#crown|👒#woman’s hat|🎩#top hat|🎓#graduation cap|🧢#billed cap|⛑️#rescue worker’s helmet|📿#prayer beads|💄#lipstick|💍#ring|💎#gem stone|🔇#muted speaker|🔈#speaker low volume|🔉#speaker medium volume|🔊#speaker high volume|📢#loudspeaker|📣#megaphone|📯#postal horn|🔔#bell|🔕#bell with slash|🎼#musical score|🎵#musical note|🎶#musical notes|🎤#microphone|🎧#headphone|📻#radio|🎷#saxophone|🎸#guitar|🎹#musical keyboard|🎺#trumpet|🎻#violin|🥁#drum|📱#mobile phone|📲#mobile phone with arrow|☎️#telephone|📞#telephone receiver|📟#pager|📠#fax machine|🔋#battery|🔌#electric plug|💻#laptop|⌨️#keyboard|💽#computer disk|💾#floppy disk|💿#optical disk|📀#dvd|🎥#movie camera|🎬#clapper board|📺#television|📷#camera|📸#camera with flash|📹#video camera|📼#videocassette|🔍#magnifying glass tilted left|🔎#magnifying glass tilted right|💡#light bulb|🔦#flashlight|🏮#red paper lantern|📔#notebook with decorative cover|📕#closed book|📖#open book|📗#green book|📘#blue book|📙#orange book|📚#books|📓#notebook|📒#ledger|📃#page with curl|📜#scroll|📄#page facing up|📰#newspaper|📑#bookmark tabs|🔖#bookmark|💰#money bag|💴#yen banknote|💵#dollar banknote|💶#euro banknote|💷#pound banknote|💸#money with wings|💳#credit card|💹#chart increasing with yen|✉️#envelope|📧#e-mail|📨#incoming envelope|📩#envelope with arrow|📤#outbox tray|📥#inbox tray|📦#package|📫#closed mailbox with raised flag|📪#closed mailbox with lowered flag|📬#open mailbox with raised flag|📭#open mailbox with lowered flag|📮#postbox|✏️#pencil|✒️#black nib|📝#memo|💼#briefcase|📁#file folder|📂#open file folder|📅#calendar|📆#tear-off calendar|📇#card index|📈#chart increasing|📉#chart decreasing|📊#bar chart|📋#clipboard|📌#pushpin|📍#round pushpin|📎#paperclip|📏#straight ruler|📐#triangular ruler|✂️#scissors|🔒#locked|🔓#unlocked|🔏#locked with pen|🔐#locked with key|🔑#key|🔨#hammer|⛏️#pick|⚒️#hammer and pick|⚔️#crossed swords|🔫#pistol|🏹#bow and arrow|🔧#wrench|🔩#nut and bolt|⚙️#gear|⚖️#balance scale|🔗#link|⛓️#chains|⚗️#alembic|🔬#microscope|🔭#telescope|📡#satellite antenna|💉#syringe|💊#pill|🚪#door|🚽#toilet|🚿#shower|🛁#bathtub|🛒#shopping cart|🚬#cigarette|⚰️#coffin|⚱️#funeral urn|🗿#moai", "🏧#ATM sign|🚮#litter in bin sign|🚰#potable water|♿#wheelchair symbol|🚹#men’s room|🚺#women’s room|🚻#restroom|🚼#baby symbol|🚾#water closet|🛂#passport control|🛃#customs|🛄#baggage claim|🛅#left luggage|⚠️#warning|🚸#children crossing|⛔#no entry|🚫#prohibited|🚳#no bicycles|🚭#no smoking|🚯#no littering|🚱#non-potable water|🚷#no pedestrians|📵#no mobile phones|🔞#no one under eighteen|☢️#radioactive|☣️#biohazard|⬆️#up arrow|↗️#up-right arrow|➡️#right arrow|↘️#down-right arrow|⬇️#down arrow|↙️#down-left arrow|⬅️#left arrow|↖️#up-left arrow|↕️#up-down arrow|↔️#left-right arrow|↩️#right arrow curving left|↪️#left arrow curving right|⤴️#right arrow curving up|⤵️#right arrow curving down|🔃#clockwise vertical arrows|🔄#counterclockwise arrows button|🔙#BACK arrow|🔚#END arrow|🔛#ON! arrow|🔜#SOON arrow|🔝#TOP arrow|🛐#place of worship|⚛️#atom symbol|✡️#star of David|☸️#wheel of dharma|☯️#yin yang|✝️#latin cross|☦️#orthodox cross|☪️#star and crescent|☮️#peace symbol|🕎#menorah|🔯#dotted six-pointed star|♈#Aries|♉#Taurus|♊#Gemini|♋#Cancer|♌#Leo|♍#Virgo|♎#Libra|♏#Scorpio|♐#Sagittarius|♑#Capricorn|♒#Aquarius|♓#Pisces|⛎#Ophiuchus|🔀#shuffle tracks button|🔁#repeat button|🔂#repeat single button|▶️#play button|⏩#fast-forward button|⏭️#next track button|⏯️#play or pause button|◀️#reverse button|⏪#fast reverse button|⏮️#last track button|🔼#upwards button|⏫#fast up button|🔽#downwards button|⏬#fast down button|⏸️#pause button|⏹️#stop button|⏺️#record button|⏏️#eject button|🎦#cinema|🔅#dim button|🔆#bright button|📶#antenna bars|📳#vibration mode|📴#mobile phone off|♀️#female sign|♂️#male sign|✖️#multiply|➕#plus|➖#minus|➗#divide|‼️#double exclamation mark|⁉️#exclamation question mark|❓#question mark|❔#white question mark|❕#white exclamation mark|❗#exclamation mark|〰️#wavy dash|💱#currency exchange|💲#heavy dollar sign|⚕️#medical symbol|♻️#recycling symbol|⚜️#fleur-de-lis|🔱#trident emblem|📛#name badge|🔰#Japanese symbol for beginner|⭕#hollow red circle|✅#check mark button|☑️#check box with check|✔️#check mark|❌#cross mark|❎#cross mark button|➰#curly loop|➿#double curly loop|〽️#part alternation mark|✳️#eight-spoked asterisk|✴️#eight-pointed star|❇️#sparkle|©️#copyright|®️#registered|™️#trade mark|🔟#keycap: 10|🔠#input latin uppercase|🔡#input latin lowercase|🔢#input numbers|🔣#input symbols|🔤#input latin letters|🆎#AB button (blood type)|🆑#CL button|🆒#COOL button|🆓#FREE button|ℹ️#information|🆔#ID button|Ⓜ️#circled M|🆕#NEW button|🆖#NG button|🆗#OK button|🆘#SOS button|🆙#UP! button|🆚#VS button|🈁#Japanese “here” button|🈶#Japanese “not free of charge” button|🈯#Japanese “reserved” button|🉐#Japanese “bargain” button|🈹#Japanese “discount” button|🈚#Japanese “free of charge” button|🈲#Japanese “prohibited” button|🉑#Japanese “acceptable” button|🈸#Japanese “application” button|🈴#Japanese “passing grade” button|🈳#Japanese “vacancy” button|㊗️#Japanese “congratulations” button|㊙️#Japanese “secret” button|🈺#Japanese “open for business” button|🈵#Japanese “no vacancy” button|🔴#red circle|🔵#blue circle|⚫#black circle|⚪#white circle|⬛#black large square|⬜#white large square|◼️#black medium square|◻️#white medium square|◾#black medium-small square|◽#white medium-small square|▪️#black small square|▫️#white small square|🔶#large orange diamond|🔷#large blue diamond|🔸#small orange diamond|🔹#small blue diamond|🔺#red triangle pointed up|🔻#red triangle pointed down|💠#diamond with a dot|🔘#radio button|🔳#white square button|🔲#black square button"]
29587
+ var emojidata = null;
29588
+
29589
+ function MakeEmojiData() {
29590
+ if (emojidata)
29591
+ return;
29592
+ emojidata = [];
29593
+ for (var groupindex = 0; groupindex < groupnames.length; groupindex++) {
29594
+ var emojiitems = [];
29595
+ var emojigroup = { index: groupindex, name: groupnames[groupindex], items: emojiitems };
29596
+ var emojiarr = emojistrs[groupindex].split('|');
29597
+ for (var ei = 0; ei < emojiarr.length; ei++) {
29598
+ var emojistr = emojiarr[ei];
29599
+ var pair = emojistr.split('#')
29600
+ emojiitems.push({ emoji: pair[0], keyword: pair[1] });
29601
+ }
29602
+ emojidata.push(emojigroup);
29603
+ }
29604
+ //console.log(emojidata);
29605
+ }
29606
+
29607
+ var obj = this;
29608
+
29609
+ var config, editor;
29610
+
29611
+ obj.PluginName = "InsertEmoji";
29612
+
29613
+ obj.InitConfig = function (argconfig) {
29614
+ config = argconfig;
29615
+ }
29616
+ obj.InitEditor = function (argeditor) {
29617
+ editor = argeditor;
29618
+
29619
+ editor.toolbarFactoryMap["insertemoji"] = function (cmd) {
29620
+ return editor.createToolbarItemDropDownPanel(cmd, function (panel) {
29621
+
29622
+ MakeEmojiData()
29623
+
29624
+ panel.style.width = "360px";
29625
+ panel.style.height = "420px";
29626
+ panel.style.display = "flex";
29627
+ panel.style.flexDirection = "column";
29628
+
29629
+ panel.onclick = function (e) {
29630
+ if (e.target.nodeName == "GSPAN") {
29631
+ editor.closeCurrentPopup();
29632
+ var htmlcode = e.target.getAttribute("htmlcode");
29633
+ editor.insertHTML(htmlcode);
29634
+ editor.collapse(false);
29635
+ editor.focus();
29636
+ }
29637
+ }
29638
+
29639
+ // Keyboard access for the emoji grid.
29640
+ //
29641
+ // The grid is <gspan> cells, which no browser makes focusable and
29642
+ // which the editor's own keyboard layer does not recognise (it
29643
+ // finds menu items by tag name, and these are not on that list).
29644
+ // Until the cells were given a role and a tab stop, the only
29645
+ // keyboard-reachable thing in this whole panel was the search box:
29646
+ // the picker announced itself as a menu and then could not be
29647
+ // operated without a mouse.
29648
+ function cells() {
29649
+ var visible = resultpanel.style.display !== "none" ? resultpanel : grouppanel;
29650
+ return [].slice.call(visible.querySelectorAll("gspan"));
29651
+ }
29652
+
29653
+ // The grid wraps, so "up" and "down" mean the nearest cell on the
29654
+ // adjacent visual row — computed from geometry rather than assuming
29655
+ // a fixed column count, which changes with the panel width.
29656
+ function step(list, from, dir) {
29657
+ var here = from.getBoundingClientRect();
29658
+ var candidates = list.filter(function (c) {
29659
+ var r = c.getBoundingClientRect();
29660
+ return dir < 0 ? r.bottom <= here.top + 1 : r.top >= here.bottom - 1;
29661
+ });
29662
+ if (!candidates.length) return null;
29663
+ var rowEdge = null;
29664
+ candidates.forEach(function (c) {
29665
+ var r = c.getBoundingClientRect();
29666
+ if (rowEdge === null) rowEdge = r.top;
29667
+ else if (dir < 0 ? r.top > rowEdge : r.top < rowEdge) rowEdge = r.top;
29668
+ });
29669
+ var row = candidates.filter(function (c) {
29670
+ return Math.abs(c.getBoundingClientRect().top - rowEdge) < 2;
29671
+ });
29672
+ var best = row[0], bestDx = Infinity;
29673
+ row.forEach(function (c) {
29674
+ var dx = Math.abs(c.getBoundingClientRect().left - here.left);
29675
+ if (dx < bestDx) { bestDx = dx; best = c; }
29676
+ });
29677
+ return best;
29678
+ }
29679
+
29680
+ panel.addEventListener("keydown", function (e) {
29681
+ var target = e.target;
29682
+ if (!target || target.nodeName !== "GSPAN") return;
29683
+ var list = cells();
29684
+ var at = list.indexOf(target);
29685
+ var next = null;
29686
+
29687
+ if (e.key === "ArrowRight") next = list[at + 1];
29688
+ else if (e.key === "ArrowLeft") next = list[at - 1];
29689
+ else if (e.key === "ArrowDown") next = step(list, target, 1);
29690
+ else if (e.key === "ArrowUp") next = step(list, target, -1);
29691
+ else if (e.key === "Home") next = list[0];
29692
+ else if (e.key === "End") next = list[list.length - 1];
29693
+ else if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
29694
+ e.preventDefault();
29695
+ e.stopPropagation();
29696
+ target.click();
29697
+ return;
29698
+ }
29699
+ else if (e.key === "Escape") {
29700
+ e.preventDefault();
29701
+ e.stopPropagation();
29702
+ editor.closeCurrentPopup();
29703
+ return;
29704
+ }
29705
+ else return;
29706
+
29707
+ e.preventDefault();
29708
+ e.stopPropagation();
29709
+ if (next) next.focus();
29710
+ }, true);
29711
+
29712
+ var selecteditem = null;
29713
+ var toselectitem = null;
29714
+ function clear_selecteditem() {
29715
+ if (selecteditem != null) {
29716
+ selecteditem.style.backgroundColor = "";
29717
+ selecteditem = null;
29718
+ }
29719
+ }
29720
+ function set_selecteditem() {
29721
+ clear_selecteditem();
29722
+ selecteditem = toselectitem; selecteditem.style.backgroundColor = "#e6e6e6";
29723
+ toselectitem = null;
29724
+ }
29725
+ var tid_sel = 0;
29726
+
29727
+ panel.onmouseover = function (e) {
29728
+ for (var node = e.target; node != panel; node = node.parentNode) {
29729
+ if (node.nodeName == "GITEM") {
29730
+ if (node == toselectitem)
29731
+ return;
29732
+ toselectitem = node;
29733
+ clearTimeout(tid_sel);
29734
+ tid_sel = setTimeout(set_selecteditem, 10);
29735
+ return;
29736
+ }
29737
+ }
29738
+ }
29739
+ panel.onmouseout = function () {
29740
+ clearTimeout(tid_sel);
29741
+ tid_sel = setTimeout(clear_selecteditem, 10)
29742
+ }
29743
+
29744
+ var searchbar = __Append(panel, "label", "margin:5px;position:relative;");
29745
+ searchbar.setAttribute("id", "emojis_searchbar");
29746
+ var searchbox = __Append(searchbar, "input", "width:100%;padding:5px 20px;border:solid 1px #ccc;border-radius:5px;");
29747
+ searchbox.setAttribute("placeholder", editor.getLangText("searchemojis"));
29748
+
29749
+ var tid_key = 0;
29750
+ searchbox.onchange = searchbox.onkeyup = searchbox.onkeypress = searchbox.onpaste = function () {
29751
+ clearTimeout(tid_key);
29752
+ tid_key = setTimeout(show_result, 100);
29753
+ }
29754
+ // 2026-05-11 quick-load rewrite: bulk-build via innerHTML and
29755
+ // only render the active category. Previous version eagerly
29756
+ // built ~4000 DOM nodes for 1,037 emojis on every panel open,
29757
+ // noticeably slow on low-end machines. New flow:
29758
+ // - panel opens → render first category only (~125 nodes)
29759
+ // - tab click → swap to that category's HTML
29760
+ // - search → bulk innerHTML build of filtered results
29761
+ function buildCategoryHTML(group) {
29762
+ var parts = [];
29763
+ parts.push('<div style="padding:3px;margin-top:5px;color:darkblue;">' + group.name[0].toUpperCase() + group.name.substring(1) + '</div>');
29764
+ parts.push('<div style="display:flex;flex-direction:row;flex-wrap:wrap;">');
29765
+ for (var i = 0; i < group.items.length; i++) {
29766
+ var item = group.items[i];
29767
+ var htmlcode = CharToHTMLCode(item.emoji);
29768
+ parts.push('<gitem class="rte-flex-column-center" style="width:32px;height:32px;margin:2px"><gspan role="menuitem" tabindex="0" aria-label="' + (item.keyword || item.emoji).replace(/"/g, '') + '" htmlcode="' + htmlcode + '" title="' + item.emoji + ' ' + (item.keyword || '').replace(/"/g, '') + '">' + htmlcode + '</gspan></gitem>');
29769
+ }
29770
+ parts.push('</div>');
29771
+ return parts.join('');
29772
+ }
29773
+
29774
+ function show_result() {
29775
+ var keyword = searchbox.value.trim().toLowerCase();
29776
+ if (!keyword) {
29777
+ tabpanel.style.display =
29778
+ grouppanel.style.display = "";
29779
+ resultpanel.style.display = "none";
29780
+ return;
29781
+ }
29782
+
29783
+ tabpanel.style.display =
29784
+ grouppanel.style.display = "none";
29785
+ resultpanel.style.display = "flex";
29786
+
29787
+ var hitsHtml = [];
29788
+ var itemindex = 0;
29789
+ for (var gi = 0; gi < emojidata.length; gi++) {
29790
+ var group = emojidata[gi];
29791
+ for (var ii = 0; ii < group.items.length; ii++) {
29792
+ var item = group.items[ii];
29793
+ if (!item.keyword || item.keyword.indexOf(keyword) == -1)
29794
+ continue;
29795
+ itemindex++;
29796
+ var htmlcode = CharToHTMLCode(item.emoji);
29797
+ hitsHtml.push('<gitem class="rte-flex-column-center" style="width:32px;height:32px;margin:2px"><gspan role="menuitem" tabindex="0" aria-label="' + (item.keyword || item.emoji).replace(/"/g, '') + '" htmlcode="' + htmlcode + '" title="' + item.emoji + ' ' + (item.keyword || '').replace(/"/g, '') + '">' + htmlcode + '</gspan></gitem>');
29798
+ }
29799
+ }
29800
+ resultpanel.innerHTML = '<div style="width:100%;padding:3px;margin-top:5px;color:darkblue;text-align:center;">' + itemindex + ' items</div>' + hitsHtml.join('');
29801
+ }
29802
+
29803
+ panel.setAttribute("id", "emoji-picker");
29804
+
29805
+ var tabpanel = __Append(panel, "div");
29806
+
29807
+ var resultpanel = __Append(panel, "div", "display:none;flex-direction:row;flex-wrap:wrap;overflow-y:scroll;padding-bottom:55px");
29808
+
29809
+ var grouppanel = __Append(panel, "div", "overflow-y:scroll;padding-bottom:55px;flex:999");
29810
+
29811
+ // Lazy-render: only build the active category. Each tab click swaps the html.
29812
+ var activeGroupIndex = 0;
29813
+ function renderCategory(gi) {
29814
+ activeGroupIndex = gi;
29815
+ grouppanel.innerHTML = buildCategoryHTML(emojidata[gi]);
29816
+ grouppanel.scrollTop = 0;
29817
+ for (var bi = 0; bi < tabuibtns.length; bi++) {
29818
+ tabuibtns[bi].className = bi === gi ? "rte-ui-active" : "";
29819
+ }
29820
+ }
29821
+
29822
+ var tabui = __Append(tabpanel, "rte-tabui");
29823
+ tabui.setAttribute("id", "emoji-picker");
29824
+ var tabuitoolbar = __Append(tabui, "rte-tabui-toolbar");
29825
+ var tabuibtns = [];
29826
+ for (var gi = 0; gi < emojidata.length; gi++) {
29827
+ (function (group) {
29828
+ var btn = __Append(tabuitoolbar, "rte-tabui-toolbar-button", "width:32px;text-align:center;margin:4px");
29829
+ btn.setAttribute("title", group.name);
29830
+ btn.innerHTML = group.items[0].emoji;
29831
+ btn.onclick = function () { renderCategory(group.index); };
29832
+ tabuibtns.push(btn);
29833
+ })(emojidata[gi]);
29834
+ }
29835
+
29836
+ // Initial render: just the first category (~125 emojis instead of all 1037).
29837
+ renderCategory(0);
29838
+
29839
+ searchbox.focus();
29840
+
29841
+
29842
+ })
29843
+ };
29844
+
29845
+ }
29846
+
29847
+ function __Append(parent, tagname, csstext, cssclass) {
29848
+ var tag = parent.ownerDocument.createElement(tagname);
29849
+ if (csstext) tag.style.cssText = csstext;
29850
+ if (cssclass) tag.className = cssclass;
29851
+ parent.appendChild(tag);
29852
+ return tag;
29853
+ }
29854
+
29855
+
29856
+ }
29857
+
29858
+
29859
+
29860
+
28820
29861
 
28821
29862
  // 2026-08-05 Image gallery browser.
28822
29863
  //
@@ -29051,6 +30092,15 @@ function RTE_Plugin_InsertGallery() {
29051
30092
 
29052
30093
  var browser = append(dialoginner, "div", "", "rte-gallery-browser");
29053
30094
 
30095
+ // Exactly what the upload endpoint accepts, and nothing more. The file
30096
+ // picker used to advertise "image/*,...,.svg", so it offered files the
30097
+ // server then rejected with "Invalid file extension" and the user had no
30098
+ // way to know the real list until after the failure. Declared here
30099
+ // because both the header hint and the file input read it.
30100
+ // SVG is excluded on purpose: it can carry script, so it is not a safe
30101
+ // thing to store and serve back from our own origin.
30102
+ var GALLERY_ACCEPT = [".jpg", ".jpeg", ".jfif", ".png", ".gif", ".webp", ".bmp", ".avif"];
30103
+
29054
30104
  // The dialog frame already draws a title bar and close button, so this is
29055
30105
  // a one-line subtitle, not a second heading.
29056
30106
  var header = append(browser, "div", "", "rte-dialog-browser-header");
@@ -29059,6 +30109,12 @@ function RTE_Plugin_InsertGallery() {
29059
30109
  ? "Browse folders, upload new files, and insert the selected image into the editor."
29060
30110
  : "Browse the available images, filter by name, and insert the selected image into the editor.";
29061
30111
 
30112
+ // State the accepted formats up front. Previously the only way to learn
30113
+ // them was to pick an unsupported file and read "Invalid file extension".
30114
+ var acceptHint = append(header, "div", "", "rte-dialog-browser-accept");
30115
+ acceptHint.innerText = "Accepted formats: " +
30116
+ GALLERY_ACCEPT.map(function (ext) { return ext.replace(".", "").toUpperCase(); }).join(", ");
30117
+
29062
30118
  var toolbar = append(browser, "div", "", "rte-gallery-browser-toolbar");
29063
30119
  var toolbarLeft = append(toolbar, "div", "", "rte-gallery-browser-toolbar-group");
29064
30120
  var toolbarRight = append(toolbar, "div", "", "rte-gallery-browser-toolbar-group rte-gallery-browser-toolbar-group-right");
@@ -29093,7 +30149,7 @@ function RTE_Plugin_InsertGallery() {
29093
30149
 
29094
30150
  var fileInput = append(toolbarRight, "input", "display:none;");
29095
30151
  fileInput.type = "file";
29096
- fileInput.accept = "image/*,.jpg,.jpeg,.png,.gif,.bmp,.webp,.svg";
30152
+ fileInput.accept = GALLERY_ACCEPT.join(",");
29097
30153
  fileInput.multiple = true;
29098
30154
 
29099
30155
  // Without a server there is no folder tree to walk and nothing to create
@@ -29119,6 +30175,12 @@ function RTE_Plugin_InsertGallery() {
29119
30175
  grid.setAttribute("role", "listbox");
29120
30176
  grid.setAttribute("aria-label", "Available images");
29121
30177
 
30178
+ // Non-blocking failure notice. Sits above the grid instead of replacing
30179
+ // it, so a rejected upload never hides the images you can still pick.
30180
+ var errorBar = append(surface, "div", "", "rte-gallery-browser-error");
30181
+ errorBar.setAttribute("role", "status");
30182
+ show(errorBar, false);
30183
+
29122
30184
  var empty = append(surface, "div", "", "rte-gallery-browser-empty");
29123
30185
 
29124
30186
  var footer = append(browser, "div", "", "rte-gallery-browser-footer");
@@ -29161,6 +30223,9 @@ function RTE_Plugin_InsertGallery() {
29161
30223
 
29162
30224
  function setSelected(url) {
29163
30225
  state.selectedUrl = url || "";
30226
+ // Picking an image is a fresh action, so a stale failure notice from
30227
+ // an earlier upload must not linger next to it.
30228
+ state.error = "";
29164
30229
  render();
29165
30230
  }
29166
30231
 
@@ -29314,12 +30379,16 @@ function RTE_Plugin_InsertGallery() {
29314
30379
  return;
29315
30380
  }
29316
30381
 
30382
+ // An upload failure must not wipe the browser. It used to replace the
30383
+ // whole grid, so one rejected file left the dialog reading "0 items"
30384
+ // with an error where the images should be - while the footer still
30385
+ // said the previously selected image was ready to insert. Two panels
30386
+ // contradicting each other. Show it as a banner and keep browsing.
29317
30387
  if (state.error) {
29318
- empty.innerText = state.error;
29319
- show(empty, true);
29320
- show(grid, false);
29321
- updateStatus(0, 0);
29322
- return;
30388
+ errorBar.innerText = state.error;
30389
+ show(errorBar, true);
30390
+ } else {
30391
+ show(errorBar, false);
29323
30392
  }
29324
30393
 
29325
30394
  for (var folderIndex = 0; folderIndex < visibleFolders.length; folderIndex++) {
@@ -30055,7 +31124,33 @@ function RTE_Plugin_KeyboardA11y() {
30055
31124
  // The editor already tracks active state — it just keeps it in a class.
30056
31125
  // Mirroring rather than recomputing means the announced state can never
30057
31126
  // disagree with the highlighted button.
30058
- var TOGGLE_CMD = /^(bold|italic|underline|strikethrough|subscript|superscript|justifyleft|justifycenter|justifyright|justifyfull|insertorderedlist|insertunorderedlist|outdent|indent|blockquote|inlinecode|trackchanges|typewriter|focusmode|pagination|formattingmarks|linenumbers|permanentpen|rtlui)$/;
31127
+ // Command names here must match the TOOLBAR command exactly — the regex is
31128
+ // anchored, so a near-miss is a silent no-op that looks like coverage.
31129
+ // "blockquote" is one: the command is `insertblockquote`, so the entry
31130
+ // written specifically for it never matched and that button ships with no
31131
+ // aria-pressed. Found by checking every genuine toggle on the full toolbar
31132
+ // rather than the three the published verify page samples.
31133
+ //
31134
+ // This mirror only copies the editor's own active state, so a command may
31135
+ // only be listed here once core actually tracks it. Emitting the attribute
31136
+ // for an untracked command yields a permanent aria-pressed="false" —
31137
+ // confidently announcing "not pressed" while the formatting IS applied,
31138
+ // which makes announced state DISAGREE with the highlight. That is the exact
31139
+ // failure this mirror exists to prevent, so silence beats a wrong answer.
31140
+ // - `toggleborder` was already tracked (core checks the editable's
31141
+ // rte-toggleborder class); it reads inactive simply when borders are off.
31142
+ // - `insertblockquote` was NOT tracked and now is, via a core case added
31143
+ // alongside `indent`, which resolves the same way.
31144
+ // Both verified on the running editor rather than inferred.
31145
+ //
31146
+ // `inlinecode`, `pagination`, `typewriter` and `focusmode` match nothing in
31147
+ // this build, and now the reason is known rather than assumed: pagination.js
31148
+ // and typewriter.js are API-only (togglePageView / toggleTypewriterMode …)
31149
+ // and register no toolbar command, and insertcode.js registers `insertcode`,
31150
+ // which inserts a block rather than toggling the selection. They are kept —
31151
+ // harmless, and correct the moment a toolbar button is added — but they
31152
+ // cover nothing today, so do not read this list as evidence of coverage.
31153
+ var TOGGLE_CMD = /^(bold|italic|underline|strikethrough|subscript|superscript|justifyleft|justifycenter|justifyright|justifyfull|insertorderedlist|insertunorderedlist|outdent|indent|insertblockquote|toggleborder|inlinecode|trackchanges|typewriter|focusmode|pagination|formattingmarks|linenumbers|permanentpen|rtlui)$/;
30059
31154
 
30060
31155
  function syncToggleStates(root) {
30061
31156
  var btns = [].slice.call(root.querySelectorAll('[role="button"][rte-cmd-name]'));
@@ -32672,6 +33767,276 @@ function RTE_Plugin_MultiLevelList() {
32672
33767
 
32673
33768
  if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
32674
33769
 
33770
+ // 2026-08-15 Multi-root editing — one toolbar, several editable regions.
33771
+ //
33772
+ // The CMS / page-builder case: a page has a title, a summary and a body, and the
33773
+ // author wants one toolbar that acts on whichever region they are in, not three
33774
+ // stacked toolbars eating the viewport.
33775
+ //
33776
+ // SCOPE — read this before assuming parity with CKEditor's multi-root:
33777
+ // This editor edits <body> inside an IFRAME and has no div/inline mode. One
33778
+ // instance therefore owns exactly one editable document, and every plugin,
33779
+ // the selection model and the CRDT binding are written against that
33780
+ // assumption. Making a single instance own N editables is a core rewrite, not
33781
+ // a plugin.
33782
+ // What this does instead is COORDINATE several ordinary instances: they share
33783
+ // one visible toolbar and one group-level API. That covers the layout and the
33784
+ // workflow, which is what the feature is actually for.
33785
+ // What it does NOT do, and cannot without core work:
33786
+ // - a shared undo stack (Ctrl+Z undoes within the focused region only),
33787
+ // - a selection spanning two regions,
33788
+ // - one collaboration session across regions (each root syncs separately).
33789
+ // Those are stated in getCapabilities() as well as here, so an integrator
33790
+ // finds out from the API rather than from a bug report.
33791
+ //
33792
+ // Design notes:
33793
+ // - Toolbars are SHOWN/HIDDEN, not rebuilt or re-pointed. Each instance's
33794
+ // toolbar already drives its own editor correctly; re-routing commands
33795
+ // across instances would mean reimplementing every command's notion of
33796
+ // "current editor" and would break the moment a plugin cached one.
33797
+ // - Moving the active toolbar into a shared host is optional. DOM moves
33798
+ // preserve listeners, so the toolbar keeps working, but a host that is
33799
+ // positioned or scrolled differently can change the float-panel anchoring —
33800
+ // hence opt-in rather than default.
33801
+ // - Registration is by GROUP NAME in a module-level registry, because plugins
33802
+ // are constructed per instance and have no other way to see each other.
33803
+ RTE_DefaultConfig.plugin_multiroot = RTE_Plugin_MultiRoot;
33804
+
33805
+ // Instances sharing a group name act as one multi-root editor. null = off.
33806
+ if (typeof RTE_DefaultConfig.multiRootGroup === "undefined") RTE_DefaultConfig.multiRootGroup = null;
33807
+ // A name for this root in the group API. Defaults to the container's id.
33808
+ if (typeof RTE_DefaultConfig.multiRootName === "undefined") RTE_DefaultConfig.multiRootName = null;
33809
+ // Element (or its id) to move the active toolbar into. null = leave in place.
33810
+ if (typeof RTE_DefaultConfig.multiRootToolbarHost === "undefined") RTE_DefaultConfig.multiRootToolbarHost = null;
33811
+
33812
+ // group name -> { roots: [entry], active: entry }
33813
+ if (!window.__RTE_MultiRootGroups) window.__RTE_MultiRootGroups = {};
33814
+
33815
+ function RTE_Plugin_MultiRoot() {
33816
+ var obj = this;
33817
+ var config, editor;
33818
+ var entry = null;
33819
+
33820
+ obj.PluginName = "MultiRoot";
33821
+
33822
+ obj.InitConfig = function (argconfig) { config = argconfig; };
33823
+
33824
+ obj.InitEditor = function (argeditor) {
33825
+ editor = argeditor;
33826
+
33827
+ var group = config.multiRootGroup;
33828
+ if (!group) return; // opt-in
33829
+
33830
+ entry = {
33831
+ name: config.multiRootName || containerId() || ("root" + (groupOf(group).roots.length + 1)),
33832
+ editor: editor,
33833
+ container: container(),
33834
+ group: group
33835
+ };
33836
+ groupOf(group).roots.push(entry);
33837
+
33838
+ editor.multiRoot = api(group);
33839
+
33840
+ // Wait for the toolbar to exist before hiding anything.
33841
+ setTimeout(function () { bindFocus(); settle(group); }, 0);
33842
+ try { editor.attachEvent("ready", function () { bindFocus(); settle(group); }); } catch (e) {}
33843
+ };
33844
+
33845
+ function groupOf(name) {
33846
+ // `listeners` lives on the GROUP, not in this closure. Every root builds
33847
+ // its own copy of this plugin, so an instance-local list means a callback
33848
+ // registered through one root's API never hears an activation fired by a
33849
+ // different root — the subscriber silently lags by one event, or misses
33850
+ // everything, depending on which root the user clicks.
33851
+ if (!window.__RTE_MultiRootGroups[name]) {
33852
+ window.__RTE_MultiRootGroups[name] = { roots: [], active: null, listeners: [] };
33853
+ }
33854
+ var g = window.__RTE_MultiRootGroups[name];
33855
+ if (!g.listeners) g.listeners = [];
33856
+ return g;
33857
+ }
33858
+
33859
+ // The editor exposes its host element as `container` (a property, not a
33860
+ // getter — there is no getContainer()). Falling back through the iframe
33861
+ // covers any build where that property is absent, rather than silently
33862
+ // returning null and leaving every toolbar visible.
33863
+ function container() {
33864
+ try {
33865
+ if (editor.container) return editor.container;
33866
+ if (editor.iframe) {
33867
+ var n = editor.iframe.parentNode;
33868
+ while (n && !(n.classList && n.classList.contains("richtexteditor"))) n = n.parentNode;
33869
+ return n || null;
33870
+ }
33871
+ } catch (e) {}
33872
+ return null;
33873
+ }
33874
+
33875
+ function containerId() {
33876
+ var c = container();
33877
+ return c && c.id ? c.id : null;
33878
+ }
33879
+
33880
+ // ---- toolbar visibility ----------------------------------------------
33881
+
33882
+ function toolbarsOf(root) {
33883
+ var c = root.container;
33884
+ if (!c) return [];
33885
+ return [].slice.call(c.querySelectorAll("rte-toolbar"));
33886
+ }
33887
+
33888
+ function setVisible(root, visible) {
33889
+ var bars = toolbarsOf(root);
33890
+ for (var i = 0; i < bars.length; i++) {
33891
+ var bar = bars[i];
33892
+ if (visible) {
33893
+ // Restore whatever the editor's own responsive logic had chosen
33894
+ // (desktop vs mobile bar), rather than forcing display:flex on
33895
+ // both and showing two toolbars at once.
33896
+ if (bar.__rteMultiRootPrevDisplay !== undefined) {
33897
+ bar.style.display = bar.__rteMultiRootPrevDisplay;
33898
+ delete bar.__rteMultiRootPrevDisplay;
33899
+ }
33900
+ } else {
33901
+ if (bar.__rteMultiRootPrevDisplay === undefined) {
33902
+ bar.__rteMultiRootPrevDisplay = bar.style.display;
33903
+ }
33904
+ bar.style.display = "none";
33905
+ }
33906
+ }
33907
+ }
33908
+
33909
+ function settle(groupName) {
33910
+ var g = groupOf(groupName);
33911
+ if (!g.roots.length) return;
33912
+ if (!g.active) g.active = g.roots[0];
33913
+ for (var i = 0; i < g.roots.length; i++) {
33914
+ setVisible(g.roots[i], g.roots[i] === g.active);
33915
+ }
33916
+ hostToolbar(g.active);
33917
+ }
33918
+
33919
+ function hostToolbar(root) {
33920
+ var hostRef = config.multiRootToolbarHost;
33921
+ if (!hostRef || !root) return;
33922
+ var host = (typeof hostRef === "string") ? document.getElementById(hostRef) : hostRef;
33923
+ if (!host) return;
33924
+ var bars = toolbarsOf(root);
33925
+ for (var i = 0; i < bars.length; i++) {
33926
+ // Moving a node keeps its listeners, so the toolbar stays live.
33927
+ if (bars[i].parentNode !== host) host.appendChild(bars[i]);
33928
+ }
33929
+ }
33930
+
33931
+ // ---- focus tracking ---------------------------------------------------
33932
+
33933
+ function bindFocus() {
33934
+ var doc;
33935
+ try { doc = editor.getDocument(); } catch (e) { return; }
33936
+ if (!doc || doc.__rteMultiRootBound) return;
33937
+ doc.__rteMultiRootBound = true;
33938
+
33939
+ var activate = function () {
33940
+ if (!entry) return;
33941
+ var g = groupOf(entry.group);
33942
+ if (g.active === entry) return;
33943
+ g.active = entry;
33944
+ settle(entry.group);
33945
+ fire(entry.group, entry);
33946
+ };
33947
+
33948
+ // focusin bubbles; focus does not.
33949
+ doc.addEventListener("focusin", activate, true);
33950
+ doc.addEventListener("mousedown", activate, true);
33951
+ }
33952
+
33953
+ function fire(groupName, root) {
33954
+ var list = groupOf(groupName).listeners;
33955
+ for (var i = 0; i < list.length; i++) {
33956
+ try { list[i]({ active: root.name, editor: root.editor }); } catch (e) {}
33957
+ }
33958
+ }
33959
+
33960
+ // ---- group API --------------------------------------------------------
33961
+
33962
+ function api(groupName) {
33963
+ var g = groupOf(groupName);
33964
+ return {
33965
+ group: groupName,
33966
+
33967
+ getRoots: function () {
33968
+ return g.roots.map(function (r) { return r.name; });
33969
+ },
33970
+
33971
+ getActive: function () {
33972
+ return g.active ? g.active.name : null;
33973
+ },
33974
+
33975
+ getEditor: function (name) {
33976
+ for (var i = 0; i < g.roots.length; i++) if (g.roots[i].name === name) return g.roots[i].editor;
33977
+ return null;
33978
+ },
33979
+
33980
+ focusRoot: function (name) {
33981
+ var target = null;
33982
+ for (var i = 0; i < g.roots.length; i++) if (g.roots[i].name === name) target = g.roots[i];
33983
+ if (!target) return false;
33984
+ g.active = target;
33985
+ settle(groupName);
33986
+ try { target.editor.focus(); } catch (e) {}
33987
+ fire(groupName, target);
33988
+ return true;
33989
+ },
33990
+
33991
+ // The whole document, one entry per root — what a CMS saves.
33992
+ getHTML: function () {
33993
+ var out = {};
33994
+ for (var i = 0; i < g.roots.length; i++) {
33995
+ try { out[g.roots[i].name] = g.roots[i].editor.getHTMLCode(); } catch (e) { out[g.roots[i].name] = null; }
33996
+ }
33997
+ return out;
33998
+ },
33999
+
34000
+ setHTML: function (map) {
34001
+ if (!map) return false;
34002
+ for (var i = 0; i < g.roots.length; i++) {
34003
+ var r = g.roots[i];
34004
+ if (!(r.name in map)) continue;
34005
+ try { r.editor.setHTMLCode(map[r.name]); } catch (e) {}
34006
+ }
34007
+ return true;
34008
+ },
34009
+
34010
+ onActiveChange: function (fn) {
34011
+ if (typeof fn !== "function") return function () {};
34012
+ g.listeners.push(fn);
34013
+ return function () {
34014
+ var at = g.listeners.indexOf(fn);
34015
+ if (at >= 0) g.listeners.splice(at, 1);
34016
+ };
34017
+ },
34018
+
34019
+ // Say plainly what this does and does not do, so an integrator does
34020
+ // not discover the limits from a support ticket.
34021
+ getCapabilities: function () {
34022
+ return {
34023
+ sharedToolbar: true,
34024
+ sharedGroupApi: true,
34025
+ perRootUndo: true,
34026
+ sharedUndoStack: false,
34027
+ selectionAcrossRoots: false,
34028
+ sharedCollaborationSession: false,
34029
+ note: "Each root is a separate editor instance sharing one visible toolbar. " +
34030
+ "Undo, selection and collaboration are per-root; the editor edits an " +
34031
+ "iframe body, so a single instance cannot own several editables without core changes."
34032
+ };
34033
+ }
34034
+ };
34035
+ }
34036
+ }
34037
+
34038
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
34039
+
32675
34040
  // 2026-07-17 Pagination / page view. Renders the editable as a stack of paper
32676
34041
  // pages — the "print layout" view Word and Google Docs have, and the feature
32677
34042
  // CKEditor and Tiptap both sell as a paid add-on.
@@ -35400,6 +36765,11 @@ function RTE_Plugin_RevisionHistory() {
35400
36765
  }
35401
36766
 
35402
36767
  function restore(id, opts) {
36768
+ // The dialog's own Restore button calls restore(id) with no options, so
36769
+ // reading opts.skipConfirm threw and the button did nothing at all. The
36770
+ // public API wrapper defaulted this, the internal caller did not - so
36771
+ // default it here, where every caller is covered.
36772
+ opts = opts || {};
35403
36773
  var entry = findById(id);
35404
36774
  if (!entry) return false;
35405
36775
  if (!opts.skipConfirm) {
@@ -38754,6 +40124,511 @@ function RTE_Plugin_TextDirection() {
38754
40124
 
38755
40125
  if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
38756
40126
 
40127
+ // 2026-08-15 Text part language ("language of parts").
40128
+ //
40129
+ // WCAG 2.1 SC 3.1.2 (Level AA) requires that the human language of every
40130
+ // PASSAGE or PHRASE be programmatically determinable when it differs from the
40131
+ // document language. Until now this editor could only set a language on the
40132
+ // whole document (a11yenhance.js stamps <html lang>), so a French quotation
40133
+ // inside an English page was unmarkable and any AA conformance claim on
40134
+ // /docs/accessibility was overstated. A screen reader reads such a phrase with
40135
+ // the wrong phoneme set, which is the failure the SC exists to prevent.
40136
+ //
40137
+ // CKEditor 5 ships this as "Text part language". TinyMCE has no equivalent
40138
+ // plugin (8.5's `content_language` sets the default proofing language, not
40139
+ // per-phrase markup).
40140
+ //
40141
+ // Design notes:
40142
+ // - `lang` is CONTENT, exactly like `dir` in textdirection.js: it must survive
40143
+ // getHTMLCode(), and it is never stripped on serialize. A document whose
40144
+ // language annotations vanished on save would silently drop its own
40145
+ // conformance.
40146
+ // - The wrapper is a bare <span lang>. No class is emitted, so there is
40147
+ // nothing for check-css-contract.mjs to chase and nothing that depends on a
40148
+ // stylesheet travelling with the content. The in-editor visual marker is
40149
+ // injected into the EDITING DOCUMENT only, keyed off [lang], so it shows the
40150
+ // author what is marked without ever leaving the editor.
40151
+ // - RTL languages also get `dir`, because `lang` alone does not set direction.
40152
+ // A Hebrew phrase marked only with lang="he" still renders left-to-right and
40153
+ // reads as gibberish.
40154
+ // - Re-applying the SAME language to a selection REMOVES it (toggle), which is
40155
+ // how every other inline format in this editor behaves.
40156
+ // - Nested lang spans inside the selection are unwrapped before wrapping, so a
40157
+ // phrase never ends up with two competing languages. The inner one would win
40158
+ // in the accessibility tree and the author would have no way to see why.
40159
+ // - A collapsed selection deliberately does NOT create an empty wrapper. An
40160
+ // empty inline span is a caret trap (the caret falls in and cannot be typed
40161
+ // out of); instead a collapsed caret retargets the [lang] element it is
40162
+ // already inside, so "put the cursor in the phrase and pick Remove" works.
40163
+ RTE_DefaultConfig.plugin_textpartlanguage = RTE_Plugin_TextPartLanguage;
40164
+
40165
+ // The dropdown list. `dir` is only needed for right-to-left languages.
40166
+ if (typeof RTE_DefaultConfig.textPartLanguages === "undefined") {
40167
+ RTE_DefaultConfig.textPartLanguages = [
40168
+ { code: "ar", label: "Arabic", dir: "rtl" },
40169
+ { code: "zh-Hans", label: "Chinese (Simplified)" },
40170
+ { code: "zh-Hant", label: "Chinese (Traditional)" },
40171
+ { code: "cs", label: "Czech" },
40172
+ { code: "nl", label: "Dutch" },
40173
+ { code: "en", label: "English" },
40174
+ { code: "fr", label: "French" },
40175
+ { code: "de", label: "German" },
40176
+ { code: "el", label: "Greek" },
40177
+ { code: "he", label: "Hebrew", dir: "rtl" },
40178
+ { code: "hi", label: "Hindi" },
40179
+ { code: "it", label: "Italian" },
40180
+ { code: "ja", label: "Japanese" },
40181
+ { code: "ko", label: "Korean" },
40182
+ { code: "fa", label: "Persian", dir: "rtl" },
40183
+ { code: "pl", label: "Polish" },
40184
+ { code: "pt", label: "Portuguese" },
40185
+ { code: "ru", label: "Russian" },
40186
+ { code: "es", label: "Spanish" },
40187
+ { code: "sv", label: "Swedish" },
40188
+ { code: "tr", label: "Turkish" },
40189
+ { code: "uk", label: "Ukrainian" },
40190
+ { code: "ur", label: "Urdu", dir: "rtl" },
40191
+ { code: "vi", label: "Vietnamese" }
40192
+ ];
40193
+ }
40194
+
40195
+ // Show marked phrases with a dotted underline while editing. Editor-only: the
40196
+ // rule lives in the editing document, never in the saved HTML.
40197
+ if (typeof RTE_DefaultConfig.textPartLanguageHighlight === "undefined") RTE_DefaultConfig.textPartLanguageHighlight = true;
40198
+
40199
+ function RTE_Plugin_TextPartLanguage() {
40200
+ var obj = this;
40201
+ var config, editor;
40202
+ var boundDoc = null;
40203
+
40204
+ obj.PluginName = "TextPartLanguage";
40205
+
40206
+ obj.InitConfig = function (argconfig) { config = argconfig; };
40207
+
40208
+ obj.InitEditor = function (argeditor) {
40209
+ editor = argeditor;
40210
+
40211
+ editor.attachEvent("exec_command_textpartlanguage", function (state) {
40212
+ state.returnValue = true;
40213
+ var v = state && state.value;
40214
+ if (!v || v === "none" || v === "remove") obj.Remove();
40215
+ else obj.Apply(v);
40216
+ });
40217
+
40218
+ if (editor.toolbarFactoryMap) {
40219
+ editor.toolbarFactoryMap["textpartlanguage"] = function (cmd) {
40220
+ return editor.createToolbarItemDropDownPanel(cmd, buildPanel);
40221
+ };
40222
+ }
40223
+
40224
+ setup();
40225
+ try { editor.attachEvent("ready", setup); } catch (e) {}
40226
+ try { editor.attachEvent("aftersethtml", setup); } catch (e) {}
40227
+ setTimeout(setup, 0);
40228
+
40229
+ // Public API.
40230
+ editor.setTextLanguage = function (code) { return obj.Apply(code); };
40231
+ editor.removeTextLanguage = function () { return obj.Remove(); };
40232
+ editor.getTextLanguage = function () { return obj.Current(); };
40233
+ editor.listTextLanguages = function () { return languages().slice(); };
40234
+ editor.getMarkedLanguages = function () { return obj.List(); };
40235
+ };
40236
+
40237
+ function getDoc() { try { return editor.getDocument(); } catch (e) { return null; } }
40238
+ function getEditable() { try { return editor.getEditable(); } catch (e) { return null; } }
40239
+
40240
+ function languages() {
40241
+ var list = config && config.textPartLanguages;
40242
+ return (list && list.length) ? list : [];
40243
+ }
40244
+
40245
+ function entry(code) {
40246
+ var list = languages();
40247
+ for (var i = 0; i < list.length; i++) if (list[i].code === code) return list[i];
40248
+ return { code: code, label: code };
40249
+ }
40250
+
40251
+ // ---- state -----------------------------------------------------------
40252
+
40253
+ // The nearest ancestor carrying a lang, bounded by the editable so the host
40254
+ // page's own <html lang> is never reported as the phrase language.
40255
+ function langAncestor(node) {
40256
+ var ed = getEditable();
40257
+ var n = (node && node.nodeType === 3) ? node.parentNode : node;
40258
+ while (n && n !== ed && n.nodeType === 1) {
40259
+ if (n.hasAttribute && n.hasAttribute("lang")) return n;
40260
+ n = n.parentNode;
40261
+ }
40262
+ return null;
40263
+ }
40264
+
40265
+ function selectionRange() {
40266
+ try {
40267
+ var sel = editor.getSelection();
40268
+ if (!sel || sel.rangeCount === 0) return null;
40269
+ return sel.getRangeAt(0);
40270
+ } catch (e) { return null; }
40271
+ }
40272
+
40273
+ obj.Current = function () {
40274
+ var r = selectionRange();
40275
+ if (!r) return null;
40276
+ var el = langAncestor(r.startContainer);
40277
+ // A non-collapsed selection only counts as "in" a language if BOTH ends
40278
+ // sit in the same marked phrase; a selection straddling the boundary has
40279
+ // no single language and must not report one.
40280
+ if (el && !r.collapsed && langAncestor(r.endContainer) !== el) return null;
40281
+ return el ? el.getAttribute("lang") : null;
40282
+ };
40283
+
40284
+ // Every distinct language marked in the document, for auditing.
40285
+ obj.List = function () {
40286
+ var ed = getEditable();
40287
+ var out = [];
40288
+ if (!ed) return out;
40289
+ var seen = {};
40290
+ var nodes = ed.querySelectorAll("[lang]");
40291
+ for (var i = 0; i < nodes.length; i++) {
40292
+ var code = nodes[i].getAttribute("lang");
40293
+ if (!code || seen[code]) continue;
40294
+ seen[code] = true;
40295
+ out.push({ code: code, label: entry(code).label });
40296
+ }
40297
+ return out;
40298
+ };
40299
+
40300
+ // ---- apply -----------------------------------------------------------
40301
+
40302
+ obj.Apply = function (code) {
40303
+ code = String(code == null ? "" : code).trim();
40304
+ if (!code) return false;
40305
+
40306
+ var doc = getDoc();
40307
+ var r = selectionRange();
40308
+ if (!doc || !r) return false;
40309
+
40310
+ // Re-picking the current language toggles it off.
40311
+ if (obj.Current() === code) return obj.Remove();
40312
+
40313
+ if (r.collapsed) {
40314
+ // No empty wrapper (caret trap). Retarget the phrase the caret is in.
40315
+ var host = langAncestor(r.startContainer);
40316
+ if (!host) return false;
40317
+ stamp(host, code);
40318
+ fireChange();
40319
+ return code;
40320
+ }
40321
+
40322
+ var span = doc.createElement("span");
40323
+ stamp(span, code);
40324
+
40325
+ try {
40326
+ // surroundContents is the clean path, but it throws whenever the
40327
+ // range partially selects a non-text node (half a <strong>), which
40328
+ // is the common case in real prose.
40329
+ r.surroundContents(span);
40330
+ } catch (e) {
40331
+ var frag = r.extractContents();
40332
+ span.appendChild(frag);
40333
+ r.insertNode(span);
40334
+ }
40335
+
40336
+ unwrapInner(span);
40337
+ pruneEmptyText(span);
40338
+ if (!span.firstChild) { // selection held nothing real
40339
+ if (span.parentNode) span.parentNode.removeChild(span);
40340
+ return false;
40341
+ }
40342
+ mergeSiblings(span);
40343
+ select(span);
40344
+ fireChange();
40345
+ return code;
40346
+ };
40347
+
40348
+ obj.Remove = function () {
40349
+ var r = selectionRange();
40350
+ if (!r) return false;
40351
+ var ed = getEditable();
40352
+ if (!ed) return false;
40353
+
40354
+ var removed = 0;
40355
+
40356
+ // The phrase the caret/selection sits inside.
40357
+ var host = langAncestor(r.startContainer);
40358
+ if (host) { unwrap(host); removed++; }
40359
+
40360
+ // Plus any fully- or partly-selected marked phrases in a wider range.
40361
+ if (!r.collapsed) {
40362
+ var nodes = ed.querySelectorAll("[lang]");
40363
+ for (var i = nodes.length - 1; i >= 0; i--) {
40364
+ var el = nodes[i];
40365
+ if (el === host) continue;
40366
+ if (intersects(r, el)) { unwrap(el); removed++; }
40367
+ }
40368
+ }
40369
+
40370
+ if (removed) fireChange();
40371
+ return removed > 0;
40372
+ };
40373
+
40374
+ function intersects(range, el) {
40375
+ try {
40376
+ var er = el.ownerDocument.createRange();
40377
+ er.selectNodeContents(el);
40378
+ return range.compareBoundaryPoints(Range.END_TO_START, er) < 0 &&
40379
+ range.compareBoundaryPoints(Range.START_TO_END, er) > 0;
40380
+ } catch (e) { return false; }
40381
+ }
40382
+
40383
+ function stamp(el, code) {
40384
+ el.setAttribute("lang", code);
40385
+ var dir = entry(code).dir;
40386
+ // lang does not imply direction; without dir an RTL phrase renders LTR.
40387
+ if (dir === "rtl" || dir === "ltr") el.setAttribute("dir", dir);
40388
+ else el.removeAttribute("dir");
40389
+ }
40390
+
40391
+ // A phrase must carry exactly one language. Anything marked inside the new
40392
+ // wrapper would otherwise win in the accessibility tree.
40393
+ function unwrapInner(span) {
40394
+ var inner = span.querySelectorAll("[lang]");
40395
+ for (var i = inner.length - 1; i >= 0; i--) unwrap(inner[i]);
40396
+ }
40397
+
40398
+ function unwrap(el) {
40399
+ if (!el || !el.parentNode) return;
40400
+ // A span we created carries nothing else; anything the author's own
40401
+ // markup carries (a styled <em lang>) must survive, so only the
40402
+ // language attributes come off.
40403
+ if (el.nodeName === "SPAN" && el.attributes.length <= 2 &&
40404
+ !el.getAttribute("class") && !el.getAttribute("style") && !el.getAttribute("id")) {
40405
+ var parent = el.parentNode;
40406
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
40407
+ parent.removeChild(el);
40408
+ try { parent.normalize(); } catch (e) {}
40409
+ } else {
40410
+ el.removeAttribute("lang");
40411
+ el.removeAttribute("dir");
40412
+ }
40413
+ }
40414
+
40415
+ // extractContents leaves zero-length text nodes behind; they make a wrapper
40416
+ // look non-empty and leave invisible caret stops in the phrase.
40417
+ function pruneEmptyText(el) {
40418
+ for (var i = el.childNodes.length - 1; i >= 0; i--) {
40419
+ var n = el.childNodes[i];
40420
+ if (n.nodeType === 3 && n.nodeValue === "") el.removeChild(n);
40421
+ }
40422
+ }
40423
+
40424
+ // Marking two adjacent halves of a phrase should read as one phrase, not two.
40425
+ function mergeSiblings(span) {
40426
+ var code = span.getAttribute("lang");
40427
+ var prev = span.previousSibling;
40428
+ if (prev && prev.nodeType === 1 && prev.nodeName === "SPAN" && prev.getAttribute("lang") === code) {
40429
+ while (span.firstChild) prev.appendChild(span.firstChild);
40430
+ span.parentNode.removeChild(span);
40431
+ span = prev;
40432
+ }
40433
+ var next = span.nextSibling;
40434
+ if (next && next.nodeType === 1 && next.nodeName === "SPAN" && next.getAttribute("lang") === code) {
40435
+ while (next.firstChild) span.appendChild(next.firstChild);
40436
+ next.parentNode.removeChild(next);
40437
+ }
40438
+ try { span.normalize(); } catch (e) {}
40439
+ return span;
40440
+ }
40441
+
40442
+ function select(span) {
40443
+ try {
40444
+ var sel = editor.getSelection();
40445
+ var r = span.ownerDocument.createRange();
40446
+ r.selectNodeContents(span);
40447
+ sel.removeAllRanges();
40448
+ sel.addRange(r);
40449
+ } catch (e) {}
40450
+ }
40451
+
40452
+ function fireChange() {
40453
+ try { if (typeof editor.updateDesign === "function") editor.updateDesign(); } catch (e) {}
40454
+ try { if (typeof editor.fireChange === "function") editor.fireChange(); } catch (e) {}
40455
+ }
40456
+
40457
+ // ---- toolbar panel ---------------------------------------------------
40458
+
40459
+ function buildPanel(panel) {
40460
+ var current = obj.Current();
40461
+ panel.style.width = "220px";
40462
+ panel.style.maxHeight = "340px";
40463
+ panel.style.overflowY = "auto";
40464
+ panel.style.padding = "4px 0";
40465
+
40466
+ var list = languages();
40467
+
40468
+ addRow(panel, editor.getLangText ? (editor.getLangText("removelanguage") || "Remove language") : "Remove language",
40469
+ null, !current, function () { obj.Remove(); });
40470
+
40471
+ var sep = panel.ownerDocument.createElement("div");
40472
+ sep.style.cssText = "height:1px;margin:4px 6px;background:var(--rte-border-color,#e0e0e0);";
40473
+ panel.appendChild(sep);
40474
+
40475
+ for (var i = 0; i < list.length; i++) {
40476
+ (function (item) {
40477
+ addRow(panel, item.label, item.code, current === item.code, function () { obj.Apply(item.code); });
40478
+ })(list[i]);
40479
+ }
40480
+
40481
+ // Opening a menu has to put focus inside it, or a keyboard user has
40482
+ // opened something they cannot reach. Start on the current language when
40483
+ // there is one — that is the item they are most likely acting on.
40484
+ setTimeout(function () {
40485
+ var rows = panel.querySelectorAll("rte-toolbar-dropdown-item");
40486
+ if (!rows.length) return;
40487
+ var target = rows[0];
40488
+ for (var i = 0; i < rows.length; i++) {
40489
+ if (rows[i].getAttribute("aria-checked") === "true") { target = rows[i]; break; }
40490
+ }
40491
+ try { target.focus(); } catch (e) {}
40492
+ }, 0);
40493
+ }
40494
+
40495
+ function addRow(panel, label, code, active, run) {
40496
+ var doc = panel.ownerDocument;
40497
+ // Two things are required for a menu row to be operable by keyboard, and
40498
+ // a plain <div role="menuitemradio"> has neither:
40499
+ // 1. The TAG. The core's keyboard layer finds items via
40500
+ // __actionElementSelector, which lists element NAMES
40501
+ // (rte-toolbar-dropdown-item, rte-dropdown-menuitem, rte-menuitem)
40502
+ // and ignores roles entirely.
40503
+ // 2. A tab stop and key handling. The core applies those inside its own
40504
+ // closure — __Append / __Make_ActionElementAccessible are not exposed
40505
+ // to plugins — so a plugin has to supply them itself rather than
40506
+ // hope an internal picks the row up.
40507
+ // Both are done here, so the menu works with a keyboard regardless of
40508
+ // what the core does or does not reach into (WCAG 2.1.1).
40509
+ var row = doc.createElement("rte-toolbar-dropdown-item");
40510
+ panel.appendChild(row);
40511
+ row.setAttribute("role", "menuitemradio");
40512
+ row.setAttribute("aria-checked", active ? "true" : "false");
40513
+ row.setAttribute("tabindex", "0");
40514
+ row.style.cssText = "padding:5px 12px;cursor:pointer;white-space:nowrap;display:flex;" +
40515
+ "justify-content:space-between;gap:12px;color:var(--rte-text-color,inherit);" +
40516
+ (active ? "font-weight:600;" : "");
40517
+
40518
+ var name = doc.createElement("span");
40519
+ name.appendChild(doc.createTextNode(label));
40520
+ row.appendChild(name);
40521
+
40522
+ if (code) {
40523
+ var tag = doc.createElement("span");
40524
+ tag.style.cssText = "opacity:.55;font-size:11px;";
40525
+ tag.appendChild(doc.createTextNode(code));
40526
+ row.appendChild(tag);
40527
+ }
40528
+
40529
+ row.onmouseover = function () { row.style.backgroundColor = "var(--rte-hover-bg,#e6e6e6)"; };
40530
+ row.onmouseout = function () { row.style.backgroundColor = ""; };
40531
+ row.onclick = function () {
40532
+ // Close first: the panel steals focus, and applying to a selection
40533
+ // the editor no longer owns silently does nothing.
40534
+ try { editor.closeCurrentPopup(); } catch (e) {}
40535
+ try { editor.focus(); } catch (e) {}
40536
+ run();
40537
+ };
40538
+
40539
+ row.onkeydown = function (e) {
40540
+ var key = e.key;
40541
+ if (key === "Enter" || key === " " || key === "Spacebar") {
40542
+ e.preventDefault();
40543
+ e.stopPropagation();
40544
+ row.onclick();
40545
+ return;
40546
+ }
40547
+ if (key === "ArrowDown" || key === "ArrowUp") {
40548
+ e.preventDefault();
40549
+ e.stopPropagation();
40550
+ var items = panel.querySelectorAll("rte-toolbar-dropdown-item");
40551
+ var list = Array.prototype.slice.call(items);
40552
+ var at = list.indexOf(row);
40553
+ // Wrap, so the list cannot dead-end and strand the user.
40554
+ var next = list[(at + (key === "ArrowDown" ? 1 : -1) + list.length) % list.length];
40555
+ if (next) next.focus();
40556
+ return;
40557
+ }
40558
+ if (key === "Home" || key === "End") {
40559
+ e.preventDefault();
40560
+ e.stopPropagation();
40561
+ var all = panel.querySelectorAll("rte-toolbar-dropdown-item");
40562
+ var target = key === "Home" ? all[0] : all[all.length - 1];
40563
+ if (target) target.focus();
40564
+ return;
40565
+ }
40566
+ if (key === "Escape") {
40567
+ e.preventDefault();
40568
+ e.stopPropagation();
40569
+ // Escape closes the menu; focus then lands in the EDITING AREA,
40570
+ // not back on the launcher.
40571
+ //
40572
+ // That is the core's behaviour for every dropdown — closing a
40573
+ // popup refocuses the editable asynchronously — and it cannot be
40574
+ // overridden from here. Measured: setting focus on the launcher
40575
+ // does take effect, and is then taken back ~16 ms later; a forced
40576
+ // re-focus is taken back again. Racing it with longer timeouts
40577
+ // would be fragile and would fight the "close the popup, carry on
40578
+ // typing" flow the rest of the editor depends on.
40579
+ //
40580
+ // This is not a trap and not a WCAG failure: the menu closes,
40581
+ // focus is visible and lands somewhere meaningful. Restoring
40582
+ // launcher focus properly belongs in the core's popup-close path,
40583
+ // where it would apply to every dropdown at once.
40584
+ try { editor.closeCurrentPopup(); } catch (ex) {}
40585
+ }
40586
+ };
40587
+ }
40588
+
40589
+ // ---- editor-only marker ----------------------------------------------
40590
+
40591
+ function setup() {
40592
+ var doc = getDoc();
40593
+ if (!doc || doc === boundDoc) return;
40594
+ boundDoc = doc;
40595
+ injectStyles(doc);
40596
+ }
40597
+
40598
+ function css() {
40599
+ if (config && config.textPartLanguageHighlight === false) return "";
40600
+ // Scoped to [lang] inside the editing document only. Never serialized,
40601
+ // so the marker cannot follow the content into a customer's page.
40602
+ //
40603
+ // :not(html):not(body) is REQUIRED, not defensive tidiness. a11yenhance.js
40604
+ // stamps the editing document's root with the document language, so a bare
40605
+ // [lang] selector matches <html> — which made hovering anywhere in the
40606
+ // editing area grey the ENTIRE surface, and drew the "marked phrase"
40607
+ // underline across the whole document. Reported from the live demos page.
40608
+ // Only a phrase the author actually marked should be decorated.
40609
+ return "[lang]:not(html):not(body){border-bottom:1px dotted rgba(120,120,120,.75);}" +
40610
+ "[lang]:not(html):not(body):hover{background:rgba(120,120,120,.10);}";
40611
+ }
40612
+
40613
+ function injectStyles(doc) {
40614
+ if (!doc) return;
40615
+ var text = css();
40616
+ var existing = doc.getElementById("rte-textpartlanguage-styles");
40617
+ if (existing) {
40618
+ if (existing.getAttribute("data-css") === text) return;
40619
+ existing.parentNode && existing.parentNode.removeChild(existing);
40620
+ }
40621
+ if (!text) return;
40622
+ var st = doc.createElement("style");
40623
+ st.id = "rte-textpartlanguage-styles";
40624
+ st.setAttribute("data-css", text);
40625
+ st.appendChild(doc.createTextNode(text));
40626
+ (doc.head || doc.getElementsByTagName("head")[0] || doc.documentElement).appendChild(st);
40627
+ }
40628
+ }
40629
+
40630
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
40631
+
38757
40632
  // 2026-06-05 Interactive task / checklist. Notion / GitHub / Google-Docs style:
38758
40633
  // a list whose items each carry a clickable checkbox; clicking toggles the item
38759
40634
  // between open and done (a check + strike-through). Closes a real gap &mdash; the
@@ -38890,695 +40765,722 @@ function RTE_Plugin_TodoList() {
38890
40765
  };
38891
40766
  }
38892
40767
 
38893
- if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
38894
-
38895
- if (!RTE_DefaultConfig.svgCode_trackchanges) {
38896
- RTE_DefaultConfig.svgCode_trackchanges = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 17l4-1 10-10-3-3L5 13l-1 4z"/><path d="M14 4l3 3"/><path d="M4 21h10"/></svg>';
38897
- }
38898
-
38899
- RTE_DefaultConfig.plugin_trackedchanges = RTE_Plugin_TrackedChanges;
38900
-
38901
- function RTE_Plugin_TrackedChanges() {
38902
- var obj = this;
38903
- var config;
38904
- var editor;
38905
- var enabled = false;
38906
-
38907
- obj.PluginName = "TrackedChanges";
38908
-
38909
- obj.InitConfig = function (argconfig) {
38910
- config = argconfig;
38911
- if (config.trackChangesEnabled === false) return;
38912
-
38913
- config.currentUser = config.currentUser || null;
38914
- config.trackChangesInsertClass = config.trackChangesInsertClass || "rte-tc rte-tc-insert";
38915
- config.trackChangesDeleteClass = config.trackChangesDeleteClass || "rte-tc rte-tc-delete";
38916
- // Optional application policy for accept/reject. Return false to keep a
38917
- // suggestion pending. The default remains permissive for compatibility.
38918
- config.reviewCanDecide = config.reviewCanDecide || null;
38919
- config.onReviewDecisionDenied = config.onReviewDecisionDenied || null;
38920
-
38921
- // Toolbar command — only surfaces if the editor page adds it to a toolbar slot.
38922
- config.text_trackchanges = config.text_trackchanges || "Suggesting mode";
38923
- appendToolbarCommand("toolbar_default", "#{trackchanges}");
38924
- appendToolbarCommand("toolbar_full", "#{trackchanges}");
38925
- appendToolbarCommand("toolbar_mobile", "#{trackchanges}");
38926
-
38927
- if ((config.controltoolbar_TEXT || "").indexOf("trackchanges") === -1) {
38928
- config.controltoolbar_TEXT = (config.controltoolbar_TEXT || "") + "|{trackchanges}";
38929
- }
38930
- };
38931
-
38932
- obj.InitEditor = function (argeditor) {
38933
- editor = argeditor;
38934
- if (config.trackChangesEnabled === false) return;
38935
-
38936
- editor.trackedChanges = {
38937
- enable: function (user) { enable(user); },
38938
- disable: function () { disable(); },
38939
- isEnabled: function () { return enabled; },
38940
- acceptAll: function (filter) { return acceptAll(filter); },
38941
- rejectAll: function (filter) { return rejectAll(filter); },
38942
- accept: function (id) { return acceptEntry(id); },
38943
- reject: function (id) { return rejectEntry(id); },
38944
- canDecide: function (id, action) {
38945
- var entry = editor.reviewLedger && editor.reviewLedger.get(id);
38946
- return !!entry && canDecide(entry, action || "accept");
38947
- },
38948
- list: function (opts) {
38949
- if (!editor.reviewLedger) return [];
38950
- // Default to the PENDING review queue (matches the internal
38951
- // accept/reject semantics). Pass {all:true} for full history
38952
- // including accepted/rejected entries (also via reviewLedger.list()).
38953
- var includeAll = opts && opts.all === true;
38954
- return editor.reviewLedger.list().filter(function (e) {
38955
- if (e.changeType !== "insert" && e.changeType !== "delete") return false;
38956
- return includeAll || e.status === "pending";
38957
- });
38958
- }
38959
- };
38960
-
38961
- injectStyles();
38962
-
38963
- editor.toolbarFactoryMap = editor.toolbarFactoryMap || {};
38964
- editor.toolbarFactoryMap["trackchanges"] = function (cmd) {
38965
- var btn = editor.createToolbarButton(cmd);
38966
- btn.__tcSync = function () {
38967
- if (enabled) btn.classList.add("rte-ui-active");
38968
- else btn.classList.remove("rte-ui-active");
38969
- btn.setAttribute("aria-pressed", enabled ? "true" : "false");
38970
- btn.setAttribute("title", enabled ? "Suggesting mode is on" : "Turn on suggesting mode");
38971
- };
38972
- btn.__tcSync();
38973
- return btn;
38974
- };
38975
-
38976
- editor.attachEvent("exec_command_trackchanges", function (state) {
38977
- state.returnValue = true;
38978
- state.stopBubble = true;
38979
- if (enabled) disable();
38980
- else enable();
38981
- });
38982
-
38983
- editor.getEditable().addEventListener("beforeinput", onBeforeInput, true);
38984
- };
38985
-
38986
- function appendToolbarCommand(toolbar, item) {
38987
- if (!config[toolbar]) return;
38988
- if (config[toolbar].indexOf(item) !== -1) return;
38989
- config[toolbar] = config[toolbar] + item;
38990
- }
38991
-
38992
- function enable(user) {
38993
- if (user) config.currentUser = user;
38994
- if (!config.currentUser) {
38995
- config.currentUser = { id: "user", name: "User", color: "#2563eb" };
38996
- console.warn("trackedchanges: no config.currentUser configured; defaulting to generic user.");
38997
- }
38998
- enabled = true;
38999
- syncToolbarButtons();
39000
- }
39001
-
39002
- function disable() {
39003
- enabled = false;
39004
- syncToolbarButtons();
39005
- }
39006
-
39007
- function syncToolbarButtons() {
39008
- var doc = editor.iframe && editor.iframe.ownerDocument || document;
39009
- var btns = doc.querySelectorAll(".rte_command_trackchanges");
39010
- for (var i = 0; i < btns.length; i++) {
39011
- if (btns[i].__tcSync) btns[i].__tcSync();
39012
- }
39013
- }
39014
-
39015
- // --- beforeinput intercept ---
39016
-
39017
- function onBeforeInput(e) {
39018
- if (!enabled) return;
39019
- if (!editor.reviewLedger) return; // No ledger => AI toolkit not loaded; do nothing.
39020
-
39021
- var type = e.inputType;
39022
- if (!type) return;
39023
-
39024
- if (type === "insertText" || type === "insertReplacementText") {
39025
- e.preventDefault();
39026
- handleInsertText(e.data || "");
39027
- return;
39028
- }
39029
- if (type === "deleteContentBackward" || type === "deleteContentForward" || type === "deleteByCut") {
39030
- e.preventDefault();
39031
- handleDelete(type);
39032
- return;
39033
- }
39034
- // insertParagraph, insertCompositionText, insertFromPaste, historyUndo/Redo: pass through for v1.
39035
- }
39036
-
39037
- // --- insert tracking ---
39038
-
39039
- function handleInsertText(text) {
39040
- if (!text) return;
39041
- var sel = editor.getSelection();
39042
- if (!sel || sel.rangeCount === 0) return;
39043
- var range = sel.getRangeAt(0);
39044
- if (!range.collapsed) {
39045
- // Selection replace: wrap selection as delete, then insert new text.
39046
- if (!wrapRangeAsDelete(range)) return;
39047
- // After wrapping, caret should be past the delete span; re-fetch selection.
39048
- range = sel.getRangeAt(0);
39049
- createInsertSpan(text, range);
39050
- return;
39051
- }
39052
-
39053
- var mergeTarget = adjacentInsertSpan(range);
39054
- if (mergeTarget) {
39055
- appendToInsertSpan(mergeTarget, text);
39056
- } else {
39057
- createInsertSpan(text, range);
39058
- }
39059
- }
39060
-
39061
- function adjacentInsertSpan(range) {
39062
- // If caret sits inside an insert span by the current user, or immediately after one, merge.
39063
- var node = range.startContainer;
39064
- if (node.nodeType === 3 && node.parentNode && isMyInsert(node.parentNode)) {
39065
- return node.parentNode;
39066
- }
39067
- // Caret just after an insert span (at an element boundary).
39068
- if (node.nodeType === 1 && range.startOffset > 0) {
39069
- var prev = node.childNodes[range.startOffset - 1];
39070
- if (prev && prev.nodeType === 1 && isMyInsert(prev)) return prev;
39071
- }
39072
- // Caret at the very beginning just before our span.
39073
- if (node.nodeType === 3 && node.previousSibling && isMyInsert(node.previousSibling) && range.startOffset === 0) {
39074
- return node.previousSibling;
39075
- }
39076
- return null;
39077
- }
39078
-
39079
- function isMyInsert(el) {
39080
- if (!el || el.nodeType !== 1) return false;
39081
- if (!el.classList || !el.classList.contains("rte-tc-insert")) return false;
39082
- return el.getAttribute("data-tc-author") === config.currentUser.id;
39083
- }
39084
-
39085
- function appendToInsertSpan(span, text) {
39086
- var editdoc = editor.getDocument();
39087
- // Append to the span's last text node (or create one).
39088
- var last = span.lastChild;
39089
- var node;
39090
- if (last && last.nodeType === 3) {
39091
- last.nodeValue = last.nodeValue + text;
39092
- node = last;
39093
- } else {
39094
- node = editdoc.createTextNode(text);
39095
- span.appendChild(node);
39096
- }
39097
- placeCaretAtEnd(node);
39098
- updateEntryForSpan(span);
39099
- }
39100
-
39101
- function createInsertSpan(text, range) {
39102
- var editdoc = editor.getDocument();
39103
- var span = editdoc.createElement("span");
39104
- span.className = config.trackChangesInsertClass;
39105
- var user = config.currentUser;
39106
- var id = "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
39107
- span.setAttribute("data-tc-id", id);
39108
- span.setAttribute("data-tc-author", user.id);
39109
- span.setAttribute("data-tc-at", new Date().toISOString());
39110
- span.style.color = user.color;
39111
- span.style.textDecoration = "underline";
39112
- span.appendChild(editdoc.createTextNode(text));
39113
- range.insertNode(span);
39114
- placeCaretAtEnd(span.firstChild);
39115
-
39116
- editor.reviewLedger.add({
39117
- id: id,
39118
- changeType: "insert",
39119
- author: user,
39120
- text: text,
39121
- status: "pending",
39122
- sourceLabel: "Suggested insert",
39123
- createdAt: Date.now()
39124
- });
39125
- }
39126
-
39127
- function updateEntryForSpan(span) {
39128
- var id = span.getAttribute("data-tc-id");
39129
- if (!id) return;
39130
- editor.reviewLedger.update(id, { text: span.textContent });
39131
- }
39132
-
39133
- // --- delete tracking ---
39134
-
39135
- function handleDelete(mode) {
39136
- var sel = editor.getSelection();
39137
- if (!sel || sel.rangeCount === 0) return;
39138
- var range = sel.getRangeAt(0);
39139
-
39140
- if (!range.collapsed) {
39141
- wrapRangeAsDelete(range);
39142
- return;
39143
- }
39144
-
39145
- // Collapsed: extend by one char in the appropriate direction, then wrap.
39146
- // The extended range covers only that one character — any delete spans between
39147
- // it and the caret should be SKIPPED, not re-wrapped.
39148
- var extended = editor.getDocument().createRange();
39149
- if (mode === "deleteContentBackward") {
39150
- var anchor = prevCharPoint(range.startContainer, range.startOffset);
39151
- if (!anchor) return;
39152
- extended.setStart(anchor.node, anchor.offset);
39153
- extended.setEnd(anchor.node, anchor.offset + 1);
39154
- } else {
39155
- var forward = nextCharPoint(range.startContainer, range.startOffset);
39156
- if (!forward) return;
39157
- extended.setStart(forward.node, forward.offset - 1);
39158
- extended.setEnd(forward.node, forward.offset);
39159
- }
39160
-
39161
- // If that extended range sits entirely inside one of the current user's own
39162
- // insert spans, treat this as undoing their own typing — actually delete.
39163
- if (rangeInsideMyInsert(extended)) {
39164
- extended.deleteContents();
39165
- var sel2 = editor.getSelection();
39166
- sel2.removeAllRanges();
39167
- sel2.addRange(extended);
39168
- // If the insert span is now empty, remove it and its ledger entry.
39169
- cleanupEmptyInsertSpans();
39170
- return;
39171
- }
39172
-
39173
- wrapRangeAsDelete(extended);
39174
- }
39175
-
39176
- function isInsideDeleteSpan(node) {
39177
- var walk = node.nodeType === 1 ? node : node.parentNode;
39178
- while (walk && walk !== editor.getEditable()) {
39179
- if (walk.classList && walk.classList.contains("rte-tc-delete")) return true;
39180
- walk = walk.parentNode;
39181
- }
39182
- return false;
39183
- }
39184
-
39185
- // Walk text leaves in the editable, skipping any that live inside an existing delete
39186
- // span (those characters have already been visually "deleted" — we shouldn't re-wrap them).
39187
- function liveTextLeaves() {
39188
- var editable = editor.getEditable();
39189
- var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT, {
39190
- acceptNode: function (n) { return isInsideDeleteSpan(n) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }
39191
- });
39192
- var out = [], n;
39193
- while ((n = walker.nextNode())) out.push(n);
39194
- return out;
39195
- }
39196
-
39197
- function prevCharPoint(node, offset) {
39198
- var leaves = liveTextLeaves();
39199
- if (!leaves.length) return null;
39200
- var idx = caretIndex(node, offset, leaves);
39201
- if (idx <= 0) return null;
39202
- return leafOffsetAt(idx - 1, leaves);
39203
- }
39204
-
39205
- function nextCharPoint(node, offset) {
39206
- var leaves = liveTextLeaves();
39207
- if (!leaves.length) return null;
39208
- var total = 0;
39209
- for (var i = 0; i < leaves.length; i++) total += leaves[i].nodeValue.length;
39210
- var idx = caretIndex(node, offset, leaves);
39211
- if (idx >= total) return null;
39212
- return leafOffsetAt(idx + 1, leaves);
39213
- }
39214
-
39215
- // How many live characters appear before the given caret position?
39216
- function caretIndex(node, offset, leaves) {
39217
- var r = editor.getDocument().createRange();
39218
- r.setStart(node, offset);
39219
- r.collapse(true);
39220
- var count = 0;
39221
- for (var i = 0; i < leaves.length; i++) {
39222
- var leaf = leaves[i];
39223
- // If the entire leaf is before the caret, add its length.
39224
- var cmpEnd = r.comparePoint(leaf, leaf.nodeValue.length);
39225
- if (cmpEnd <= 0) { count += leaf.nodeValue.length; continue; }
39226
- var cmpStart = r.comparePoint(leaf, 0);
39227
- if (cmpStart >= 0) break; // leaf starts at or after caret
39228
- // Caret falls inside this leaf — binary search the split point.
39229
- var lo = 0, hi = leaf.nodeValue.length;
39230
- while (lo < hi) {
39231
- var mid = (lo + hi) >> 1;
39232
- if (r.comparePoint(leaf, mid) < 0) lo = mid + 1;
39233
- else hi = mid;
39234
- }
39235
- count += lo;
39236
- break;
39237
- }
39238
- return count;
39239
- }
39240
-
39241
- function leafOffsetAt(index, leaves) {
39242
- var remaining = index;
39243
- for (var i = 0; i < leaves.length; i++) {
39244
- if (remaining <= leaves[i].nodeValue.length) return { node: leaves[i], offset: remaining };
39245
- remaining -= leaves[i].nodeValue.length;
39246
- }
39247
- return null;
39248
- }
39249
-
39250
- function firstTextLeaf(node) {
39251
- if (!node) return null;
39252
- if (node.nodeType === 3) return node;
39253
- var walker = editor.getDocument().createTreeWalker(node, NodeFilter.SHOW_TEXT);
39254
- return walker.nextNode();
39255
- }
39256
-
39257
- function lastTextLeaf(node) {
39258
- if (!node) return null;
39259
- if (node.nodeType === 3) return node;
39260
- var walker = editor.getDocument().createTreeWalker(node, NodeFilter.SHOW_TEXT);
39261
- var last = null, n;
39262
- while ((n = walker.nextNode())) last = n;
39263
- return last;
39264
- }
39265
-
39266
- function previousTextLeaf(node) {
39267
- var editable = editor.getEditable();
39268
- var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT);
39269
- var prev = null, cur;
39270
- while ((cur = walker.nextNode())) {
39271
- if (cur === node) return prev;
39272
- prev = cur;
39273
- }
39274
- return null;
39275
- }
39276
-
39277
- function nextTextLeaf(node) {
39278
- var editable = editor.getEditable();
39279
- var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT);
39280
- var seen = false, cur;
39281
- while ((cur = walker.nextNode())) {
39282
- if (seen) return cur;
39283
- if (cur === node) seen = true;
39284
- }
39285
- return null;
39286
- }
39287
-
39288
- function rangeInsideMyInsert(range) {
39289
- var span = range.startContainer.parentNode;
39290
- while (span && span !== editor.getEditable()) {
39291
- if (isMyInsert(span)) return range.endContainer.parentNode === span || span.contains(range.endContainer);
39292
- span = span.parentNode;
39293
- }
39294
- return false;
39295
- }
39296
-
39297
- function wrapRangeAsDelete(range) {
39298
- if (range.collapsed) return false;
39299
- var editdoc = editor.getDocument();
39300
-
39301
- var fragment = range.cloneContents();
39302
- if (!fragmentHasText(fragment)) return false;
39303
-
39304
- // Find existing same-author delete spans adjacent to the range so we can merge
39305
- // sequential backspaces / forward deletes into one contiguous span.
39306
- var user = config.currentUser;
39307
- var mergeBefore = adjacentDeleteSpan(range, "before", user);
39308
- var mergeAfter = adjacentDeleteSpan(range, "after", user);
39309
-
39310
- range.deleteContents();
39311
-
39312
- if (mergeBefore) {
39313
- // Append the fragment's content to the existing "before" span.
39314
- appendFragmentToSpan(mergeBefore, fragment);
39315
- if (mergeAfter && mergeAfter !== mergeBefore) {
39316
- // Collapse both sides: move "after" span's children into "before" and drop it.
39317
- while (mergeAfter.firstChild) mergeBefore.appendChild(mergeAfter.firstChild);
39318
- var afterId = mergeAfter.getAttribute("data-tc-id");
39319
- mergeAfter.remove();
39320
- if (afterId && editor.reviewLedger) editor.reviewLedger.remove(afterId);
39321
- }
39322
- var rangeAfter = editdoc.createRange();
39323
- rangeAfter.setStartAfter(mergeBefore);
39324
- rangeAfter.collapse(true);
39325
- var sel = editor.getSelection();
39326
- sel.removeAllRanges();
39327
- sel.addRange(rangeAfter);
39328
- // 2026-05-28 Defense: if a customer pasted a delete-class span from
39329
- // outside this plugin, it may lack data-tc-id. Skip the ledger
39330
- // update instead of writing under a `null` key (would silently
39331
- // corrupt the ledger via the update("null", ...) coercion).
39332
- var beforeId = mergeBefore.getAttribute("data-tc-id");
39333
- if (beforeId && editor.reviewLedger) {
39334
- editor.reviewLedger.update(beforeId, { text: mergeBefore.textContent });
39335
- }
39336
- return true;
39337
- }
39338
-
39339
- if (mergeAfter) {
39340
- prependFragmentToSpan(mergeAfter, fragment);
39341
- var rangeBefore = editdoc.createRange();
39342
- rangeBefore.setStartBefore(mergeAfter);
39343
- rangeBefore.collapse(true);
39344
- var sel2 = editor.getSelection();
39345
- sel2.removeAllRanges();
39346
- sel2.addRange(rangeBefore);
39347
- // Same defense as above (mergeAfter may lack data-tc-id).
39348
- var afterMergeId = mergeAfter.getAttribute("data-tc-id");
39349
- if (afterMergeId && editor.reviewLedger) {
39350
- editor.reviewLedger.update(afterMergeId, { text: mergeAfter.textContent });
39351
- }
39352
- return true;
39353
- }
39354
-
39355
- var span = editdoc.createElement("span");
39356
- span.className = config.trackChangesDeleteClass;
39357
- var id = "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
39358
- span.setAttribute("data-tc-id", id);
39359
- span.setAttribute("data-tc-author", user.id);
39360
- span.setAttribute("data-tc-at", new Date().toISOString());
39361
- span.style.color = user.color;
39362
- span.style.textDecoration = "line-through";
39363
- span.appendChild(fragment);
39364
- range.insertNode(span);
39365
-
39366
- var after = editdoc.createRange();
39367
- after.setStartAfter(span);
39368
- after.collapse(true);
39369
- var sel3 = editor.getSelection();
39370
- sel3.removeAllRanges();
39371
- sel3.addRange(after);
39372
-
39373
- editor.reviewLedger.add({
39374
- id: id,
39375
- changeType: "delete",
39376
- author: user,
39377
- text: span.textContent,
39378
- originalHtml: span.innerHTML,
39379
- status: "pending",
39380
- sourceLabel: "Suggested delete",
39381
- createdAt: Date.now()
39382
- });
39383
- return true;
39384
- }
39385
-
39386
- function adjacentDeleteSpan(range, side, user) {
39387
- // Find element immediately before or after the range boundaries at the container level.
39388
- var node = side === "before" ? range.startContainer : range.endContainer;
39389
- var offset = side === "before" ? range.startOffset : range.endOffset;
39390
- if (node.nodeType === 3) {
39391
- // Look at the sibling of the text node if we're at the text node's boundary.
39392
- if (side === "before" && offset === 0) {
39393
- var prev = node.previousSibling;
39394
- if (prev && isDeleteSpanByAuthor(prev, user)) return prev;
39395
- }
39396
- if (side === "after" && offset === node.nodeValue.length) {
39397
- var next = node.nextSibling;
39398
- if (next && isDeleteSpanByAuthor(next, user)) return next;
39399
- }
39400
- return null;
39401
- }
39402
- // Element container: look at children on either side of the offset.
39403
- if (side === "before" && offset > 0) {
39404
- var childBefore = node.childNodes[offset - 1];
39405
- if (childBefore && isDeleteSpanByAuthor(childBefore, user)) return childBefore;
39406
- }
39407
- if (side === "after" && offset < node.childNodes.length) {
39408
- var childAfter = node.childNodes[offset];
39409
- if (childAfter && isDeleteSpanByAuthor(childAfter, user)) return childAfter;
39410
- }
39411
- return null;
39412
- }
39413
-
39414
- function isDeleteSpanByAuthor(el, user) {
39415
- if (!el || el.nodeType !== 1) return false;
39416
- if (!el.classList || !el.classList.contains("rte-tc-delete")) return false;
39417
- return el.getAttribute("data-tc-author") === user.id;
39418
- }
39419
-
39420
- function appendFragmentToSpan(span, fragment) {
39421
- while (fragment.firstChild) span.appendChild(fragment.firstChild);
39422
- }
39423
-
39424
- function prependFragmentToSpan(span, fragment) {
39425
- var first = span.firstChild;
39426
- while (fragment.firstChild) span.insertBefore(fragment.firstChild, first);
39427
- }
39428
-
39429
- function fragmentHasText(fragment) {
39430
- var t = (fragment.textContent || "").replace(/\s+/g, "");
39431
- return !!t;
39432
- }
39433
-
39434
- function cleanupEmptyInsertSpans() {
39435
- var spans = editor.getEditable().querySelectorAll("." + "rte-tc-insert");
39436
- for (var i = 0; i < spans.length; i++) {
39437
- var s = spans[i];
39438
- if (!s.textContent || !s.textContent.length) {
39439
- var id = s.getAttribute("data-tc-id");
39440
- if (id && editor.reviewLedger) editor.reviewLedger.remove(id);
39441
- s.remove();
39442
- } else {
39443
- updateEntryForSpan(s);
39444
- }
39445
- }
39446
- }
39447
-
39448
- function placeCaretAtEnd(node) {
39449
- if (!node) return;
39450
- var editdoc = editor.getDocument();
39451
- var range = editdoc.createRange();
39452
- if (node.nodeType === 3) {
39453
- range.setStart(node, node.nodeValue.length);
39454
- } else {
39455
- range.setStart(node, node.childNodes.length);
39456
- }
39457
- range.collapse(true);
39458
- var sel = editor.getSelection();
39459
- sel.removeAllRanges();
39460
- sel.addRange(range);
39461
- }
39462
-
39463
- // --- accept / reject ---
39464
-
39465
- function decisionUser() {
39466
- return config.currentUser || { id: "user", name: "User", color: "#2563eb" };
39467
- }
39468
-
39469
- function canDecide(entry, action) {
39470
- if (typeof config.reviewCanDecide !== "function") return true;
39471
- var allowed = false;
39472
- var user = decisionUser();
39473
- try { allowed = config.reviewCanDecide(entry, action, user, editor) !== false; }
39474
- catch (err) {
39475
- console.warn("trackedchanges: reviewCanDecide threw; denying decision.", err);
39476
- }
39477
- if (allowed) return true;
39478
- var detail = { entry: entry, action: action, user: user };
39479
- try { typeof config.onReviewDecisionDenied === "function" && config.onReviewDecisionDenied(detail); } catch (ignore) { }
39480
- try { typeof editor.fireEvent === "function" && editor.fireEvent("review_decision_denied", detail); } catch (ignore2) { }
39481
- return false;
39482
- }
39483
-
39484
- function acceptEntry(id) {
39485
- var entry = editor.reviewLedger.get(id);
39486
- if (!entry) return false;
39487
- if (!canDecide(entry, "accept")) return false;
39488
- var span = editor.getEditable().querySelector('[data-tc-id="' + cssEscape(id) + '"]');
39489
- if (entry.changeType === "insert") {
39490
- if (span) unwrapKeepChildren(span);
39491
- } else if (entry.changeType === "delete") {
39492
- if (span) span.remove();
39493
- }
39494
- editor.reviewLedger.update(id, { status: "accepted", decidedAt: Date.now(), decidedBy: decisionUser() });
39495
- return true;
39496
- }
39497
-
39498
- function rejectEntry(id) {
39499
- var entry = editor.reviewLedger.get(id);
39500
- if (!entry) return false;
39501
- if (!canDecide(entry, "reject")) return false;
39502
- var span = editor.getEditable().querySelector('[data-tc-id="' + cssEscape(id) + '"]');
39503
- if (entry.changeType === "insert") {
39504
- if (span) span.remove();
39505
- } else if (entry.changeType === "delete") {
39506
- if (span) unwrapKeepChildren(span);
39507
- }
39508
- editor.reviewLedger.update(id, { status: "rejected", decidedAt: Date.now(), decidedBy: decisionUser() });
39509
- return true;
39510
- }
39511
-
39512
- function acceptAll(filter) {
39513
- var entries = editor.reviewLedger.list(filter).filter(function (e) {
39514
- return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
39515
- });
39516
- var accepted = 0;
39517
- for (var i = 0; i < entries.length; i++) if (acceptEntry(entries[i].id)) accepted++;
39518
- return accepted;
39519
- }
39520
-
39521
- function rejectAll(filter) {
39522
- var entries = editor.reviewLedger.list(filter).filter(function (e) {
39523
- return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
39524
- });
39525
- var rejected = 0;
39526
- for (var i = 0; i < entries.length; i++) if (rejectEntry(entries[i].id)) rejected++;
39527
- return rejected;
39528
- }
39529
-
39530
- function unwrapKeepChildren(el) {
39531
- var parent = el.parentNode;
39532
- if (!parent) return;
39533
- while (el.firstChild) parent.insertBefore(el.firstChild, el);
39534
- parent.removeChild(el);
39535
- }
39536
-
39537
- // 2026-05-28 Prefer native CSS.escape — same hardening as comments.js.
39538
- // Defense-in-depth for customers who wire reviewLedger.add() with custom IDs
39539
- // containing punctuation (e.g. UUIDs with dashes, OAuth subject claims with
39540
- // colons, base64 IDs with `=` padding).
39541
- function cssEscape(s) {
39542
- var str = String(s);
39543
- if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
39544
- try { return CSS.escape(str); } catch (e) { /* fall through */ }
39545
- }
39546
- return str.replace(/["\\\]\[\(\)\{\}'#:;,<>=`~!@$%^&*+/?\s\x00-\x1F]/g, function (c) {
39547
- return "\\" + c;
39548
- });
39549
- }
39550
-
39551
- // --- styles ---
39552
-
39553
- function injectStyles() {
39554
- var host = (editor && editor.iframe && editor.iframe.ownerDocument) || document;
39555
- if (!host.querySelector("style[data-rte-trackchanges]")) {
39556
- var style = host.createElement("style");
39557
- style.setAttribute("data-rte-trackchanges", "1");
39558
- style.textContent = [
39559
- ".richtexteditor rte-toolbar-button.rte_command_trackchanges{position:relative;border-radius:8px;transition:background .14s ease,color .14s ease,box-shadow .14s ease}",
39560
- ".richtexteditor rte-toolbar-button.rte_command_trackchanges.rte-ui-active{background:linear-gradient(135deg,#e9f7ef,#eef5ff);color:#17633f;box-shadow:inset 0 0 0 1px rgba(34,132,82,.28),0 6px 14px rgba(34,132,82,.12)}",
39561
- ".richtexteditor rte-toolbar-button.rte_command_trackchanges.rte-ui-active:after{content:'';position:absolute;right:5px;top:5px;width:6px;height:6px;border-radius:50%;background:#22a66a;box-shadow:0 0 0 2px #fff}",
39562
- ".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
39563
- ".rte-tc-insert{background:linear-gradient(180deg,rgba(232,248,239,.78),rgba(232,248,239,.38));color:#125b39;text-decoration:underline;text-decoration-color:#22a66a;text-decoration-thickness:2px;text-underline-offset:3px}",
39564
- ".rte-tc-delete{background:linear-gradient(180deg,rgba(255,240,240,.82),rgba(255,240,240,.42));color:#9f1d1d;text-decoration:line-through;text-decoration-color:#dc4c4c;text-decoration-thickness:2px;opacity:.92}"
39565
- ].join("\n");
39566
- host.head.appendChild(style);
39567
- }
39568
-
39569
- var editdoc = editor.getDocument();
39570
- if (editdoc && editdoc.head && !editdoc.querySelector("style[data-rte-trackchanges-inline]")) {
39571
- var iStyle = editdoc.createElement("style");
39572
- iStyle.setAttribute("data-rte-trackchanges-inline", "1");
39573
- iStyle.textContent = [
39574
- ".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
39575
- ".rte-tc-insert{background:linear-gradient(180deg,rgba(232,248,239,.78),rgba(232,248,239,.38));color:#125b39;text-decoration:underline;text-decoration-color:#22a66a;text-decoration-thickness:2px;text-underline-offset:3px}",
39576
- ".rte-tc-delete{background:linear-gradient(180deg,rgba(255,240,240,.82),rgba(255,240,240,.42));color:#9f1d1d;text-decoration:line-through;text-decoration-color:#dc4c4c;text-decoration-thickness:2px;opacity:.92}"
39577
- ].join("\n");
39578
- editdoc.head.appendChild(iStyle);
39579
- }
39580
- }
39581
- }
40768
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
40769
+
40770
+ if (!RTE_DefaultConfig.svgCode_trackchanges) {
40771
+ RTE_DefaultConfig.svgCode_trackchanges = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 17l4-1 10-10-3-3L5 13l-1 4z"/><path d="M14 4l3 3"/><path d="M4 21h10"/></svg>';
40772
+ }
40773
+
40774
+ RTE_DefaultConfig.plugin_trackedchanges = RTE_Plugin_TrackedChanges;
40775
+
40776
+ function RTE_Plugin_TrackedChanges() {
40777
+ var obj = this;
40778
+ var config;
40779
+ var editor;
40780
+ var enabled = false;
40781
+
40782
+ obj.PluginName = "TrackedChanges";
40783
+
40784
+ obj.InitConfig = function (argconfig) {
40785
+ config = argconfig;
40786
+ if (config.trackChangesEnabled === false) return;
40787
+
40788
+ config.currentUser = config.currentUser || null;
40789
+ config.trackChangesInsertClass = config.trackChangesInsertClass || "rte-tc rte-tc-insert";
40790
+ config.trackChangesDeleteClass = config.trackChangesDeleteClass || "rte-tc rte-tc-delete";
40791
+ // Optional application policy for accept/reject. Return false to keep a
40792
+ // suggestion pending. The default remains permissive for compatibility.
40793
+ config.reviewCanDecide = config.reviewCanDecide || null;
40794
+ config.onReviewDecisionDenied = config.onReviewDecisionDenied || null;
40795
+
40796
+ // Toolbar command — only surfaces if the editor page adds it to a toolbar slot.
40797
+ config.text_trackchanges = config.text_trackchanges || "Suggesting mode";
40798
+ appendToolbarCommand("toolbar_default", "#{trackchanges}");
40799
+ appendToolbarCommand("toolbar_full", "#{trackchanges}");
40800
+ appendToolbarCommand("toolbar_mobile", "#{trackchanges}");
40801
+
40802
+ if ((config.controltoolbar_TEXT || "").indexOf("trackchanges") === -1) {
40803
+ config.controltoolbar_TEXT = (config.controltoolbar_TEXT || "") + "|{trackchanges}";
40804
+ }
40805
+ };
40806
+
40807
+ obj.InitEditor = function (argeditor) {
40808
+ editor = argeditor;
40809
+ if (config.trackChangesEnabled === false) return;
40810
+
40811
+ editor.trackedChanges = {
40812
+ enable: function (user) { enable(user); },
40813
+ disable: function () { disable(); },
40814
+ isEnabled: function () { return enabled; },
40815
+ acceptAll: function (filter) { return acceptAll(filter); },
40816
+ rejectAll: function (filter) { return rejectAll(filter); },
40817
+ accept: function (id) { return acceptEntry(id); },
40818
+ reject: function (id) { return rejectEntry(id); },
40819
+ canDecide: function (id, action) {
40820
+ var entry = editor.reviewLedger && editor.reviewLedger.get(id);
40821
+ return !!entry && canDecide(entry, action || "accept");
40822
+ },
40823
+ list: function (opts) {
40824
+ if (!editor.reviewLedger) return [];
40825
+ // Default to the PENDING review queue (matches the internal
40826
+ // accept/reject semantics). Pass {all:true} for full history
40827
+ // including accepted/rejected entries (also via reviewLedger.list()).
40828
+ var includeAll = opts && opts.all === true;
40829
+ return editor.reviewLedger.list().filter(function (e) {
40830
+ if (e.changeType !== "insert" && e.changeType !== "delete") return false;
40831
+ return includeAll || e.status === "pending";
40832
+ });
40833
+ }
40834
+ };
40835
+
40836
+ injectStyles();
40837
+
40838
+ editor.toolbarFactoryMap = editor.toolbarFactoryMap || {};
40839
+ editor.toolbarFactoryMap["trackchanges"] = function (cmd) {
40840
+ var btn = editor.createToolbarButton(cmd);
40841
+ btn.__tcSync = function () {
40842
+ if (enabled) btn.classList.add("rte-ui-active");
40843
+ else btn.classList.remove("rte-ui-active");
40844
+ btn.setAttribute("aria-pressed", enabled ? "true" : "false");
40845
+ // WCAG 2.5.3 Label in Name: the visible label (this tooltip) must be
40846
+ // contained in the accessible name. "Turn on suggesting mode" was not
40847
+ // — the accessible name is "Suggesting mode" — so a speech-input user
40848
+ // saying what they see did not match the control. State belongs in
40849
+ // aria-pressed and the active styling, not in the visible label.
40850
+ btn.setAttribute("title", config.text_trackchanges || "Suggesting mode");
40851
+ };
40852
+ btn.__tcSync();
40853
+ return btn;
40854
+ };
40855
+
40856
+ editor.attachEvent("exec_command_trackchanges", function (state) {
40857
+ state.returnValue = true;
40858
+ state.stopBubble = true;
40859
+ if (enabled) disable();
40860
+ else enable();
40861
+ });
40862
+
40863
+ editor.getEditable().addEventListener("beforeinput", onBeforeInput, true);
40864
+ };
40865
+
40866
+ function appendToolbarCommand(toolbar, item) {
40867
+ if (!config[toolbar]) return;
40868
+ if (config[toolbar].indexOf(item) !== -1) return;
40869
+ config[toolbar] = config[toolbar] + item;
40870
+ }
40871
+
40872
+ function enable(user) {
40873
+ if (user) config.currentUser = user;
40874
+ if (!config.currentUser) {
40875
+ config.currentUser = { id: "user", name: "User", color: "#2563eb" };
40876
+ console.warn("trackedchanges: no config.currentUser configured; defaulting to generic user.");
40877
+ }
40878
+ enabled = true;
40879
+ syncToolbarButtons();
40880
+ }
40881
+
40882
+ function disable() {
40883
+ enabled = false;
40884
+ syncToolbarButtons();
40885
+ }
40886
+
40887
+ function syncToolbarButtons() {
40888
+ var doc = editor.iframe && editor.iframe.ownerDocument || document;
40889
+ var btns = doc.querySelectorAll(".rte_command_trackchanges");
40890
+ for (var i = 0; i < btns.length; i++) {
40891
+ if (btns[i].__tcSync) btns[i].__tcSync();
40892
+ }
40893
+ }
40894
+
40895
+ // --- beforeinput intercept ---
40896
+
40897
+ function onBeforeInput(e) {
40898
+ if (!enabled) return;
40899
+ if (!editor.reviewLedger) return; // No ledger => AI toolkit not loaded; do nothing.
40900
+
40901
+ var type = e.inputType;
40902
+ if (!type) return;
40903
+
40904
+ if (type === "insertText" || type === "insertReplacementText") {
40905
+ e.preventDefault();
40906
+ handleInsertText(e.data || "");
40907
+ return;
40908
+ }
40909
+ if (type === "deleteContentBackward" || type === "deleteContentForward" || type === "deleteByCut") {
40910
+ e.preventDefault();
40911
+ handleDelete(type);
40912
+ return;
40913
+ }
40914
+ // insertParagraph, insertCompositionText, insertFromPaste, historyUndo/Redo: pass through for v1.
40915
+ }
40916
+
40917
+ // --- insert tracking ---
40918
+
40919
+ function handleInsertText(text) {
40920
+ if (!text) return;
40921
+ var sel = editor.getSelection();
40922
+ if (!sel || sel.rangeCount === 0) return;
40923
+ var range = sel.getRangeAt(0);
40924
+ if (!range.collapsed) {
40925
+ // Selection replace: wrap selection as delete, then insert new text.
40926
+ if (!wrapRangeAsDelete(range)) return;
40927
+ // After wrapping, caret should be past the delete span; re-fetch selection.
40928
+ range = sel.getRangeAt(0);
40929
+ createInsertSpan(text, range);
40930
+ return;
40931
+ }
40932
+
40933
+ var mergeTarget = adjacentInsertSpan(range);
40934
+ if (mergeTarget) {
40935
+ appendToInsertSpan(mergeTarget, text);
40936
+ } else {
40937
+ createInsertSpan(text, range);
40938
+ }
40939
+ }
40940
+
40941
+ function adjacentInsertSpan(range) {
40942
+ // If caret sits inside an insert span by the current user, or immediately after one, merge.
40943
+ var node = range.startContainer;
40944
+ if (node.nodeType === 3 && node.parentNode && isMyInsert(node.parentNode)) {
40945
+ return node.parentNode;
40946
+ }
40947
+ // Caret just after an insert span (at an element boundary).
40948
+ if (node.nodeType === 1 && range.startOffset > 0) {
40949
+ var prev = node.childNodes[range.startOffset - 1];
40950
+ if (prev && prev.nodeType === 1 && isMyInsert(prev)) return prev;
40951
+ }
40952
+ // Caret at the very beginning just before our span.
40953
+ if (node.nodeType === 3 && node.previousSibling && isMyInsert(node.previousSibling) && range.startOffset === 0) {
40954
+ return node.previousSibling;
40955
+ }
40956
+ return null;
40957
+ }
40958
+
40959
+ function isMyInsert(el) {
40960
+ if (!el || el.nodeType !== 1) return false;
40961
+ if (!el.classList || !el.classList.contains("rte-tc-insert")) return false;
40962
+ return el.getAttribute("data-tc-author") === config.currentUser.id;
40963
+ }
40964
+
40965
+ // We preventDefault() the browser's own text insertion, which means we also
40966
+ // lose the whitespace handling it does for free. A plain second space is
40967
+ // collapsed by HTML rendering, so pressing space a second time moved nothing
40968
+ // on screen and suggesting mode looked like it had stopped accepting input.
40969
+ // Reproduce what contenteditable does: any space followed by another space,
40970
+ // and any trailing space, becomes a non-breaking space. Recomputed from
40971
+ // scratch each time so an NBSP reverts to a normal space once real text
40972
+ // follows it (keeping word wrap and copied text clean). Only typed text
40973
+ // reaches this path -- paste arrives as insertFromPaste and is passed
40974
+ // through -- so this never rewrites an NBSP the user inserted deliberately.
40975
+ function normalizeInsertWhitespace(node) {
40976
+ if (!node || node.nodeType !== 3) return;
40977
+ var v = node.nodeValue;
40978
+ if (v.indexOf(" ") === -1 && v.indexOf("\u00a0") === -1) return;
40979
+ v = v.replace(/\u00a0/g, " ");
40980
+ v = v.replace(/ (?= )/g, "\u00a0");
40981
+ v = v.replace(/ $/, "\u00a0");
40982
+ if (v !== node.nodeValue) node.nodeValue = v;
40983
+ }
40984
+
40985
+ function appendToInsertSpan(span, text) {
40986
+ var editdoc = editor.getDocument();
40987
+ // Append to the span's last text node (or create one).
40988
+ var last = span.lastChild;
40989
+ var node;
40990
+ if (last && last.nodeType === 3) {
40991
+ last.nodeValue = last.nodeValue + text;
40992
+ node = last;
40993
+ } else {
40994
+ node = editdoc.createTextNode(text);
40995
+ span.appendChild(node);
40996
+ }
40997
+ normalizeInsertWhitespace(node);
40998
+ placeCaretAtEnd(node);
40999
+ updateEntryForSpan(span);
41000
+ }
41001
+
41002
+ function createInsertSpan(text, range) {
41003
+ var editdoc = editor.getDocument();
41004
+ var span = editdoc.createElement("span");
41005
+ span.className = config.trackChangesInsertClass;
41006
+ var user = config.currentUser;
41007
+ var id = "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
41008
+ span.setAttribute("data-tc-id", id);
41009
+ span.setAttribute("data-tc-author", user.id);
41010
+ span.setAttribute("data-tc-at", new Date().toISOString());
41011
+ span.style.color = user.color;
41012
+ span.style.textDecoration = "underline";
41013
+ span.appendChild(editdoc.createTextNode(text));
41014
+ range.insertNode(span);
41015
+ normalizeInsertWhitespace(span.firstChild);
41016
+ placeCaretAtEnd(span.firstChild);
41017
+
41018
+ editor.reviewLedger.add({
41019
+ id: id,
41020
+ changeType: "insert",
41021
+ author: user,
41022
+ text: text,
41023
+ status: "pending",
41024
+ sourceLabel: "Suggested insert",
41025
+ createdAt: Date.now()
41026
+ });
41027
+ }
41028
+
41029
+ function updateEntryForSpan(span) {
41030
+ var id = span.getAttribute("data-tc-id");
41031
+ if (!id) return;
41032
+ editor.reviewLedger.update(id, { text: span.textContent });
41033
+ }
41034
+
41035
+ // --- delete tracking ---
41036
+
41037
+ function handleDelete(mode) {
41038
+ var sel = editor.getSelection();
41039
+ if (!sel || sel.rangeCount === 0) return;
41040
+ var range = sel.getRangeAt(0);
41041
+
41042
+ if (!range.collapsed) {
41043
+ wrapRangeAsDelete(range);
41044
+ return;
41045
+ }
41046
+
41047
+ // Collapsed: extend by one char in the appropriate direction, then wrap.
41048
+ // The extended range covers only that one character — any delete spans between
41049
+ // it and the caret should be SKIPPED, not re-wrapped.
41050
+ var extended = editor.getDocument().createRange();
41051
+ if (mode === "deleteContentBackward") {
41052
+ var anchor = prevCharPoint(range.startContainer, range.startOffset);
41053
+ if (!anchor) return;
41054
+ extended.setStart(anchor.node, anchor.offset);
41055
+ extended.setEnd(anchor.node, anchor.offset + 1);
41056
+ } else {
41057
+ var forward = nextCharPoint(range.startContainer, range.startOffset);
41058
+ if (!forward) return;
41059
+ extended.setStart(forward.node, forward.offset - 1);
41060
+ extended.setEnd(forward.node, forward.offset);
41061
+ }
41062
+
41063
+ // If that extended range sits entirely inside one of the current user's own
41064
+ // insert spans, treat this as undoing their own typing — actually delete.
41065
+ if (rangeInsideMyInsert(extended)) {
41066
+ extended.deleteContents();
41067
+ var sel2 = editor.getSelection();
41068
+ sel2.removeAllRanges();
41069
+ sel2.addRange(extended);
41070
+ // If the insert span is now empty, remove it and its ledger entry.
41071
+ cleanupEmptyInsertSpans();
41072
+ return;
41073
+ }
41074
+
41075
+ wrapRangeAsDelete(extended);
41076
+ }
41077
+
41078
+ function isInsideDeleteSpan(node) {
41079
+ var walk = node.nodeType === 1 ? node : node.parentNode;
41080
+ while (walk && walk !== editor.getEditable()) {
41081
+ if (walk.classList && walk.classList.contains("rte-tc-delete")) return true;
41082
+ walk = walk.parentNode;
41083
+ }
41084
+ return false;
41085
+ }
41086
+
41087
+ // Walk text leaves in the editable, skipping any that live inside an existing delete
41088
+ // span (those characters have already been visually "deleted" — we shouldn't re-wrap them).
41089
+ function liveTextLeaves() {
41090
+ var editable = editor.getEditable();
41091
+ var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT, {
41092
+ acceptNode: function (n) { return isInsideDeleteSpan(n) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }
41093
+ });
41094
+ var out = [], n;
41095
+ while ((n = walker.nextNode())) out.push(n);
41096
+ return out;
41097
+ }
41098
+
41099
+ function prevCharPoint(node, offset) {
41100
+ var leaves = liveTextLeaves();
41101
+ if (!leaves.length) return null;
41102
+ var idx = caretIndex(node, offset, leaves);
41103
+ if (idx <= 0) return null;
41104
+ return leafOffsetAt(idx - 1, leaves);
41105
+ }
41106
+
41107
+ function nextCharPoint(node, offset) {
41108
+ var leaves = liveTextLeaves();
41109
+ if (!leaves.length) return null;
41110
+ var total = 0;
41111
+ for (var i = 0; i < leaves.length; i++) total += leaves[i].nodeValue.length;
41112
+ var idx = caretIndex(node, offset, leaves);
41113
+ if (idx >= total) return null;
41114
+ return leafOffsetAt(idx + 1, leaves);
41115
+ }
41116
+
41117
+ // How many live characters appear before the given caret position?
41118
+ function caretIndex(node, offset, leaves) {
41119
+ var r = editor.getDocument().createRange();
41120
+ r.setStart(node, offset);
41121
+ r.collapse(true);
41122
+ var count = 0;
41123
+ for (var i = 0; i < leaves.length; i++) {
41124
+ var leaf = leaves[i];
41125
+ // If the entire leaf is before the caret, add its length.
41126
+ var cmpEnd = r.comparePoint(leaf, leaf.nodeValue.length);
41127
+ if (cmpEnd <= 0) { count += leaf.nodeValue.length; continue; }
41128
+ var cmpStart = r.comparePoint(leaf, 0);
41129
+ if (cmpStart >= 0) break; // leaf starts at or after caret
41130
+ // Caret falls inside this leaf — binary search the split point.
41131
+ var lo = 0, hi = leaf.nodeValue.length;
41132
+ while (lo < hi) {
41133
+ var mid = (lo + hi) >> 1;
41134
+ if (r.comparePoint(leaf, mid) < 0) lo = mid + 1;
41135
+ else hi = mid;
41136
+ }
41137
+ count += lo;
41138
+ break;
41139
+ }
41140
+ return count;
41141
+ }
41142
+
41143
+ function leafOffsetAt(index, leaves) {
41144
+ var remaining = index;
41145
+ for (var i = 0; i < leaves.length; i++) {
41146
+ if (remaining <= leaves[i].nodeValue.length) return { node: leaves[i], offset: remaining };
41147
+ remaining -= leaves[i].nodeValue.length;
41148
+ }
41149
+ return null;
41150
+ }
41151
+
41152
+ function firstTextLeaf(node) {
41153
+ if (!node) return null;
41154
+ if (node.nodeType === 3) return node;
41155
+ var walker = editor.getDocument().createTreeWalker(node, NodeFilter.SHOW_TEXT);
41156
+ return walker.nextNode();
41157
+ }
41158
+
41159
+ function lastTextLeaf(node) {
41160
+ if (!node) return null;
41161
+ if (node.nodeType === 3) return node;
41162
+ var walker = editor.getDocument().createTreeWalker(node, NodeFilter.SHOW_TEXT);
41163
+ var last = null, n;
41164
+ while ((n = walker.nextNode())) last = n;
41165
+ return last;
41166
+ }
41167
+
41168
+ function previousTextLeaf(node) {
41169
+ var editable = editor.getEditable();
41170
+ var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT);
41171
+ var prev = null, cur;
41172
+ while ((cur = walker.nextNode())) {
41173
+ if (cur === node) return prev;
41174
+ prev = cur;
41175
+ }
41176
+ return null;
41177
+ }
41178
+
41179
+ function nextTextLeaf(node) {
41180
+ var editable = editor.getEditable();
41181
+ var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT);
41182
+ var seen = false, cur;
41183
+ while ((cur = walker.nextNode())) {
41184
+ if (seen) return cur;
41185
+ if (cur === node) seen = true;
41186
+ }
41187
+ return null;
41188
+ }
41189
+
41190
+ function rangeInsideMyInsert(range) {
41191
+ var span = range.startContainer.parentNode;
41192
+ while (span && span !== editor.getEditable()) {
41193
+ if (isMyInsert(span)) return range.endContainer.parentNode === span || span.contains(range.endContainer);
41194
+ span = span.parentNode;
41195
+ }
41196
+ return false;
41197
+ }
41198
+
41199
+ function wrapRangeAsDelete(range) {
41200
+ if (range.collapsed) return false;
41201
+ var editdoc = editor.getDocument();
41202
+
41203
+ var fragment = range.cloneContents();
41204
+ if (!fragmentHasText(fragment)) return false;
41205
+
41206
+ // Find existing same-author delete spans adjacent to the range so we can merge
41207
+ // sequential backspaces / forward deletes into one contiguous span.
41208
+ var user = config.currentUser;
41209
+ var mergeBefore = adjacentDeleteSpan(range, "before", user);
41210
+ var mergeAfter = adjacentDeleteSpan(range, "after", user);
41211
+
41212
+ range.deleteContents();
41213
+
41214
+ if (mergeBefore) {
41215
+ // Append the fragment's content to the existing "before" span.
41216
+ appendFragmentToSpan(mergeBefore, fragment);
41217
+ if (mergeAfter && mergeAfter !== mergeBefore) {
41218
+ // Collapse both sides: move "after" span's children into "before" and drop it.
41219
+ while (mergeAfter.firstChild) mergeBefore.appendChild(mergeAfter.firstChild);
41220
+ var afterId = mergeAfter.getAttribute("data-tc-id");
41221
+ mergeAfter.remove();
41222
+ if (afterId && editor.reviewLedger) editor.reviewLedger.remove(afterId);
41223
+ }
41224
+ var rangeAfter = editdoc.createRange();
41225
+ rangeAfter.setStartAfter(mergeBefore);
41226
+ rangeAfter.collapse(true);
41227
+ var sel = editor.getSelection();
41228
+ sel.removeAllRanges();
41229
+ sel.addRange(rangeAfter);
41230
+ // 2026-05-28 Defense: if a customer pasted a delete-class span from
41231
+ // outside this plugin, it may lack data-tc-id. Skip the ledger
41232
+ // update instead of writing under a `null` key (would silently
41233
+ // corrupt the ledger via the update("null", ...) coercion).
41234
+ var beforeId = mergeBefore.getAttribute("data-tc-id");
41235
+ if (beforeId && editor.reviewLedger) {
41236
+ editor.reviewLedger.update(beforeId, { text: mergeBefore.textContent });
41237
+ }
41238
+ return true;
41239
+ }
41240
+
41241
+ if (mergeAfter) {
41242
+ prependFragmentToSpan(mergeAfter, fragment);
41243
+ var rangeBefore = editdoc.createRange();
41244
+ rangeBefore.setStartBefore(mergeAfter);
41245
+ rangeBefore.collapse(true);
41246
+ var sel2 = editor.getSelection();
41247
+ sel2.removeAllRanges();
41248
+ sel2.addRange(rangeBefore);
41249
+ // Same defense as above (mergeAfter may lack data-tc-id).
41250
+ var afterMergeId = mergeAfter.getAttribute("data-tc-id");
41251
+ if (afterMergeId && editor.reviewLedger) {
41252
+ editor.reviewLedger.update(afterMergeId, { text: mergeAfter.textContent });
41253
+ }
41254
+ return true;
41255
+ }
41256
+
41257
+ var span = editdoc.createElement("span");
41258
+ span.className = config.trackChangesDeleteClass;
41259
+ var id = "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
41260
+ span.setAttribute("data-tc-id", id);
41261
+ span.setAttribute("data-tc-author", user.id);
41262
+ span.setAttribute("data-tc-at", new Date().toISOString());
41263
+ span.style.color = user.color;
41264
+ span.style.textDecoration = "line-through";
41265
+ span.appendChild(fragment);
41266
+ range.insertNode(span);
41267
+
41268
+ var after = editdoc.createRange();
41269
+ after.setStartAfter(span);
41270
+ after.collapse(true);
41271
+ var sel3 = editor.getSelection();
41272
+ sel3.removeAllRanges();
41273
+ sel3.addRange(after);
41274
+
41275
+ editor.reviewLedger.add({
41276
+ id: id,
41277
+ changeType: "delete",
41278
+ author: user,
41279
+ text: span.textContent,
41280
+ originalHtml: span.innerHTML,
41281
+ status: "pending",
41282
+ sourceLabel: "Suggested delete",
41283
+ createdAt: Date.now()
41284
+ });
41285
+ return true;
41286
+ }
41287
+
41288
+ function adjacentDeleteSpan(range, side, user) {
41289
+ // Find element immediately before or after the range boundaries at the container level.
41290
+ var node = side === "before" ? range.startContainer : range.endContainer;
41291
+ var offset = side === "before" ? range.startOffset : range.endOffset;
41292
+ if (node.nodeType === 3) {
41293
+ // Look at the sibling of the text node if we're at the text node's boundary.
41294
+ if (side === "before" && offset === 0) {
41295
+ var prev = node.previousSibling;
41296
+ if (prev && isDeleteSpanByAuthor(prev, user)) return prev;
41297
+ }
41298
+ if (side === "after" && offset === node.nodeValue.length) {
41299
+ var next = node.nextSibling;
41300
+ if (next && isDeleteSpanByAuthor(next, user)) return next;
41301
+ }
41302
+ return null;
41303
+ }
41304
+ // Element container: look at children on either side of the offset.
41305
+ if (side === "before" && offset > 0) {
41306
+ var childBefore = node.childNodes[offset - 1];
41307
+ if (childBefore && isDeleteSpanByAuthor(childBefore, user)) return childBefore;
41308
+ }
41309
+ if (side === "after" && offset < node.childNodes.length) {
41310
+ var childAfter = node.childNodes[offset];
41311
+ if (childAfter && isDeleteSpanByAuthor(childAfter, user)) return childAfter;
41312
+ }
41313
+ return null;
41314
+ }
41315
+
41316
+ function isDeleteSpanByAuthor(el, user) {
41317
+ if (!el || el.nodeType !== 1) return false;
41318
+ if (!el.classList || !el.classList.contains("rte-tc-delete")) return false;
41319
+ return el.getAttribute("data-tc-author") === user.id;
41320
+ }
41321
+
41322
+ function appendFragmentToSpan(span, fragment) {
41323
+ while (fragment.firstChild) span.appendChild(fragment.firstChild);
41324
+ }
41325
+
41326
+ function prependFragmentToSpan(span, fragment) {
41327
+ var first = span.firstChild;
41328
+ while (fragment.firstChild) span.insertBefore(fragment.firstChild, first);
41329
+ }
41330
+
41331
+ function fragmentHasText(fragment) {
41332
+ var t = (fragment.textContent || "").replace(/\s+/g, "");
41333
+ return !!t;
41334
+ }
41335
+
41336
+ function cleanupEmptyInsertSpans() {
41337
+ var spans = editor.getEditable().querySelectorAll("." + "rte-tc-insert");
41338
+ for (var i = 0; i < spans.length; i++) {
41339
+ var s = spans[i];
41340
+ if (!s.textContent || !s.textContent.length) {
41341
+ var id = s.getAttribute("data-tc-id");
41342
+ if (id && editor.reviewLedger) editor.reviewLedger.remove(id);
41343
+ s.remove();
41344
+ } else {
41345
+ updateEntryForSpan(s);
41346
+ }
41347
+ }
41348
+ }
41349
+
41350
+ function placeCaretAtEnd(node) {
41351
+ if (!node) return;
41352
+ var editdoc = editor.getDocument();
41353
+ var range = editdoc.createRange();
41354
+ if (node.nodeType === 3) {
41355
+ range.setStart(node, node.nodeValue.length);
41356
+ } else {
41357
+ range.setStart(node, node.childNodes.length);
41358
+ }
41359
+ range.collapse(true);
41360
+ var sel = editor.getSelection();
41361
+ sel.removeAllRanges();
41362
+ sel.addRange(range);
41363
+ }
41364
+
41365
+ // --- accept / reject ---
41366
+
41367
+ function decisionUser() {
41368
+ return config.currentUser || { id: "user", name: "User", color: "#2563eb" };
41369
+ }
41370
+
41371
+ function canDecide(entry, action) {
41372
+ if (typeof config.reviewCanDecide !== "function") return true;
41373
+ var allowed = false;
41374
+ var user = decisionUser();
41375
+ try { allowed = config.reviewCanDecide(entry, action, user, editor) !== false; }
41376
+ catch (err) {
41377
+ console.warn("trackedchanges: reviewCanDecide threw; denying decision.", err);
41378
+ }
41379
+ if (allowed) return true;
41380
+ var detail = { entry: entry, action: action, user: user };
41381
+ try { typeof config.onReviewDecisionDenied === "function" && config.onReviewDecisionDenied(detail); } catch (ignore) { }
41382
+ try { typeof editor.fireEvent === "function" && editor.fireEvent("review_decision_denied", detail); } catch (ignore2) { }
41383
+ return false;
41384
+ }
41385
+
41386
+ function acceptEntry(id) {
41387
+ var entry = editor.reviewLedger.get(id);
41388
+ if (!entry) return false;
41389
+ if (!canDecide(entry, "accept")) return false;
41390
+ var span = editor.getEditable().querySelector('[data-tc-id="' + cssEscape(id) + '"]');
41391
+ if (entry.changeType === "insert") {
41392
+ if (span) unwrapKeepChildren(span);
41393
+ } else if (entry.changeType === "delete") {
41394
+ if (span) span.remove();
41395
+ }
41396
+ editor.reviewLedger.update(id, { status: "accepted", decidedAt: Date.now(), decidedBy: decisionUser() });
41397
+ return true;
41398
+ }
41399
+
41400
+ function rejectEntry(id) {
41401
+ var entry = editor.reviewLedger.get(id);
41402
+ if (!entry) return false;
41403
+ if (!canDecide(entry, "reject")) return false;
41404
+ var span = editor.getEditable().querySelector('[data-tc-id="' + cssEscape(id) + '"]');
41405
+ if (entry.changeType === "insert") {
41406
+ if (span) span.remove();
41407
+ } else if (entry.changeType === "delete") {
41408
+ if (span) unwrapKeepChildren(span);
41409
+ }
41410
+ editor.reviewLedger.update(id, { status: "rejected", decidedAt: Date.now(), decidedBy: decisionUser() });
41411
+ return true;
41412
+ }
41413
+
41414
+ function acceptAll(filter) {
41415
+ var entries = editor.reviewLedger.list(filter).filter(function (e) {
41416
+ return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
41417
+ });
41418
+ var accepted = 0;
41419
+ for (var i = 0; i < entries.length; i++) if (acceptEntry(entries[i].id)) accepted++;
41420
+ return accepted;
41421
+ }
41422
+
41423
+ function rejectAll(filter) {
41424
+ var entries = editor.reviewLedger.list(filter).filter(function (e) {
41425
+ return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
41426
+ });
41427
+ var rejected = 0;
41428
+ for (var i = 0; i < entries.length; i++) if (rejectEntry(entries[i].id)) rejected++;
41429
+ return rejected;
41430
+ }
41431
+
41432
+ function unwrapKeepChildren(el) {
41433
+ var parent = el.parentNode;
41434
+ if (!parent) return;
41435
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
41436
+ parent.removeChild(el);
41437
+ }
41438
+
41439
+ // 2026-05-28 Prefer native CSS.escape — same hardening as comments.js.
41440
+ // Defense-in-depth for customers who wire reviewLedger.add() with custom IDs
41441
+ // containing punctuation (e.g. UUIDs with dashes, OAuth subject claims with
41442
+ // colons, base64 IDs with `=` padding).
41443
+ function cssEscape(s) {
41444
+ var str = String(s);
41445
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
41446
+ try { return CSS.escape(str); } catch (e) { /* fall through */ }
41447
+ }
41448
+ return str.replace(/["\\\]\[\(\)\{\}'#:;,<>=`~!@$%^&*+/?\s\x00-\x1F]/g, function (c) {
41449
+ return "\\" + c;
41450
+ });
41451
+ }
41452
+
41453
+ // --- styles ---
41454
+
41455
+ function injectStyles() {
41456
+ var host = (editor && editor.iframe && editor.iframe.ownerDocument) || document;
41457
+ if (!host.querySelector("style[data-rte-trackchanges]")) {
41458
+ var style = host.createElement("style");
41459
+ style.setAttribute("data-rte-trackchanges", "1");
41460
+ style.textContent = [
41461
+ ".richtexteditor rte-toolbar-button.rte_command_trackchanges{position:relative;border-radius:8px;transition:background .14s ease,color .14s ease,box-shadow .14s ease}",
41462
+ ".richtexteditor rte-toolbar-button.rte_command_trackchanges.rte-ui-active{background:linear-gradient(135deg,#e9f7ef,#eef5ff);color:#17633f;box-shadow:inset 0 0 0 1px rgba(34,132,82,.28),0 6px 14px rgba(34,132,82,.12)}",
41463
+ ".richtexteditor rte-toolbar-button.rte_command_trackchanges.rte-ui-active:after{content:'';position:absolute;right:5px;top:5px;width:6px;height:6px;border-radius:50%;background:#22a66a;box-shadow:0 0 0 2px #fff}",
41464
+ ".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
41465
+ ".rte-tc-insert{background:linear-gradient(180deg,rgba(232,248,239,.78),rgba(232,248,239,.38));color:#125b39;text-decoration:underline;text-decoration-color:#22a66a;text-decoration-thickness:2px;text-underline-offset:3px}",
41466
+ ".rte-tc-delete{background:linear-gradient(180deg,rgba(255,240,240,.82),rgba(255,240,240,.42));color:#9f1d1d;text-decoration:line-through;text-decoration-color:#dc4c4c;text-decoration-thickness:2px;opacity:.92}"
41467
+ ].join("\n");
41468
+ host.head.appendChild(style);
41469
+ }
41470
+
41471
+ var editdoc = editor.getDocument();
41472
+ if (editdoc && editdoc.head && !editdoc.querySelector("style[data-rte-trackchanges-inline]")) {
41473
+ var iStyle = editdoc.createElement("style");
41474
+ iStyle.setAttribute("data-rte-trackchanges-inline", "1");
41475
+ iStyle.textContent = [
41476
+ ".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
41477
+ ".rte-tc-insert{background:linear-gradient(180deg,rgba(232,248,239,.78),rgba(232,248,239,.38));color:#125b39;text-decoration:underline;text-decoration-color:#22a66a;text-decoration-thickness:2px;text-underline-offset:3px}",
41478
+ ".rte-tc-delete{background:linear-gradient(180deg,rgba(255,240,240,.82),rgba(255,240,240,.42));color:#9f1d1d;text-decoration:line-through;text-decoration-color:#dc4c4c;text-decoration-thickness:2px;opacity:.92}"
41479
+ ].join("\n");
41480
+ editdoc.head.appendChild(iStyle);
41481
+ }
41482
+ }
41483
+ }
39582
41484
 
39583
41485
  if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
39584
41486
 
@@ -40175,7 +42077,7 @@ function RTE_Plugin_WordExport() {
40175
42077
  if (!name) return "";
40176
42078
  // Strip control chars + filesystem-reserved characters, collapse spaces.
40177
42079
  return String(name)
40178
- .replace(/[\\/:*?"<>|-]+/g, " ")
42080
+ .replace(/[\\/:*?"<>|\x00-\x1f]+/g, " ")
40179
42081
  .replace(/\s+/g, " ")
40180
42082
  .trim()
40181
42083
  .slice(0, 80);