@richscripts/richtexteditor 2.14.0 → 2.14.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.
@@ -25526,36 +25526,47 @@ function RTE_Plugin_DocumentOutline() {
25526
25526
  // valid and the pasted copy becomes "slug-2". Ids on non-heading elements are
25527
25527
  // reserved too, so a heading never takes one. Every heading is checked, including
25528
25528
  // levels the outline does not display, since they share one id namespace.
25529
- var used = {};
25530
- var withId = editable.querySelectorAll("[id]");
25531
- for (i = 0; i < withId.length; i++) {
25532
- if (!/^H[1-6]$/.test(withId[i].nodeName)) used[withId[i].id] = true;
25533
- }
25534
- // Headings with text claim ids first. An EMPTY heading is usually the shell a cut
25535
- // leaves behind, still carrying the moved heading's id; letting it claim first would
25536
- // hand the moved heading "-2" and break every link to it. So empty headings go last
25537
- // and simply drop an id that a real heading already holds.
25538
- var textOf = function (el) {
25539
- return (el.innerText || el.textContent || "").replace(/\s+/g, " ").replace(/^\s+|\s+$/g, "");
25540
- };
25541
- var empties = [];
25542
- for (i = 0; i < headings.length; i++) {
25543
- var h = headings[i];
25544
- var t = textOf(h);
25545
- if (!t) { empties.push(h); continue; }
25546
- var base = h.id;
25547
- if (!base) base = slugifyHeading(t);
25548
- else if (!used[base]) { used[base] = true; continue; }
25549
- var unique = base;
25550
- var n = 2;
25551
- while (used[unique]) { unique = base + "-" + n; n++; }
25552
- used[unique] = true;
25553
- if (h.id !== unique) h.id = unique;
25554
- }
25555
- for (i = 0; i < empties.length; i++) {
25556
- if (!empties[i].id) continue;
25557
- if (used[empties[i].id]) empties[i].removeAttribute("id");
25558
- else used[empties[i].id] = true;
25529
+ //
25530
+ // 2026-09-25 ...but only when the core does not own heading ids. With
25531
+ // autoHeadingAnchors on (the default) the core pass gives every heading a unique id
25532
+ // after each change, with the same first-keeps rule, and marks the ids it generated so
25533
+ // they can follow the text. This block ran 80 ms after each change instead: a heading
25534
+ // a person was typing got the slug of its FIRST letter ("Typed heading" -> id="t"),
25535
+ // unmarked, so the core took it for the author's own id and kept it for good. The
25536
+ // outline needs no id to work (it navigates by element), so it now leaves ids to the
25537
+ // core and keeps this pass for integrations that switched core anchors off.
25538
+ if (config.autoHeadingAnchors === false) {
25539
+ var used = {};
25540
+ var withId = editable.querySelectorAll("[id]");
25541
+ for (i = 0; i < withId.length; i++) {
25542
+ if (!/^H[1-6]$/.test(withId[i].nodeName)) used[withId[i].id] = true;
25543
+ }
25544
+ // Headings with text claim ids first. An EMPTY heading is usually the shell a cut
25545
+ // leaves behind, still carrying the moved heading's id; letting it claim first would
25546
+ // hand the moved heading "-2" and break every link to it. So empty headings go last
25547
+ // and simply drop an id that a real heading already holds.
25548
+ var textOf = function (el) {
25549
+ return (el.innerText || el.textContent || "").replace(/\s+/g, " ").replace(/^\s+|\s+$/g, "");
25550
+ };
25551
+ var empties = [];
25552
+ for (i = 0; i < headings.length; i++) {
25553
+ var h = headings[i];
25554
+ var t = textOf(h);
25555
+ if (!t) { empties.push(h); continue; }
25556
+ var base = h.id;
25557
+ if (!base) base = slugifyHeading(t);
25558
+ else if (!used[base]) { used[base] = true; continue; }
25559
+ var unique = base;
25560
+ var n = 2;
25561
+ while (used[unique]) { unique = base + "-" + n; n++; }
25562
+ used[unique] = true;
25563
+ if (h.id !== unique) h.id = unique;
25564
+ }
25565
+ for (i = 0; i < empties.length; i++) {
25566
+ if (!empties[i].id) continue;
25567
+ if (used[empties[i].id]) empties[i].removeAttribute("id");
25568
+ else used[empties[i].id] = true;
25569
+ }
25559
25570
  }
25560
25571
  for (i = 0; i < headings.length; i++) {
25561
25572
  var heading = headings[i];
@@ -28477,22 +28488,67 @@ function RTE_Plugin_FormattingMarks() {
28477
28488
  // may be deliberate.
28478
28489
  if (parent.nodeName === "CODE" || parent.nodeName === "PRE") return;
28479
28490
 
28491
+ // Replacing the text node moves a caret inside it to the node's old place in its
28492
+ // parent - usually the paragraph start. This runs on every keyup, and the first
28493
+ // space typed at a line end is always a non-breaking one, so with marks on every
28494
+ // later character landed at the start, reversed: " edited" became "detide".
28495
+ // Remember where the selection was inside this node and put it back.
28496
+ var sel = doc.getSelection ? doc.getSelection() : null;
28497
+ var range = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
28498
+ var keepStart = range && range.startContainer === node ? range.startOffset : -1;
28499
+ var keepEnd = range && range.endContainer === node ? range.endOffset : -1;
28500
+
28480
28501
  var frag = doc.createDocumentFragment();
28502
+ var pieces = []; // in order: { node, marker } - every marker stands for ONE character
28481
28503
  var buf = "";
28482
28504
  for (var i = 0; i < text.length; i++) {
28483
28505
  var def = chars[text[i]];
28506
+ // A space typed at a line end is inserted as a non-breaking placeholder that the
28507
+ // browser turns back into a space when the next character arrives. Wrapped in a
28508
+ // non-editable marker it never could, and saved as a hard &nbsp;. Leave the one
28509
+ // right before the caret alone; it is marked once the caret moves on.
28510
+ if (def && text[i] === " " && i === keepStart - 1 && keepStart === keepEnd) def = null;
28484
28511
  if (!def) { buf += text[i]; continue; }
28485
- if (buf) { frag.appendChild(doc.createTextNode(buf)); buf = ""; }
28512
+ if (buf) { pieces.push({ node: frag.appendChild(doc.createTextNode(buf)), marker: false }); buf = ""; }
28486
28513
  var span = doc.createElement("span");
28487
28514
  span.setAttribute("data-rte-fm", "1");
28488
28515
  span.setAttribute("data-rte-fm-char", text[i]);
28489
28516
  span.className = def.cls;
28490
28517
  span.setAttribute("contenteditable", "false");
28491
28518
  span.textContent = def.label;
28492
- frag.appendChild(span);
28519
+ pieces.push({ node: frag.appendChild(span), marker: true });
28493
28520
  }
28494
- if (buf) frag.appendChild(doc.createTextNode(buf));
28521
+ if (buf) pieces.push({ node: frag.appendChild(doc.createTextNode(buf)), marker: false });
28495
28522
  parent.replaceChild(frag, node);
28523
+
28524
+ if (keepStart < 0 && keepEnd < 0) return;
28525
+ // Offset k in the old text -> a point in the pieces. Inside text where possible;
28526
+ // never inside a marker (it is not editable): just before or after it.
28527
+ function pointAt(k) {
28528
+ var pos = 0;
28529
+ for (var p = 0; p < pieces.length; p++) {
28530
+ var piece = pieces[p];
28531
+ if (piece.marker) {
28532
+ if (k <= pos) return { node: parent, offset: Array.prototype.indexOf.call(parent.childNodes, piece.node) };
28533
+ pos += 1;
28534
+ } else {
28535
+ var len = piece.node.nodeValue.length;
28536
+ if (k <= pos + len) return { node: piece.node, offset: k - pos };
28537
+ pos += len;
28538
+ }
28539
+ }
28540
+ var last = pieces[pieces.length - 1].node;
28541
+ return { node: parent, offset: Array.prototype.indexOf.call(parent.childNodes, last) + 1 };
28542
+ }
28543
+ try {
28544
+ var r = doc.createRange();
28545
+ var a = pointAt(keepStart >= 0 ? keepStart : 0);
28546
+ var b = keepEnd >= 0 ? pointAt(keepEnd) : a;
28547
+ if (keepStart < 0) { r.setStart(range.startContainer, range.startOffset); } else r.setStart(a.node, a.offset);
28548
+ if (keepEnd < 0) { r.setEnd(range.endContainer, range.endOffset); } else r.setEnd(b.node, b.offset);
28549
+ sel.removeAllRanges();
28550
+ sel.addRange(r);
28551
+ } catch (e) { /* a selection we cannot restore is no reason to lose the marks */ }
28496
28552
  }
28497
28553
 
28498
28554
  // ---- serialization safety -------------------------------------------
@@ -40277,6 +40333,7 @@ function RTE_Plugin_Sanitizer() {
40277
40333
  }
40278
40334
 
40279
40335
  var attrs = [];
40336
+ var strippedSrc = false;
40280
40337
  for (var a = 0; a < el.attributes.length; a++) attrs.push(el.attributes[a]);
40281
40338
  for (var k = 0; k < attrs.length; k++) {
40282
40339
  var name = attrs[k].name.toLowerCase();
@@ -40294,7 +40351,11 @@ function RTE_Plugin_Sanitizer() {
40294
40351
  if (name.indexOf("data-") === 0 || name.indexOf("aria-") === 0) continue;
40295
40352
 
40296
40353
  if (URL_ATTRS[name]) {
40297
- if (!isSafeUrl(value, tag, name)) { note(report.removedAttributes, tag + "[" + name + "]"); el.removeAttribute(attrs[k].name); }
40354
+ if (!isSafeUrl(value, tag, name)) {
40355
+ note(report.removedAttributes, tag + "[" + name + "]");
40356
+ el.removeAttribute(attrs[k].name);
40357
+ if (name === "src") strippedSrc = true;
40358
+ }
40298
40359
  continue;
40299
40360
  }
40300
40361
  if (!ALLOWED_ATTRS[name] && !attrExtra(name)) {
@@ -40303,6 +40364,18 @@ function RTE_Plugin_Sanitizer() {
40303
40364
  }
40304
40365
  }
40305
40366
 
40367
+ // An <img> whose only source was just stripped is an empty box that
40368
+ // saves as <img> with no src -- dragging a page image into the editor
40369
+ // from a page opened on disk did exactly that (its file: src is not a
40370
+ // safe scheme). Drop the element when nothing is left to show. Keep it
40371
+ // when srcset or a lazy-load data-*src still carries an image, and never
40372
+ // touch an <img> that arrived without a src (an app placeholder).
40373
+ if (tag === "img" && strippedSrc && !el.hasAttribute("srcset") && !hasDataSource(el)) {
40374
+ note(report.removedTags, "img");
40375
+ el.parentNode.removeChild(el);
40376
+ continue;
40377
+ }
40378
+
40306
40379
  // A link that opens a new tab without rel="noopener" hands the
40307
40380
  // opener window to the destination.
40308
40381
  if (tag === "a" && (el.getAttribute("target") || "").toLowerCase() === "_blank") {
@@ -40319,6 +40392,12 @@ function RTE_Plugin_Sanitizer() {
40319
40392
  parent.removeChild(el);
40320
40393
  }
40321
40394
  function note(list, what) { if (list.indexOf(what) < 0) list.push(what); }
40395
+ function hasDataSource(el) {
40396
+ for (var i = 0; i < el.attributes.length; i++) {
40397
+ if (/^data-(.*-)?(src|srcset|original)$/i.test(el.attributes[i].name)) return true;
40398
+ }
40399
+ return false;
40400
+ }
40322
40401
 
40323
40402
  function sanitizeHtml(html) {
40324
40403
  var out = html;