@innovastudio/contentbuilder 1.5.221 → 1.5.223

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@innovastudio/contentbuilder",
4
- "version": "1.5.221",
4
+ "version": "1.5.223",
5
5
  "description": "",
6
6
  "main": "public/contentbuilder/contentbuilder.esm.js",
7
7
  "types": "index.d.ts",
@@ -4977,6 +4977,12 @@ i.bi, i.icon {
4977
4977
  display: inline-flex;
4978
4978
  }
4979
4979
 
4980
+ /* As a flex box an icon ignores Bootstrap Icons' own vertical-align (on its ::before) and sits
4981
+ higher than the text beside it in the editor. Put the same offset on the icon itself. */
4982
+ .is-container i.bi {
4983
+ vertical-align: -0.125em;
4984
+ }
4985
+
4980
4986
  /* block tool */
4981
4987
  /*
4982
4988
  .is-block-tool {
@@ -5591,6 +5591,10 @@ class ToolbarKeys {
5591
5591
  */
5592
5592
  _hold(container, control) {
5593
5593
  if (!control) return;
5594
+
5595
+ // Where the control was: if the command removes what it sat on, the caret
5596
+ // goes to what took its place
5597
+ const spot = (control.closest('.row, [data-cb-block]') || control).getBoundingClientRect();
5594
5598
  setTimeout(() => {
5595
5599
  // Not bailing when the container has gone: deleting the last column
5596
5600
  // takes the strip with it, and that is exactly when the focus needs
@@ -5648,8 +5652,10 @@ class ToolbarKeys {
5648
5652
  // A tick later again: emptying an area makes the editor put its
5649
5653
  // own button there, and that has not happened yet.
5650
5654
  setTimeout(() => {
5651
- const somewhere = this._somewhereUsable();
5652
- if (somewhere) somewhere.focus();
5655
+ const somewhere = this._somewhereUsable(spot);
5656
+ if (somewhere) somewhere.focus({
5657
+ preventScroll: true
5658
+ });
5653
5659
  }, 30);
5654
5660
  }
5655
5661
  }, 0);
@@ -5720,7 +5726,9 @@ class ToolbarKeys {
5720
5726
  // the trip to the toolbar after one button. Stay on it, so the next format
5721
5727
  // is a key away, and leave deliberately with Escape. Anything that claims
5722
5728
  // the focus for itself, a panel or a dialog, is left to do so.
5729
+ // A mouse click (detail > 0) is left alone, so the mouse works as it always did.
5723
5730
  tool.addEventListener('click', e => {
5731
+ if (e.detail > 0) return;
5724
5732
  const control = this._controlFrom(e, tool, BUTTONS);
5725
5733
  if (control) this._hold(tool, control);
5726
5734
  });
@@ -5862,13 +5870,26 @@ class ToolbarKeys {
5862
5870
  * go dead with the focus stuck on a strip. Falls back through what the editor
5863
5871
  * still calls active, then to the first column on the page.
5864
5872
  */
5865
- currentColumn() {
5873
+ currentColumn(near) {
5866
5874
  // It has to be able to hold the focus. A locked column is still the active
5867
5875
  // one but cannot take it, and focusing it silently does nothing.
5868
5876
  const usable = el => el && el.isConnected && (el.isContentEditable || el.hasAttribute('tabindex')) ? el : null;
5869
5877
  const active = usable(this.builder.util.cellSelected()) || usable(this.builder.activeCol);
5870
5878
  if (active) return active;
5871
5879
  const rows = this.builder.doc.querySelectorAll(`${this.builder.opts.container} > *`);
5880
+
5881
+ // Near a place on screen (where a removed row was): the column that took its
5882
+ // place, or the last one before it
5883
+ if (near) {
5884
+ let before = null;
5885
+ for (const row of rows) {
5886
+ const cell = ToolbarKeys.cells(row).find(usable);
5887
+ if (!cell) continue;
5888
+ if (cell.getBoundingClientRect().top >= near.top - 1) return cell;
5889
+ before = cell;
5890
+ }
5891
+ if (before) return before;
5892
+ }
5872
5893
  for (const row of rows) {
5873
5894
  const cell = ToolbarKeys.cells(row).find(usable);
5874
5895
  if (cell) return cell;
@@ -5882,8 +5903,8 @@ class ToolbarKeys {
5882
5903
  * editing away: in both the focus would otherwise land on the body, where no
5883
5904
  * key reaches the editor at all.
5884
5905
  */
5885
- _somewhereUsable() {
5886
- const column = this.currentColumn();
5906
+ _somewhereUsable(near) {
5907
+ const column = this.currentColumn(near);
5887
5908
  if (column) return column;
5888
5909
  const doc = this.builder.doc;
5889
5910
 
@@ -7056,6 +7077,18 @@ class Util {
7056
7077
  // A toolbar button that opens a dialog often moves the focus first, so the
7057
7078
  // strip fills this in from the button actually used (toolbarkeys.js).
7058
7079
 
7080
+ // Opened from a control inside the content iframe: this page never saw
7081
+ // that mouse click, so the browser would ring the dialog's first control
7082
+ // as if the keyboard had been used. Carry the opener's own state over.
7083
+ let focusOpts;
7084
+ if (opener && opener.tagName === 'IFRAME') {
7085
+ try {
7086
+ const inner = opener.contentDocument.activeElement;
7087
+ if (!(inner && inner.matches(':focus-visible'))) focusOpts = {
7088
+ focusVisible: false
7089
+ };
7090
+ } catch (e) {/* another origin */}
7091
+ }
7059
7092
  if (overflowHidden) document.body.style.overflow = 'hidden';
7060
7093
  const handleKeyDown = e => {
7061
7094
  if (e.keyCode === 27) {
@@ -7073,11 +7106,11 @@ class Util {
7073
7106
  modal.addEventListener('keydown', handleKeyDown);
7074
7107
  let content = modal.querySelector('.is-tab-content.active');
7075
7108
  if (content) {
7076
- content.focus();
7109
+ content.focus(focusOpts);
7077
7110
  this.builder.tabs.setupTabKey(content);
7078
7111
  } else {
7079
7112
  this.setupTabKeys(modal);
7080
- modal.focus();
7113
+ modal.focus(focusOpts);
7081
7114
  }
7082
7115
 
7083
7116
  // Land on a control rather than on the dialog itself: focus on the dialog
@@ -7097,7 +7130,7 @@ class Util {
7097
7130
  const settled = modal.contains(held) && held !== modal && held.tagName !== 'IFRAME' && !held.classList.contains('is-tab-content');
7098
7131
  if (settled) return;
7099
7132
  const first = ToolbarKeys.firstControl(modal);
7100
- if (first) first.focus();
7133
+ if (first) first.focus(focusOpts);
7101
7134
  }, 0);
7102
7135
 
7103
7136
  // A dialog whose content is an iframe, the asset picker, is a trap without
@@ -9126,6 +9159,7 @@ class Util {
9126
9159
  * Placeholders substituted by export():
9127
9160
  * [%CONTENT%] the content HTML
9128
9161
  * [%PLUGINS%] runtime plugin registry (see getPluginRegistry)
9162
+ * [%PLUGINFOLDER%] runtime plugin folder (see getPluginFolder)
9129
9163
  * [%PATH%] base URL for runtime/asset references
9130
9164
  */
9131
9165
  getPageTemplate(framework) {
@@ -9163,7 +9197,8 @@ class Util {
9163
9197
  // Registers available plugins (not yet loaded).
9164
9198
  // Scripts and styles are fetched only when the plugin is actually used in content.
9165
9199
  const runtime = new ContentBuilderRuntime({
9166
- plugins: [%PLUGINS%]
9200
+ plugins: [%PLUGINS%],
9201
+ pluginFolder: [%PLUGINFOLDER%]
9167
9202
  });
9168
9203
  runtime.init();
9169
9204
  runtime.renderPageCss('.container');
@@ -9213,7 +9248,8 @@ class Util {
9213
9248
  // Registers available plugins (not yet loaded).
9214
9249
  // Scripts and styles are fetched only when the plugin is actually used in content.
9215
9250
  const runtime = new ContentBuilderRuntime({
9216
- plugins: [%PLUGINS%]
9251
+ plugins: [%PLUGINS%],
9252
+ pluginFolder: [%PLUGINFOLDER%]
9217
9253
  });
9218
9254
  runtime.init();
9219
9255
  </script>
@@ -9266,7 +9302,8 @@ class Util {
9266
9302
  // Registers available plugins (not yet loaded).
9267
9303
  // Scripts and styles are fetched only when the plugin is actually used in content.
9268
9304
  const runtime = new ContentBuilderRuntime({
9269
- plugins: [%PLUGINS%]
9305
+ plugins: [%PLUGINS%],
9306
+ pluginFolder: [%PLUGINFOLDER%]
9270
9307
  });
9271
9308
  runtime.init();
9272
9309
  </script>
@@ -9324,7 +9361,8 @@ class Util {
9324
9361
  // Registers available plugins (not yet loaded).
9325
9362
  // Scripts and styles are fetched only when the plugin is actually used in content.
9326
9363
  const runtime = new ContentBuilderRuntime({
9327
- plugins: [%PLUGINS%]
9364
+ plugins: [%PLUGINS%],
9365
+ pluginFolder: [%PLUGINFOLDER%]
9328
9366
  });
9329
9367
  runtime.init();
9330
9368
  </script>
@@ -9376,7 +9414,8 @@ class Util {
9376
9414
  // Registers available plugins (not yet loaded).
9377
9415
  // Scripts and styles are fetched only when the plugin is actually used in content.
9378
9416
  const runtime = new ContentBuilderRuntime({
9379
- plugins: [%PLUGINS%]
9417
+ plugins: [%PLUGINS%],
9418
+ pluginFolder: [%PLUGINFOLDER%]
9380
9419
  });
9381
9420
  runtime.init();
9382
9421
  </script>
@@ -9430,6 +9469,27 @@ class Util {
9430
9469
  });
9431
9470
  return JSON.stringify(registry, null, 4);
9432
9471
  }
9472
+
9473
+ /*
9474
+ * The runtime's plugin folder (pluginFolder in blank.html), as a JS string.
9475
+ * Plugins used in the content load from it without being registered, so an
9476
+ * exported page keeps them. A URL under the content frame's base becomes a
9477
+ * [%PATH%] token, like the registry's URLs.
9478
+ */
9479
+ getPluginFolder() {
9480
+ const win = this.builder.win;
9481
+ let folder = '';
9482
+ try {
9483
+ folder = win.builderRuntime.settings.pluginFolder || '';
9484
+ } catch (e) {/* no runtime in the content frame */}
9485
+ let base = '';
9486
+ try {
9487
+ const href = win.location.href;
9488
+ base = href.substring(0, href.lastIndexOf('/'));
9489
+ } catch (e) {/* cross-origin content frame */}
9490
+ if (base && folder.startsWith(base + '/')) folder = '[%PATH%]' + folder.substring(base.length);
9491
+ return JSON.stringify(folder);
9492
+ }
9433
9493
  getElementPosition(element) {
9434
9494
  const iframe = this.builder.iframe;
9435
9495
  let top, left;
@@ -9641,6 +9701,46 @@ class Util {
9641
9701
  }
9642
9702
  `;
9643
9703
  }
9704
+
9705
+ // Place a small tool (link/button tool) above its target. When the target sits at the
9706
+ // top edge with no room above, put the tool beside it (right, then left), so it never
9707
+ // lands on the element tool below the element. Below is the last resort.
9708
+ // vTarget (optional, e.g. a selected icon inside a link) sets the vertical position.
9709
+ // The tool must be displayed before this (its size is measured).
9710
+ placeToolAbove(tool, target, vTarget) {
9711
+ const hr = target.getBoundingClientRect();
9712
+ const vr = (vTarget || target).getBoundingClientRect();
9713
+ tool.style.top = vr.top + this.builder.win.pageYOffset - tool.offsetHeight - 3 + 'px';
9714
+ tool.style.left = hr.left + target.offsetWidth * this.builder.opts.zoom - tool.offsetWidth + 'px';
9715
+
9716
+ // Measured, not computed: the tool can carry margins and a scale.
9717
+ const t = tool.getBoundingClientRect();
9718
+ if (t.top >= 0) return;
9719
+ const moveTo = (left, top) => {
9720
+ const c = tool.getBoundingClientRect();
9721
+ tool.style.left = parseFloat(tool.style.left) + left - c.left + 'px';
9722
+ tool.style.top = parseFloat(tool.style.top) + top - c.top + 'px';
9723
+ };
9724
+
9725
+ // Horizontal room, kept clear of the row tool when it sits beside the target.
9726
+ let minX = 0;
9727
+ let maxX = this.builder.doc.documentElement.clientWidth;
9728
+ const row = target.closest('.row');
9729
+ const rowTool = row && row.querySelector(':scope > .is-row-tool');
9730
+ if (rowTool && rowTool.offsetParent) {
9731
+ const r = rowTool.getBoundingClientRect();
9732
+ if (r.left >= hr.right) maxX = Math.min(maxX, r.left);else if (r.right <= hr.left) minX = Math.max(minX, r.right);
9733
+ }
9734
+ const gap = 8;
9735
+ const sideTop = vr.top + vr.height / 2 - t.height / 2;
9736
+ if (hr.right + gap + t.width <= maxX) {
9737
+ moveTo(hr.right + gap, sideTop);
9738
+ } else if (hr.left - gap - t.width >= minX) {
9739
+ moveTo(hr.left - gap - t.width, sideTop);
9740
+ } else {
9741
+ moveTo(t.left, vr.bottom + gap);
9742
+ }
9743
+ }
9644
9744
  }
9645
9745
  class Dom {
9646
9746
  constructor(builder) {
@@ -32502,15 +32602,8 @@ class Hyperlink {
32502
32602
  }
32503
32603
  // /Button Stuff
32504
32604
 
32505
- let left, top;
32506
- top = link.getBoundingClientRect().top + this.builder.win.pageYOffset;
32507
- if (this.builder.activeIcon) top = this.builder.activeIcon.getBoundingClientRect().top + this.builder.win.pageYOffset;
32508
- left = link.getBoundingClientRect().left;
32509
- linkTool.style.display = 'flex';
32510
- const w = linkTool.offsetWidth; //to get value, element must not hidden (display:none). So set display:flex before this.
32511
- const h = linkTool.offsetHeight;
32512
- linkTool.style.top = top - h - 3 + 'px';
32513
- linkTool.style.left = left + link.offsetWidth * this.builder.opts.zoom - w + 'px';
32605
+ linkTool.style.display = 'flex'; //to get the size, the tool must not be hidden (display:none)
32606
+ this.builder.util.placeToolAbove(linkTool, link, this.builder.activeIcon);
32514
32607
  const handleLinkButtonClick = e => {
32515
32608
  const clrPicker = document.querySelector('.pop-picker.active') || document.querySelector('.pickgradientcolor.active');
32516
32609
  let elm = e.target;
@@ -32809,13 +32902,8 @@ class Button {
32809
32902
  // selecting the button, so the pencil is a second route to the same
32810
32903
  // panel and is left out.
32811
32904
  buttonTool.querySelector('.button-edit').style.display = this.builder.opts.onButtonEdit ? 'none' : '';
32812
- let top = activeButton.getBoundingClientRect().top + this.builder.win.pageYOffset;
32813
- let left = activeButton.getBoundingClientRect().left;
32814
- buttonTool.style.display = 'flex';
32815
- const w = buttonTool.offsetWidth; //to get value, element must not hidden (display:none). So set display:flex before this.
32816
- const h = buttonTool.offsetHeight;
32817
- buttonTool.style.top = top - h - 3 + 'px';
32818
- buttonTool.style.left = left + activeButton.offsetWidth * this.builder.opts.zoom - w + 'px';
32905
+ buttonTool.style.display = 'flex'; //to get the size, the tool must not be hidden (display:none)
32906
+ this.builder.util.placeToolAbove(buttonTool, activeButton);
32819
32907
  }
32820
32908
  hideTool() {
32821
32909
  if (this.buttonTool) this.buttonTool.style.display = '';
@@ -34012,7 +34100,10 @@ class Plugin {
34012
34100
  const handlePluginClick = e => {
34013
34101
  const clrPicker = document.querySelector('.pop-picker.active') || document.querySelector('.pickgradientcolor.active');
34014
34102
  let elm = e.target;
34015
- let elmRemoved = !document.contains(elm); // e.g. when deleting a card in card list plugin
34103
+
34104
+ // isConnected, not document.contains(): in iframe mode the click is in
34105
+ // the content document, which this document never contains.
34106
+ let elmRemoved = !elm.isConnected; // e.g. when deleting a card in card list plugin
34016
34107
 
34017
34108
  if (this.builder.doc.activeElement) {
34018
34109
  if (this.builder.doc.activeElement.closest('.is-modal')) {
@@ -34210,7 +34301,9 @@ class Plugin {
34210
34301
  const plugin = e.target.closest('[data-cb-type]');
34211
34302
  if (plugin) {
34212
34303
  this.builder.activePlugin = plugin;
34213
- if (!this.builder.isContentBox) {
34304
+
34305
+ // With a control panel, plugin settings open there instead.
34306
+ if (!this.builder.controlPanel) {
34214
34307
  this.renderTool();
34215
34308
  this.pluginTool.style.display = 'flex';
34216
34309
  let _toolwidth = this.pluginTool.offsetWidth; //to get value, element must not hidden (display:none). So set display:flex before this.
@@ -36486,6 +36579,10 @@ class Element {
36486
36579
  customcode = true;
36487
36580
  subblock = elm.closest('.is-subblock');
36488
36581
  }
36582
+ if (elm.closest('[data-cb-code]')) {
36583
+ // Independent code element: the whole block is the element
36584
+ customcode = true;
36585
+ }
36489
36586
  if (!customcode && !noedit && !_protected || subblock) {
36490
36587
  //previously this is commented: && !noedit && !_protected
36491
36588
 
@@ -58147,6 +58244,136 @@ function coverValue(cover, def) {
58147
58244
  return true;
58148
58245
  }
58149
58246
 
58247
+ /*
58248
+ normalizer.js — the clean-output guarantee.
58249
+
58250
+ Runs after every mutation. It never changes the visible text; it only
58251
+ reduces markup to the minimal equivalent form:
58252
+
58253
+ <strong>a</strong><strong>b</strong> -> <strong>ab</strong>
58254
+ <strong><strong>x</strong></strong> -> <strong>x</strong>
58255
+ <span style="color:red"><span style="font-size:18px">x</span></span>
58256
+ -> one span, two properties
58257
+ <span>x</span> -> x
58258
+ <em></em> -> (removed)
58259
+
58260
+ Because it preserves text content exactly, character-offset selection
58261
+ bookmarks taken before normalization remain valid after it.
58262
+ */
58263
+ const INLINE_TAGS = new Set(['SPAN', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'DEL', 'INS', 'SUB', 'SUP', 'CODE', 'MARK', 'A', 'SMALL', 'BIG', 'FONT', 'ABBR', 'Q', 'CITE', 'DFN', 'KBD', 'SAMP', 'VAR', 'TIME']);
58264
+
58265
+ /* Identity signature: two inline elements with equal signatures are
58266
+ interchangeable and can be merged. */
58267
+ function sig(el) {
58268
+ const attrs = [];
58269
+ for (const a of el.attributes) {
58270
+ attrs.push(a.name === 'style' ? 'style=' + el.style.cssText : a.name + '=' + a.value);
58271
+ }
58272
+ return el.tagName + '|' + attrs.sort().join(';');
58273
+ }
58274
+ function isEmptyInline(el) {
58275
+ if (el.textContent !== '' || el.querySelector(KEEPERS)) return false;
58276
+ // An empty inline that carries a class typically renders its content via CSS
58277
+ // rather than text — an icon font (<i class="bi bi-alarm">, <span
58278
+ // class="material-icons">…</span>, fa-*, glyphicon). It is NOT truly empty,
58279
+ // so never drop it. Bare leftover marks (<em></em>, <i></i>) have no class
58280
+ // and are still removed.
58281
+ if (el.getAttribute('class')) return false;
58282
+ return true;
58283
+ }
58284
+
58285
+ /* Does the page's CSS style this attribute-less span? A design can colour or
58286
+ size "the span in this heading" from a stylesheet (.hero h1 span {...});
58287
+ unwrapping it then changes how the text looks, which this file promises
58288
+ never to do. Compared against the parent for inherited properties and
58289
+ against the initial value for the rest. Without a live view (detached, or
58290
+ a headless test) it is treated as unstyled, as before. */
58291
+ const INHERITED = ['color', 'fontSize', 'fontWeight', 'fontStyle', 'fontFamily', 'letterSpacing', 'textTransform', 'lineHeight'];
58292
+ function styledByCss(el) {
58293
+ const parent = el.parentElement;
58294
+ const win = el.ownerDocument && el.ownerDocument.defaultView;
58295
+ if (!parent || !win || !el.isConnected || typeof win.getComputedStyle !== 'function') return false;
58296
+ const cs = win.getComputedStyle(el);
58297
+ const ps = win.getComputedStyle(parent);
58298
+ for (const p of INHERITED) if (cs[p] !== ps[p]) return true;
58299
+ if (cs.display && cs.display !== 'inline') return true;
58300
+ if (cs.backgroundImage && cs.backgroundImage !== 'none') return true;
58301
+ if (cs.backgroundColor && !/^(transparent|rgba\(0, 0, 0, 0\))$/.test(cs.backgroundColor)) return true;
58302
+ if (cs.textDecorationLine && cs.textDecorationLine !== 'none') return true;
58303
+ return false;
58304
+ }
58305
+
58306
+ /* One bottom-up cleanup pass. Returns true if anything changed. */
58307
+ function pass(scope) {
58308
+ let changed = false;
58309
+ const els = Array.prototype.slice.call(scope.querySelectorAll('*')).reverse();
58310
+ for (const el of els) {
58311
+ if (!el.isConnected && !scope.contains(el)) continue;
58312
+ if (el.closest && el.closest('[contenteditable="false"]')) continue;
58313
+ if (!INLINE_TAGS.has(el.tagName)) continue;
58314
+
58315
+ /* 1. Drop empty inline elements */
58316
+ if (isEmptyInline(el)) {
58317
+ el.remove();
58318
+ changed = true;
58319
+ continue;
58320
+ }
58321
+
58322
+ /* 2. Unwrap attribute-less spans (unless the page's CSS styles them) */
58323
+ if (el.tagName === 'SPAN' && el.attributes.length === 0 && !styledByCss(el)) {
58324
+ unwrap(el);
58325
+ changed = true;
58326
+ continue;
58327
+ }
58328
+
58329
+ /* 3. Unwrap when an identical ancestor already covers this element */
58330
+ let anc = el.parentNode;
58331
+ let redundant = false;
58332
+ while (isEl$1(anc) && anc !== scope && INLINE_TAGS.has(anc.tagName)) {
58333
+ if (sig(anc) === sig(el)) {
58334
+ redundant = true;
58335
+ break;
58336
+ }
58337
+ anc = anc.parentNode;
58338
+ }
58339
+ if (redundant) {
58340
+ unwrap(el);
58341
+ changed = true;
58342
+ continue;
58343
+ }
58344
+
58345
+ /* 4. Merge with an identical previous sibling */
58346
+ const prev = el.previousSibling;
58347
+ if (isEl$1(prev) && sig(prev) === sig(el)) {
58348
+ while (el.firstChild) prev.appendChild(el.firstChild);
58349
+ el.remove();
58350
+ changed = true;
58351
+ continue;
58352
+ }
58353
+
58354
+ /* 5. Collapse span-in-span single-child chains into one span.
58355
+ The inner (more specific) properties win on conflict. */
58356
+ if (el.tagName === 'SPAN' && el.childNodes.length === 1 && isEl$1(el.firstChild) && el.firstChild.tagName === 'SPAN') {
58357
+ const inner = el.firstChild;
58358
+ for (let i = 0; i < inner.style.length; i++) {
58359
+ const prop = inner.style[i];
58360
+ el.style.setProperty(prop, inner.style.getPropertyValue(prop));
58361
+ }
58362
+ inner.classList.forEach(c => el.classList.add(c));
58363
+ if (!el.getAttribute('style')) el.removeAttribute('style');
58364
+ unwrap(inner);
58365
+ changed = true;
58366
+ }
58367
+ }
58368
+ return changed;
58369
+ }
58370
+ function normalize(scope) {
58371
+ let guard = 0;
58372
+ while (pass(scope) && guard++ < 6) {/* repeat until stable */}
58373
+ scope.normalize(); /* merge adjacent text nodes */
58374
+ return scope;
58375
+ }
58376
+
58150
58377
  /*
58151
58378
  formatter.js — inline formatting engine.
58152
58379
 
@@ -58413,7 +58640,9 @@ function strip(el, def) {
58413
58640
  unwrap(el);
58414
58641
  return;
58415
58642
  }
58416
- if (el.tagName === 'SPAN' && el.attributes.length === 0) unwrap(el);
58643
+ /* Left bare: unwrap, unless the page's CSS styles it (a design's
58644
+ ".hero h1 span" colour), which must survive clearing a size from it. */
58645
+ if (el.tagName === 'SPAN' && el.attributes.length === 0 && !styledByCss(el)) unwrap(el);
58417
58646
  }
58418
58647
 
58419
58648
  /* Apply a style across a (non-collapsed) range. */
@@ -58478,115 +58707,6 @@ function removeStyle(root, range, def, opts) {
58478
58707
  return texts;
58479
58708
  }
58480
58709
 
58481
- /*
58482
- normalizer.js — the clean-output guarantee.
58483
-
58484
- Runs after every mutation. It never changes the visible text; it only
58485
- reduces markup to the minimal equivalent form:
58486
-
58487
- <strong>a</strong><strong>b</strong> -> <strong>ab</strong>
58488
- <strong><strong>x</strong></strong> -> <strong>x</strong>
58489
- <span style="color:red"><span style="font-size:18px">x</span></span>
58490
- -> one span, two properties
58491
- <span>x</span> -> x
58492
- <em></em> -> (removed)
58493
-
58494
- Because it preserves text content exactly, character-offset selection
58495
- bookmarks taken before normalization remain valid after it.
58496
- */
58497
- const INLINE_TAGS = new Set(['SPAN', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'DEL', 'INS', 'SUB', 'SUP', 'CODE', 'MARK', 'A', 'SMALL', 'BIG', 'FONT', 'ABBR', 'Q', 'CITE', 'DFN', 'KBD', 'SAMP', 'VAR', 'TIME']);
58498
-
58499
- /* Identity signature: two inline elements with equal signatures are
58500
- interchangeable and can be merged. */
58501
- function sig(el) {
58502
- const attrs = [];
58503
- for (const a of el.attributes) {
58504
- attrs.push(a.name === 'style' ? 'style=' + el.style.cssText : a.name + '=' + a.value);
58505
- }
58506
- return el.tagName + '|' + attrs.sort().join(';');
58507
- }
58508
- function isEmptyInline(el) {
58509
- if (el.textContent !== '' || el.querySelector(KEEPERS)) return false;
58510
- // An empty inline that carries a class typically renders its content via CSS
58511
- // rather than text — an icon font (<i class="bi bi-alarm">, <span
58512
- // class="material-icons">…</span>, fa-*, glyphicon). It is NOT truly empty,
58513
- // so never drop it. Bare leftover marks (<em></em>, <i></i>) have no class
58514
- // and are still removed.
58515
- if (el.getAttribute('class')) return false;
58516
- return true;
58517
- }
58518
-
58519
- /* One bottom-up cleanup pass. Returns true if anything changed. */
58520
- function pass(scope) {
58521
- let changed = false;
58522
- const els = Array.prototype.slice.call(scope.querySelectorAll('*')).reverse();
58523
- for (const el of els) {
58524
- if (!el.isConnected && !scope.contains(el)) continue;
58525
- if (el.closest && el.closest('[contenteditable="false"]')) continue;
58526
- if (!INLINE_TAGS.has(el.tagName)) continue;
58527
-
58528
- /* 1. Drop empty inline elements */
58529
- if (isEmptyInline(el)) {
58530
- el.remove();
58531
- changed = true;
58532
- continue;
58533
- }
58534
-
58535
- /* 2. Unwrap attribute-less spans */
58536
- if (el.tagName === 'SPAN' && el.attributes.length === 0) {
58537
- unwrap(el);
58538
- changed = true;
58539
- continue;
58540
- }
58541
-
58542
- /* 3. Unwrap when an identical ancestor already covers this element */
58543
- let anc = el.parentNode;
58544
- let redundant = false;
58545
- while (isEl$1(anc) && anc !== scope && INLINE_TAGS.has(anc.tagName)) {
58546
- if (sig(anc) === sig(el)) {
58547
- redundant = true;
58548
- break;
58549
- }
58550
- anc = anc.parentNode;
58551
- }
58552
- if (redundant) {
58553
- unwrap(el);
58554
- changed = true;
58555
- continue;
58556
- }
58557
-
58558
- /* 4. Merge with an identical previous sibling */
58559
- const prev = el.previousSibling;
58560
- if (isEl$1(prev) && sig(prev) === sig(el)) {
58561
- while (el.firstChild) prev.appendChild(el.firstChild);
58562
- el.remove();
58563
- changed = true;
58564
- continue;
58565
- }
58566
-
58567
- /* 5. Collapse span-in-span single-child chains into one span.
58568
- The inner (more specific) properties win on conflict. */
58569
- if (el.tagName === 'SPAN' && el.childNodes.length === 1 && isEl$1(el.firstChild) && el.firstChild.tagName === 'SPAN') {
58570
- const inner = el.firstChild;
58571
- for (let i = 0; i < inner.style.length; i++) {
58572
- const prop = inner.style[i];
58573
- el.style.setProperty(prop, inner.style.getPropertyValue(prop));
58574
- }
58575
- inner.classList.forEach(c => el.classList.add(c));
58576
- if (!el.getAttribute('style')) el.removeAttribute('style');
58577
- unwrap(inner);
58578
- changed = true;
58579
- }
58580
- }
58581
- return changed;
58582
- }
58583
- function normalize(scope) {
58584
- let guard = 0;
58585
- while (pass(scope) && guard++ < 6) {/* repeat until stable */}
58586
- scope.normalize(); /* merge adjacent text nodes */
58587
- return scope;
58588
- }
58589
-
58590
58710
  /*
58591
58711
  probe.js — non-mutating state inspection (queryCommandState done right).
58592
58712
 
@@ -99057,11 +99177,14 @@ class ContentBuilder {
99057
99177
  this.shortenOutput = false;
99058
99178
  output = pageTemplate.replace('[%CONTENT%]', html);
99059
99179
 
99060
- // [%PLUGINS%] - runtime plugin registry (must run before [%PATH%],
99061
- // the registry's URLs are emitted as [%PATH%] tokens)
99180
+ // [%PLUGINS%], [%PLUGINFOLDER%] - runtime plugin registry and folder (must run
99181
+ // before [%PATH%], their URLs are emitted as [%PATH%] tokens)
99062
99182
  if (output.indexOf('[%PLUGINS%]') !== -1) {
99063
99183
  output = output.replaceAll('[%PLUGINS%]', this.util.getPluginRegistry());
99064
99184
  }
99185
+ if (output.indexOf('[%PLUGINFOLDER%]') !== -1) {
99186
+ output = output.replaceAll('[%PLUGINFOLDER%]', this.util.getPluginFolder());
99187
+ }
99065
99188
 
99066
99189
  // Custom
99067
99190
  if (options.replace) {
@@ -101801,6 +101924,9 @@ Please obtain a license at: https://innovastudio.com/contentbox`);
101801
101924
  });
101802
101925
  tmp.querySelectorAll('*').forEach(el => {
101803
101926
  el.removeAttribute('style');
101927
+ // Text direction is the destination's too: Word marks every
101928
+ // paragraph and run dir="RTL"/"LTR" in right-to-left documents.
101929
+ el.removeAttribute('dir');
101804
101930
  if (el.classList && el.classList.length) {
101805
101931
  const kept = Array.prototype.filter.call(el.classList, c => keep.has(c));
101806
101932
  if (kept.length) el.setAttribute('class', kept.join(' '));else el.removeAttribute('class');