@seliseblocks/mailcraft 0.2.10 → 0.2.11

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.11",
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 = [
@@ -557,15 +561,19 @@ export class EditorCore {
557
561
  };
558
562
 
559
563
  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;
564
+ // Uncommitted inline formatting is folded into props by `setProp` below
565
+ // (`onFoldLiveEdit`), not here. This used to do its own fold, as a second
566
+ // commit: that read `editEl.innerHTML` unconditionally, so a second click
567
+ // landing in the same frame as the first -- before the rebuild had put the
568
+ // rescaled html into the DOM -- wrote the pre-scale markup straight back
569
+ // over it, and two quick clicks on a mixed-size block moved nothing. It
570
+ // also cost an extra undo step per click.
571
+ // Read the size off the live document, not off the `b` the toolbar closed
572
+ // over when it was built: two clicks landing before the next rebuild both
573
+ // saw the same stale base, so the second one re-applied the first one's
574
+ // value and the pair counted as a single step.
575
+ const live = this.find(this.state.doc, b.id).block || b;
576
+ const cur = Number(live.props.size) || 16;
569
577
  const [lo, hi] = SIZE_SPAN[b.type] || [10, 64];
570
578
  this.setProp(b.id, 'size', Math.max(lo, Math.min(hi, cur + delta)));
571
579
  }
@@ -620,15 +628,28 @@ export class EditorCore {
620
628
  this._persistTimer = setTimeout(() => this.persist(doc), 400);
621
629
  }
622
630
 
631
+ /**
632
+ * Both directions of history have the same two obligations when a block is
633
+ * being edited, because the live contenteditable is a second copy of that
634
+ * block's content: fold it into props *before* the current state is pushed
635
+ * onto the opposite stack (or the step back carries markup a few keystrokes
636
+ * behind what was on screen), and mark it stale afterwards (or the render
637
+ * that follows syncs the pre-undo DOM straight back over the restored doc --
638
+ * which is what made undo look like it skipped the focused block).
639
+ */
623
640
  undo() {
624
641
  const hist = this.state.history.slice(); const prev = hist.pop(); if (!prev) return;
642
+ if (this.state.editing && this.onFoldLiveEdit) this.onFoldLiveEdit();
625
643
  const doc = JSON.parse(prev);
644
+ if (this.state.editing) this.editStale = this.state.editing;
626
645
  this.setState({ doc, history: hist, future: this.state.future.concat(JSON.stringify(this.state.doc)), sel: null }, () => this.persist(doc));
627
646
  }
628
647
 
629
648
  redo() {
630
649
  const fut = this.state.future.slice(); const next = fut.pop(); if (!next) return;
650
+ if (this.state.editing && this.onFoldLiveEdit) this.onFoldLiveEdit();
631
651
  const doc = JSON.parse(next);
652
+ if (this.state.editing) this.editStale = this.state.editing;
632
653
  this.setState({ doc, future: fut, history: this.state.history.concat(JSON.stringify(this.state.doc)), sel: null }, () => this.persist(doc));
633
654
  }
634
655
 
@@ -660,14 +681,28 @@ export class EditorCore {
660
681
  selObj() { return this.state.sel ? this.find(this.state.doc, this.state.sel.id) : {}; }
661
682
 
662
683
  setProp(id, key, val) {
684
+ // The rewrite below works from the block's *committed* html, so anything
685
+ // still living only in the focused contenteditable is folded into props
686
+ // first -- otherwise it would both rewrite stale content and lose the
687
+ // uncommitted edit the moment the rebuilt node reads props back.
688
+ if (this.state.editing === id && this.onFoldLiveEdit) this.onFoldLiveEdit();
689
+ let rewrote = false;
663
690
  this.commit((doc) => {
664
691
  const f = this.find(doc, id);
665
692
  const target = f.block ? f.block.props : (f.row ? f.row.props : null);
666
693
  if (!target) return;
667
694
  // Before the write: the size rewrite needs the outgoing value as its base.
668
- if (f.block) syncRichContent(f.block, key, val);
695
+ if (f.block) rewrote = syncRichContent(f.block, key, val);
669
696
  target[key] = val;
670
697
  });
698
+ // props are now *ahead* of the live contenteditable, which still holds the
699
+ // pre-rewrite html. Flagged so the render that follows syncs nothing back
700
+ // over them (`syncLiveEdit`, mailcraft-editor.js): without this, every
701
+ // Text size / color / spacing change made while the block was focused --
702
+ // i.e. every change made from the RTE's own +/- pair -- was silently
703
+ // reverted one frame later, so a mixed-size block ended up with a climbing
704
+ // `size` prop and untouched inline sizes.
705
+ if (rewrote) this.editStale = id;
671
706
  }
672
707
 
673
708
  setTheme(key, val) { this.commit((doc) => { doc.theme[key] = val; }); }
@@ -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
@@ -137,6 +137,7 @@ export class MailCraftEditor extends ElementBase {
137
137
  return tag === 'input' || tag === 'textarea' || (target && target.isContentEditable);
138
138
  });
