@seliseblocks/mailcraft 0.2.10 → 0.2.12

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/mailcraft",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
5
5
  "license": "MIT",
6
6
  "author": "SELISE Digital Platforms",
@@ -61,20 +61,24 @@ const RICH_OWNED_STYLE = { color: 'color', lh: 'line-height', weight: 'font-weig
61
61
  */
62
62
  function syncRichContent(block, key, val) {
63
63
  const prop = RICH_HTML_PROP[block.type];
64
- if (!prop) return;
64
+ if (!prop) return false;
65
65
  // list items are one fragment per line; the rewrite must not run across the joins.
66
66
  const perLine = (src, fn) => (prop === 'items' ? String(src).split('\n').map(fn).join('\n') : fn(String(src)));
67
67
  const src = block.props[prop];
68
- if (src == null || src === '') return;
68
+ if (src == null || src === '') return false;
69
+ let out = src;
69
70
  if (key === 'size') {
70
71
  const cur = Number(block.props.size);
71
72
  const next = Number(val);
72
73
  // An unknown base (imports that carried no readable size) can't be scaled
73
74
  // against -- the first explicit size just establishes the base.
74
- if (cur > 0 && next > 0 && next !== cur) block.props[prop] = perLine(src, (s) => scaleInlineSizes(s, next / cur));
75
+ if (cur > 0 && next > 0 && next !== cur) out = perLine(src, (s) => scaleInlineSizes(s, next / cur));
75
76
  } else if (RICH_OWNED_STYLE[key]) {
76
- block.props[prop] = perLine(src, (s) => stripInlineStyle(s, RICH_OWNED_STYLE[key]));
77
+ out = perLine(src, (s) => stripInlineStyle(s, RICH_OWNED_STYLE[key]));
77
78
  }
79
+ if (out === src) return false;
80
+ block.props[prop] = out;
81
+ return true;
78
82
  }
79
83
 
80
84
  const BORDER_STYLES = [
@@ -419,11 +423,48 @@ export class EditorCore {
419
423
  if (this.onFormatChange) this.onFormatChange();
420
424
  };
421
425
  document.addEventListener('selectionchange', this.onSelect);
426
+ /**
427
+ * Click-outside fallback for closing the RTE toolbar. The blur path
428
+ * (`blockCtx.onBlur`, canvas.js) only runs if the edited block still holds
429
+ * focus at the moment of the outside press -- but several toolbar controls
430
+ * legitimately move focus to themselves (the Text style / Merge Tags
431
+ * selects, the color inputs, the link popover's href field). Dismiss one
432
+ * of those without committing and no block blur can ever fire again, so
433
+ * `state.editing` -- and the toolbar -- stayed open no matter where the
434
+ * user clicked. A completed click whose composed path contains neither the
435
+ * edited block nor the toolbar closes the edit explicitly.
436
+ *
437
+ * `click`, deliberately not `pointerdown`: by click time the press's
438
+ * native blur/focus transition has fully settled, so this never rebuilds
439
+ * the canvas mid-gesture (the dropped-click problem documented in
440
+ * `blockCtx.onFocus`, canvas.js) and never races focus-preserve into
441
+ * refocusing -- and thereby reopening -- the block it just closed. It also
442
+ * ignores scrollbar drags, which emit no click.
443
+ */
444
+ this.onOutsideClick = (e) => {
445
+ if (!this.state.editing || this.rendering) return;
446
+ // A drag-selection that starts inside the block but ends outside it
447
+ // fires its click on a common ancestor -- but the block keeps focus
448
+ // through such a drag, while a genuine outside press blurs it first
449
+ // (and the blur pipeline has then already handled the close).
450
+ const active = this.exportRoot && this.exportRoot.activeElement;
451
+ if (active && active === this.editEl) return;
452
+ const path = e.composedPath ? e.composedPath() : [];
453
+ for (const n of path) {
454
+ if (!n || n.nodeType !== 1) continue;
455
+ if (n.getAttribute && n.getAttribute('data-mc-content') === this.state.editing) return;
456
+ if (n.hasAttribute && n.hasAttribute('data-rte-root')) return;
457
+ }
458
+ this.closeEditing();
459
+ };
460
+ if (this.exportRoot) this.exportRoot.addEventListener('click', this.onOutsideClick);
422
461
  }
423
462
 
424
463
  unmountKeyboard() {
425
464
  window.removeEventListener('keydown', this.onKey);
426
465
  document.removeEventListener('selectionchange', this.onSelect);
466
+ if (this.exportRoot && this.onOutsideClick) this.exportRoot.removeEventListener('click', this.onOutsideClick);
467
+ this.onOutsideClick = null;
427
468
  }
428
469
 
429
470
  unmount() {
@@ -446,6 +487,30 @@ export class EditorCore {
446
487
 
447
488
  // ---- rich text editing ----------------------------------------------
448
489
 
490
+ /**
491
+ * Explicitly ends the active rich-text edit: commits the live content the
492
+ * way `blockCtx.onBlur` (canvas.js) would, then clears `editing`/`linkDraft`.
493
+ * Used by the click-outside fallback (`mountKeyboard`), which fires exactly
494
+ * when the block no longer holds focus, so no blur will ever arrive to do
495
+ * this. Any blur this close itself provokes is deliberately swallowed via
496
+ * `rteActive`: the commit below is the single commit path -- letting onBlur
497
+ * also run would compare against the same `editOriginal` and push a second
498
+ * undo entry for the same change.
499
+ */
500
+ closeEditing() {
501
+ const id = this.state.editing;
502
+ if (!id) return;
503
+ const elNode = this.editEl;
504
+ const val = elNode && elNode.isConnected && this.editKey
505
+ ? (this.editPlain ? elNode.textContent : elNode.innerHTML)
506
+ : null;
507
+ this.rteActive = true;
508
+ if (elNode && this.exportRoot && this.exportRoot.activeElement === elNode) elNode.blur();
509
+ this.rteActive = false;
510
+ if (val !== null && val !== this.editOriginal) this.setProp(id, this.editKey, val);
511
+ if (this.state.editing === id) this.setState({ editing: null, linkDraft: null });
512
+ }
513
+
449
514
  exec(cmd, arg) {
450
515
  this.rteActive = true;
451
516
  try {
@@ -557,15 +622,19 @@ export class EditorCore {
557
622
  };
558
623
 
559
624
  size(b, delta) {
560
- // fold any uncommitted inline formatting into state first, or the re-render
561
- // from the size change would rewrite innerHTML from stale props
562
- const elNode = this.editEl;
563
- if (elNode) {
564
- const key = b.type === 'heading' ? 'text' : (b.type === 'html' ? 'code' : 'html');
565
- const val = b.type === 'heading' ? elNode.textContent : elNode.innerHTML;
566
- if (b.props[key] !== val) this.setProp(b.id, key, val);
567
- }
568
- const cur = Number(b.props.size) || 16;
625
+ // Uncommitted inline formatting is folded into props by `setProp` below
626
+ // (`onFoldLiveEdit`), not here. This used to do its own fold, as a second
627
+ // commit: that read `editEl.innerHTML` unconditionally, so a second click
628
+ // landing in the same frame as the first -- before the rebuild had put the
629
+ // rescaled html into the DOM -- wrote the pre-scale markup straight back
630
+ // over it, and two quick clicks on a mixed-size block moved nothing. It
631
+ // also cost an extra undo step per click.
632
+ // Read the size off the live document, not off the `b` the toolbar closed
633
+ // over when it was built: two clicks landing before the next rebuild both
634
+ // saw the same stale base, so the second one re-applied the first one's
635
+ // value and the pair counted as a single step.
636
+ const live = this.find(this.state.doc, b.id).block || b;
637
+ const cur = Number(live.props.size) || 16;
569
638
  const [lo, hi] = SIZE_SPAN[b.type] || [10, 64];
570
639
  this.setProp(b.id, 'size', Math.max(lo, Math.min(hi, cur + delta)));
571
640
  }
@@ -620,15 +689,28 @@ export class EditorCore {
620
689
  this._persistTimer = setTimeout(() => this.persist(doc), 400);
621
690
  }
622
691
 
692
+ /**
693
+ * Both directions of history have the same two obligations when a block is
694
+ * being edited, because the live contenteditable is a second copy of that
695
+ * block's content: fold it into props *before* the current state is pushed
696
+ * onto the opposite stack (or the step back carries markup a few keystrokes
697
+ * behind what was on screen), and mark it stale afterwards (or the render
698
+ * that follows syncs the pre-undo DOM straight back over the restored doc --
699
+ * which is what made undo look like it skipped the focused block).
700
+ */
623
701
  undo() {
624
702
  const hist = this.state.history.slice(); const prev = hist.pop(); if (!prev) return;
703
+ if (this.state.editing && this.onFoldLiveEdit) this.onFoldLiveEdit();
625
704
  const doc = JSON.parse(prev);
705
+ if (this.state.editing) this.editStale = this.state.editing;
626
706
  this.setState({ doc, history: hist, future: this.state.future.concat(JSON.stringify(this.state.doc)), sel: null }, () => this.persist(doc));
627
707
  }
628
708
 
629
709
  redo() {
630
710
  const fut = this.state.future.slice(); const next = fut.pop(); if (!next) return;
711
+ if (this.state.editing && this.onFoldLiveEdit) this.onFoldLiveEdit();
631
712
  const doc = JSON.parse(next);
713
+ if (this.state.editing) this.editStale = this.state.editing;
632
714
  this.setState({ doc, future: fut, history: this.state.history.concat(JSON.stringify(this.state.doc)), sel: null }, () => this.persist(doc));
633
715
  }
634
716
 
@@ -660,14 +742,28 @@ export class EditorCore {
660
742
  selObj() { return this.state.sel ? this.find(this.state.doc, this.state.sel.id) : {}; }
661
743
 
662
744
  setProp(id, key, val) {
745
+ // The rewrite below works from the block's *committed* html, so anything
746
+ // still living only in the focused contenteditable is folded into props
747
+ // first -- otherwise it would both rewrite stale content and lose the
748
+ // uncommitted edit the moment the rebuilt node reads props back.
749
+ if (this.state.editing === id && this.onFoldLiveEdit) this.onFoldLiveEdit();
750
+ let rewrote = false;
663
751
  this.commit((doc) => {
664
752
  const f = this.find(doc, id);
665
753
  const target = f.block ? f.block.props : (f.row ? f.row.props : null);
666
754
  if (!target) return;
667
755
  // Before the write: the size rewrite needs the outgoing value as its base.
668
- if (f.block) syncRichContent(f.block, key, val);
756
+ if (f.block) rewrote = syncRichContent(f.block, key, val);
669
757
  target[key] = val;
670
758
  });
759
+ // props are now *ahead* of the live contenteditable, which still holds the
760
+ // pre-rewrite html. Flagged so the render that follows syncs nothing back
761
+ // over them (`syncLiveEdit`, mailcraft-editor.js): without this, every
762
+ // Text size / color / spacing change made while the block was focused --
763
+ // i.e. every change made from the RTE's own +/- pair -- was silently
764
+ // reverted one frame later, so a mixed-size block ended up with a climbing
765
+ // `size` prop and untouched inline sizes.
766
+ if (rewrote) this.editStale = id;
671
767
  }
672
768
 
673
769
  setTheme(key, val) { this.commit((doc) => { doc.theme[key] = val; }); }
@@ -1,83 +1,83 @@
1
- import { EN as ENBase } from './en.js';
2
-
3
- /**
4
- * Builds a translator. `overrides` is whatever a host passes as `.messages`
5
- * on the element -- a host's own table, an imported locale, or both merged
6
- * via `defineMessages` below.
7
- *
8
- * Three deliberate properties:
9
- * 1. English always resolves. A locale is an overlay, never a replacement,
10
- * so a partial or missing translation shows English rather than a gap.
11
- * 2. Params interpolate `{name}`.
12
- * 3. A truly missing key (not in `overrides`, not in `EN`) renders as the
13
- * key itself, not an empty string -- a visible `toast.deleted` in the UI
14
- * is obviously wrong and names the exact key to add, where blank text
15
- * just looks like a broken build.
16
- */
17
- export function createTranslator(overrides) {
18
- const table = overrides || {};
19
- return function t(key, params) {
20
- const template = table[key] ?? ENBase[key] ?? key;
21
- if (!params) return template;
22
- return template.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole));
23
- };
24
- }
25
-
26
- /** Merges a locale over a base, for a host assembling its own table -- the documented way to combine a shipped locale with a few product-specific overrides. */
27
- export function defineMessages(base, overrides) {
28
- return Object.assign({}, base, overrides);
29
- }
30
-
31
- /** Keys in `base` that `locale` does not translate. What a translator has left to do. */
32
- export function missingKeys(locale, base) {
33
- const source = base || ENBase;
34
- return Object.keys(source).filter((key) => locale[key] === undefined).sort();
35
- }
36
-
37
- /**
38
- * Every locale that ships, for a host building a language switcher.
39
- * Metadata only -- no message tables -- so listing the locales never pulls
40
- * every translation file into a consumer's bundle; a host deep-imports the
41
- * one it wants, e.g. `mailcraft-editor/src/core/i18n/bn.js`.
42
- */
43
- export const LOCALES = [
44
- { tag: 'en', name: 'English' },
45
- { tag: 'ar', name: 'Arabic', rtl: true },
46
- { tag: 'bn', name: 'Bangla' },
47
- { tag: 'dz', name: 'Dzongkha' },
48
- { tag: 'bg', name: 'Bulgarian' },
49
- { tag: 'ca', name: 'Catalan' },
50
- { tag: 'cs', name: 'Czech' },
51
- { tag: 'da', name: 'Danish' },
52
- { tag: 'de', name: 'German' },
53
- { tag: 'de-CH', name: 'Swiss German' },
54
- { tag: 'el', name: 'Greek' },
55
- { tag: 'es', name: 'Spanish' },
56
- { tag: 'et', name: 'Estonian' },
57
- { tag: 'fi', name: 'Finnish' },
58
- { tag: 'fr', name: 'French' },
59
- { tag: 'hr', name: 'Croatian' },
60
- { tag: 'hu', name: 'Hungarian' },
61
- { tag: 'it', name: 'Italian' },
62
- { tag: 'lt', name: 'Lithuanian' },
63
- { tag: 'lv', name: 'Latvian' },
64
- { tag: 'nb', name: 'Norwegian Bokmål' },
65
- { tag: 'nl', name: 'Dutch' },
66
- { tag: 'pl', name: 'Polish' },
67
- { tag: 'pt', name: 'Portuguese' },
68
- { tag: 'ro', name: 'Romanian' },
69
- { tag: 'ru', name: 'Russian' },
70
- { tag: 'sk', name: 'Slovak' },
71
- { tag: 'sl', name: 'Slovenian' },
72
- { tag: 'sv', name: 'Swedish' },
73
- { tag: 'tr', name: 'Turkish' },
74
- { tag: 'uk', name: 'Ukrainian' },
75
- ];
76
-
77
- /** `true` when the tag is written right to left. Metadata only -- `dir` is still what actually flips the layout. */
78
- export function isRtl(tag) {
79
- const entry = LOCALES.find((l) => l.tag === tag);
80
- return entry ? entry.rtl === true : false;
81
- }
82
-
83
- export { EN, MESSAGE_KEYS } from './en.js';
1
+ import { EN as ENBase } from './en.js';
2
+
3
+ /**
4
+ * Builds a translator. `overrides` is whatever a host passes as `.messages`
5
+ * on the element -- a host's own table, an imported locale, or both merged
6
+ * via `defineMessages` below.
7
+ *
8
+ * Three deliberate properties:
9
+ * 1. English always resolves. A locale is an overlay, never a replacement,
10
+ * so a partial or missing translation shows English rather than a gap.
11
+ * 2. Params interpolate `{name}`.
12
+ * 3. A truly missing key (not in `overrides`, not in `EN`) renders as the
13
+ * key itself, not an empty string -- a visible `toast.deleted` in the UI
14
+ * is obviously wrong and names the exact key to add, where blank text
15
+ * just looks like a broken build.
16
+ */
17
+ export function createTranslator(overrides) {
18
+ const table = overrides || {};
19
+ return function t(key, params) {
20
+ const template = table[key] ?? ENBase[key] ?? key;
21
+ if (!params) return template;
22
+ return template.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole));
23
+ };
24
+ }
25
+
26
+ /** Merges a locale over a base, for a host assembling its own table -- the documented way to combine a shipped locale with a few product-specific overrides. */
27
+ export function defineMessages(base, overrides) {
28
+ return Object.assign({}, base, overrides);
29
+ }
30
+
31
+ /** Keys in `base` that `locale` does not translate. What a translator has left to do. */
32
+ export function missingKeys(locale, base) {
33
+ const source = base || ENBase;
34
+ return Object.keys(source).filter((key) => locale[key] === undefined).sort();
35
+ }
36
+
37
+ /**
38
+ * Every locale that ships, for a host building a language switcher.
39
+ * Metadata only -- no message tables -- so listing the locales never pulls
40
+ * every translation file into a consumer's bundle; a host deep-imports the
41
+ * one it wants, e.g. `mailcraft-editor/src/core/i18n/bn.js`.
42
+ */
43
+ export const LOCALES = [
44
+ { tag: 'en', name: 'English' },
45
+ { tag: 'ar', name: 'Arabic', rtl: true },
46
+ { tag: 'bn', name: 'Bangla' },
47
+ { tag: 'dz', name: 'Dzongkha' },
48
+ { tag: 'bg', name: 'Bulgarian' },
49
+ { tag: 'ca', name: 'Catalan' },
50
+ { tag: 'cs', name: 'Czech' },
51
+ { tag: 'da', name: 'Danish' },
52
+ { tag: 'de', name: 'German' },
53
+ { tag: 'de-CH', name: 'Swiss German' },
54
+ { tag: 'el', name: 'Greek' },
55
+ { tag: 'es', name: 'Spanish' },
56
+ { tag: 'et', name: 'Estonian' },
57
+ { tag: 'fi', name: 'Finnish' },
58
+ { tag: 'fr', name: 'French' },
59
+ { tag: 'hr', name: 'Croatian' },
60
+ { tag: 'hu', name: 'Hungarian' },
61
+ { tag: 'it', name: 'Italian' },
62
+ { tag: 'lt', name: 'Lithuanian' },
63
+ { tag: 'lv', name: 'Latvian' },
64
+ { tag: 'nb', name: 'Norwegian Bokmål' },
65
+ { tag: 'nl', name: 'Dutch' },
66
+ { tag: 'pl', name: 'Polish' },
67
+ { tag: 'pt', name: 'Portuguese' },
68
+ { tag: 'ro', name: 'Romanian' },
69
+ { tag: 'ru', name: 'Russian' },
70
+ { tag: 'sk', name: 'Slovak' },
71
+ { tag: 'sl', name: 'Slovenian' },
72
+ { tag: 'sv', name: 'Swedish' },
73
+ { tag: 'tr', name: 'Turkish' },
74
+ { tag: 'uk', name: 'Ukrainian' },
75
+ ];
76
+
77
+ /** `true` when the tag is written right to left. Metadata only -- `dir` is still what actually flips the layout. */
78
+ export function isRtl(tag) {
79
+ const entry = LOCALES.find((l) => l.tag === tag);
80
+ return entry ? entry.rtl === true : false;
81
+ }
82
+
83
+ export { EN, MESSAGE_KEYS } from './en.js';
package/src/core/ids.js CHANGED
@@ -1 +1 @@
1
- export const uid = () => Math.random().toString(36).slice(2, 9);
1
+ export const uid = () => Math.random().toString(36).slice(2, 9);
@@ -753,7 +753,7 @@ function isStructural(el) {
753
753
  return /^(TABLE|FORM|IFRAME|SCRIPT|STYLE|VIDEO|OBJECT|EMBED)$/.test(el.tagName) || !!el.querySelector('table,form,iframe,script,video,object,embed');
754
754
  }
755
755
 
756
- /** `core/export.js` wraps every block in a column with `<div style="{boxCss(b.props)}">`, which for every block type shipped today (none define the `bBg/bBorder/bLine/bRadius/bPad` props `boxCss` reads) always resolves to a bare `<div style="margin:0">` -- a see-through spacing wrapper, not real content. Unwraps that one level so the classifiers see the block's own signature div directly; leaves any div that carries other styling (an intentionally-styled container someone pasted in) alone, since that's real content, not framework wrapper. */
756
+ /** `core/export.js` wraps every block in a column with `<div style="{boxCss(b.props)}">`, which resolves to a bare `<div style="margin:0">` for any block whose "Box & border" panel is untouched -- a see-through spacing wrapper, not real content. Unwraps that one level so the classifiers see the block's own signature div directly; leaves any div that carries other styling alone, since that's either a set box (read back by `boxPropsOf`) or an intentionally-styled container someone pasted in -- real content, not framework wrapper. */
757
757
  function unwrapBoxDiv(el) {
758
758
  if (el.tagName !== 'DIV' || el.children.length !== 1) return el;
759
759
  // A device-visibility wrapper is framework too, and the declarations that
@@ -800,6 +800,48 @@ function logicMarkersOf(text) {
800
800
  return out;
801
801
  }
802
802
 
803
+ /**
804
+ * The container styling of a `<div>` that paints a panel around its content.
805
+ *
806
+ * `cleanImportHtml`'s tag whitelist has no DIV (it never could have one --
807
+ * arbitrary paste brings div soup), so a `<div
808
+ * style="background-color:#eff6fc;border-radius:8px">` wrapping a run of
809
+ * text -- the verification-code panel every transactional template has --
810
+ * was flattened away on import and the box came back unpainted on the very
811
+ * first save. Read here instead, into the exact props `boxCss`/`boxStyle`
812
+ * already write for a block's own box, so the shape round-trips and the
813
+ * color lands under the inspector's "Box & border" controls where it can be
814
+ * edited.
815
+ *
816
+ * A color or a border is what makes a div a panel; a radius alone paints
817
+ * nothing, so it is picked up alongside but never claimed on its own.
818
+ * Padding is deliberately not read: it already reaches the block as its
819
+ * py/px run padding, which keeps the source's asymmetry (`18px 24px`) that
820
+ * the single-value `bPad` cannot express.
821
+ */
822
+ function boxPropsOf(el) {
823
+ if (!el || el.tagName !== 'DIV' || !el.style) return null;
824
+ const out = {};
825
+ const bg = bgOf(el);
826
+ // `background:transparent` is what the exporter writes on every styled
827
+ // column wrapper, and CSSOM hands the keyword back as `rgba(0, 0, 0, 0)`;
828
+ // claiming either as a box color paints nothing and only makes the run
829
+ // look like a panel.
830
+ if (bg && bg !== 'transparent' && !/^rgba\([^)]*,\s*0\s*\)$/.test(bg)) out.bBg = bg;
831
+ const frame = borderSidesOf(el.style);
832
+ if (frame.width) {
833
+ out.bBorder = frame.width;
834
+ out.bStyle = borderStyleOf(el.style);
835
+ out.bLine = borderColorOf(el.style) || '#e2e2e5';
836
+ out.bTop = frame.sides.top > 0; out.bRight = frame.sides.right > 0;
837
+ out.bBottom = frame.sides.bottom > 0; out.bLeft = frame.sides.left > 0;
838
+ }
839
+ if (!out.bBg && !out.bBorder) return null;
840
+ const radius = radiusOf(el.style);
841
+ if (radius) out.bRadius = radius;
842
+ return out;
843
+ }
844
+
803
845
  /** Whether an element (or the transparent single-child chain under it) carries its own padding -- the shape the exporter writes for a block's py/px, and a builder section's own spacing. Such a wrapper is one block, never part of a text run. */
804
846
  function hasOwnRunPad(el) {
805
847
  let e = el;
@@ -840,6 +882,10 @@ function blocksFromNodes(nodes) {
840
882
  // inheritedStyle reads below were skipped wholesale, so the run imported
841
883
  // at the theme default (a 30px/800 verification code became 16px plain).
842
884
  let bufTextEl = null;
885
+ // The container styling of the run's own wrapper (see `boxPropsOf`), kept
886
+ // out of the html because the sanitizer's whitelist has no DIV to hang it
887
+ // on.
888
+ let bufBox = null;
843
889
  const flush = () => {
844
890
  const raw = buf.join('');
845
891
  const html = cleanImportHtml(raw);
@@ -919,9 +965,12 @@ function blocksFromNodes(nodes) {
919
965
  // went through it, so a mobile-only paragraph reloaded visible
920
966
  // everywhere.
921
967
  if (bufVis) over.vis = bufVis;
968
+ // The wrapper's own box, last: these are block-level props, so nothing
969
+ // read off the content above can collide with them.
970
+ if (bufBox) Object.assign(over, bufBox);
922
971
  out.push(blk('text', over));
923
972
  }
924
- buf = []; bufFirstEl = null; bufTextEl = null; bufEls = []; bufVis = undefined;
973
+ buf = []; bufFirstEl = null; bufTextEl = null; bufEls = []; bufVis = undefined; bufBox = null;
925
974
  };
926
975
  nodes.forEach((n) => {
927
976
  if (n.nodeType === 3) {
@@ -978,8 +1027,17 @@ function blocksFromNodes(nodes) {
978
1027
  // as padding does: the exporter writes exactly one such wrapper per
979
1028
  // block, and without this two zero-padded text blocks buffered into one
980
1029
  // -- the second lost its size, weight, everything -- on every save.
981
- const boundary = n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && (hasOwnRunPad(target) || target !== n);
1030
+ // A painted panel is one block, whatever it holds: its styling describes
1031
+ // the whole run, so it must neither merge with the prose around it (a
1032
+ // bg-only div carries no padding to make it a boundary on its own) nor
1033
+ // split, since only the first block of a split would keep the panel.
1034
+ const box = boxPropsOf(target);
1035
+ const boundary = n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && (hasOwnRunPad(target) || target !== n || !!box);
982
1036
  if (boundary && buf.length) flush();
1037
+ // Claimed only when the panel opens the run, which after that flush is
1038
+ // always -- a box nested inside a longer run is content, and its color
1039
+ // must not be promoted to the block around it.
1040
+ if (box && !bufFirstEl) bufBox = box;
983
1041
  if (!bufFirstEl) bufFirstEl = target;
984
1042
  bufEls.push(target);
985
1043
  // Read off `n`, not `target`: the visibility class rides the wrapper