139
139
  this.core.onFormatChange = () => this.scheduleRteRefresh();
140
+ this.core.onFoldLiveEdit = () => this.syncLiveEdit();
140
141
  this.core.onCodeSourceChange = () => this.refreshCodeSource();
141
142
  this.core.onSavedChange = () => this.refreshSavedLabel();
142
143
  this.core.onToast = () => this.refreshToast();
@@ -151,6 +152,7 @@ export class MailCraftEditor extends ElementBase {
151
152
  disconnectedCallback() {
152
153
  this.unsubscribe?.();
153
154
  this.core.onFormatChange = null;
155
+ this.core.onFoldLiveEdit = null;
154
156
  this.core.onCodeSourceChange = null;
155
157
  this.core.onSavedChange = null;
156
158
  this.core.onToast = null;
@@ -889,6 +891,10 @@ export class MailCraftEditor extends ElementBase {
889
891
  this.renderInner();
890
892
  } finally {
891
893
  this.core.rendering = false;
894
+ // Every render rebuilds the canvas from props, so by here the live
895
+ // contenteditable is a copy of them again -- whatever `editStale`
896
+ // claimed is now true of the DOM as well (see `syncLiveEdit`).
897
+ this.core.editStale = null;
892
898
  }
893
899
  if (this.mainEl) {
894
900
  if (this.mainEl.scrollTop !== mainTop) this.mainEl.scrollTop = mainTop;
@@ -902,10 +908,26 @@ export class MailCraftEditor extends ElementBase {
902
908
 
903
909
  syncLiveEdit() {
904
910
  const c = this.core;
911
+ // One-shot right of way for the two cases where props are deliberately
912
+ // *ahead* of the live contenteditable: a rich-content rewrite
913
+ // (`syncRichContent`) and an undo/redo, both in editor-core.js. Syncing
914
+ // the DOM back then is the "props are the truth" rule pointing the wrong
915
+ // way -- it silently undid every Text size / color / spacing change made
916
+ // while the block was focused, and every undo of one. Props win this one
917
+ // render; the rebuild right after puts the new html into the DOM. The flag
918
+ // The flag is cleared by the rebuild in `render()`, not here: a fold can
919
+ // run several times (once per `setProp`) before the next frame, and
920
+ // clearing it on the first of those handed the render back to the DOM
921
+ // copy -- two quick clicks of the RTE's `+` moved nothing at all.
922
+ if (c.editStale && c.editStale === c.state.editing) return;
905
923
  if (!c.state.editing || !c.editEl || !c.editEl.isConnected || !c.editKey) return;
906
924
  const found = c.find(c.state.doc, c.state.editing);
907
925
  if (!found.block) return;
908
926
  const val = c.editPlain ? c.editEl.textContent : c.editEl.innerHTML;
927
+ // Unchanged since the render that built it (`editRendered`, canvas.js
928
+ // `onFocus`) means there is no user edit to fold -- only the render's own
929
+ // DOM decorations, which must not become part of the document.
930
+ if (val === c.editRendered) return;
909
931
  if (val !== found.block.props[c.editKey]) found.block.props[c.editKey] = val;
910
932
  }
911
933
 
@@ -118,6 +118,17 @@ function blockCtx(core, editingBlockId) {
118
118
  core.editEl = node;
119
119
  core.editKey = key;
120
120
  core.editPlain = !!isPlainText;
121
+ // What the renderer just produced for this node, before the user has
122
+ // touched it -- including the decorations the render applies to the DOM
123
+ // and not to props (`overrideRichFont`, `overrideLinkColor` in
124
+ // block-body.js). `syncLiveEdit` folds the node back into props only
125
+ // when it differs from this, so a *user* edit is still captured but a
126
+ // rendering decision is never written into the document. Without it,
127
+ // picking a block font while editing baked the strip into props.html,
128
+ // and switching the control back to Inherit could no longer bring the
129
+ // imported per-paragraph fonts back. Refreshed on every refocus, which
130
+ // is every rebuild (focus-preserve.js).
131
+ core.editRendered = isPlainText ? node.textContent : node.innerHTML;
121
132
  if (core.state.editing === block.id && core.state.sel && core.state.sel.type === 'block' && core.state.sel.id === block.id) return;
122
133
  // Snapshotted only on a genuine new focus (not the guarded no-op above,
123
134
  // which also covers every re-render-triggered refocus while typing --