@seliseblocks/mailcraft 0.2.13 → 0.2.14

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.13",
3
+ "version": "0.2.14",
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",
@@ -580,7 +580,7 @@ export class EditorCore {
580
580
  * dragging through uniformly-formatted text rebuilds nothing at all.
581
581
  */
582
582
  formatFingerprint(b) {
583
- let s = b.id + '|' + this.currentTag() + '|' + (b.props.size || 16) + '|' + (this.state.linkDraft ? 1 : 0) + '|';
583
+ let s = b.id + '|' + this.currentTag() + '|' + this.selSize(b) + '|' + (this.state.linkDraft ? 1 : 0) + '|';
584
584
  for (const cmd of ['bold', 'italic', 'underline', 'strikeThrough', 'superscript', 'subscript', 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull', 'insertUnorderedList', 'insertOrderedList']) {
585
585
  let on = false;
586
586
  try { on = document.queryCommandState(cmd); } catch { /* ignore */ }
@@ -622,6 +622,13 @@ export class EditorCore {
622
622
  };
623
623
 
624
624
  size(b, delta) {
625
+ // A non-collapsed selection inside a rich text block means the user is
626
+ // sizing a *run*, not the block -- every neighbouring control (bold,
627
+ // color, highlight) is selection-scoped, so a ± that rewrote the whole
628
+ // block's prop here read as broken. Only `text` can keep the resulting
629
+ // spans: a heading folds back through `textContent` (syncEdit), which
630
+ // would silently drop them, so it stays block-level.
631
+ if (b.type === 'text' && this.sizeSelection(b, delta)) return;
625
632
  // Uncommitted inline formatting is folded into props by `setProp` below
626
633
  // (`onFoldLiveEdit`), not here. This used to do its own fold, as a second
627
634
  // commit: that read `editEl.innerHTML` unconditionally, so a second click
@@ -639,6 +646,114 @@ export class EditorCore {
639
646
  this.setProp(b.id, 'size', Math.max(lo, Math.min(hi, cur + delta)));
640
647
  }
641
648
 
649
+ /** Nearest inline px font-size walking up from `node` to the edited block's wrapper -- null when no run declares one (the block prop then owns the size). */
650
+ inlineSizeAt(node) {
651
+ let n = node && node.nodeType === 1 ? node : (node ? node.parentElement : null);
652
+ while (n && n !== this.editEl) {
653
+ const m = /^([\d.]+)px$/.exec((n.style && n.style.fontSize) || '');
654
+ if (m) return parseFloat(m[1]);
655
+ n = n.parentElement;
656
+ }
657
+ return null;
658
+ }
659
+
660
+ /** What the ± readout should show: the inline size at the selection when the caret sits inside a sized run, else the block's own size. Also part of `formatFingerprint`, so moving the caret across differently-sized runs refreshes the toolbar. */
661
+ selSize(b) {
662
+ const live = this.find(this.state.doc, b.id).block || b;
663
+ const base = Number(live.props.size) || 16;
664
+ if (b.type !== 'text' || this.state.editing !== b.id || !this.editEl) return base;
665
+ const sel = this.getSelection();
666
+ const node = sel && sel.rangeCount && this.editEl.contains(sel.anchorNode)
667
+ ? sel.anchorNode
668
+ : (this.savedRange && this.editEl.contains(this.savedRange.startContainer) ? this.savedRange.startContainer : null);
669
+ const inline = node ? this.inlineSizeAt(node) : null;
670
+ return inline == null ? base : Math.round(inline);
671
+ }
672
+
673
+ /**
674
+ * Sizes just the selected run(s) of text by wrapping each selected text node
675
+ * in a `font-size` span (or restepping the span a previous click made --
676
+ * repeated ± must not nest one span per click). Wrapping happens at the text
677
+ * node, the innermost level, so the new size always outranks any inline size
678
+ * an imported ancestor carries. The change lives in the contenteditable like
679
+ * bold/italic do and folds into props through the same blur/commit path.
680
+ *
681
+ * Returns false when the click is not selection-scoped -- no live edit, a
682
+ * bare caret, or a selection covering the whole block. The last keeps
683
+ * select-all + ± behaving as the block-level master scale it always was
684
+ * (`syncRichContent` then *scales* mixed sizes instead of flattening them,
685
+ * and the saved `size` prop stays truthful).
686
+ */
687
+ sizeSelection(b, delta) {
688
+ const root = this.editEl;
689
+ if (!root || !root.isConnected || this.state.editing !== b.id) return false;
690
+ const sel = this.getSelection();
691
+ // Same fallback discipline as `exec`: the live selection wins when it is
692
+ // inside the edited block; `savedRange` covers focus stolen by a control.
693
+ let src = sel && sel.rangeCount && root.contains(sel.anchorNode) && root.contains(sel.focusNode) ? sel.getRangeAt(0) : null;
694
+ if (!src && this.savedRange && root.contains(this.savedRange.startContainer) && root.contains(this.savedRange.endContainer)) src = this.savedRange;
695
+ if (!src || src.collapsed) return false;
696
+ const range = src.cloneRange();
697
+ const total = root.textContent.length;
698
+ if (charOffset(root, range.startContainer, range.startOffset) === 0
699
+ && charOffset(root, range.endContainer, range.endOffset) === total) return false;
700
+
701
+ const [lo, hi] = SIZE_SPAN.text;
702
+ const live = this.find(this.state.doc, b.id).block || b;
703
+ const cur = this.inlineSizeAt(range.startContainer) || Number(live.props.size) || 16;
704
+ const next = Math.max(lo, Math.min(hi, Math.round(cur) + delta));
705
+
706
+ // Split the boundary text nodes so every text node intersecting the range
707
+ // is *fully* inside it; order matters when both ends share one node.
708
+ const endC = range.endContainer;
709
+ if (endC.nodeType === 3 && range.endOffset < endC.nodeValue.length) endC.splitText(range.endOffset);
710
+ const startC = range.startContainer;
711
+ if (startC.nodeType === 3 && range.startOffset > 0) {
712
+ const tail = startC.splitText(range.startOffset);
713
+ range.setStart(tail, 0);
714
+ if (endC === startC) range.setEnd(tail, tail.nodeValue.length);
715
+ }
716
+ const s = charOffset(root, range.startContainer, range.startOffset);
717
+ const e = charOffset(root, range.endContainer, range.endOffset);
718
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
719
+ const hits = [];
720
+ let pos = 0; let tn;
721
+ while ((tn = walker.nextNode())) {
722
+ const len = tn.nodeValue.length;
723
+ if (len && pos >= s && pos + len <= e) hits.push(tn);
724
+ pos += len;
725
+ if (pos >= e) break;
726
+ }
727
+ // Something non-text was selected (an image, say): still handled -- the
728
+ // click must not fall through and resize the whole block.
729
+ if (!hits.length) return true;
730
+ const wraps = hits.map((node) => {
731
+ const parent = node.parentElement;
732
+ if (parent && parent !== root && parent.tagName === 'SPAN' && parent.childNodes.length === 1) {
733
+ parent.style.fontSize = next + 'px';
734
+ return parent;
735
+ }
736
+ const span = document.createElement('span');
737
+ span.style.fontSize = next + 'px';
738
+ node.replaceWith(span);
739
+ span.appendChild(node);
740
+ return span;
741
+ });
742
+ // Reselect the runs so the next ± click steps from here, and cache the
743
+ // range the way `exec` does for controls that steal focus. Boundaries go
744
+ // *inside* the first/last wrap (each holds exactly one text node), so the
745
+ // selection anchor sits under the new span and `selSize` reads it for the
746
+ // toolbar readout.
747
+ const first = wraps[0].firstChild;
748
+ const last = wraps[wraps.length - 1].lastChild;
749
+ const r2 = document.createRange();
750
+ r2.setStart(first, 0);
751
+ r2.setEnd(last, last.nodeValue.length);
752
+ if (sel) { sel.removeAllRanges(); sel.addRange(r2); }
753
+ this.savedRange = r2.cloneRange();
754
+ return true;
755
+ }
756
+
642
757
  hasCountdown() { return this.state.doc.rows.some((r) => r.cols.some((c) => c.blocks.some((b) => b.type === 'countdown'))); }
643
758
 
644
759
  persist(doc, assets, chrome) {
@@ -158,8 +158,14 @@ export function blockBody(b, theme, live, ctx) {
158
158
  // `inline-table` so the wrapper's text-align still positions it; the
159
159
  // `align` attribute is the same instruction for Word, which ignores the
160
160
  // display value. A full-width button is a plain 100% table instead.
161
+ // `float:none` is load-bearing: browsers map `align="left|right"` on a
162
+ // table to a float presentational hint, which takes the pill out of
163
+ // flow -- the block collapses to its padding on the canvas and the
164
+ // button paints over the next block. The inline style outranks the
165
+ // hint everywhere floats work, and Word ignores CSS float, so the
166
+ // `align` attribute still does its one job there.
161
167
  const table = el('table', {
162
- display: p.full ? 'table' : 'inline-table', width: p.full ? '100%' : 'auto', borderCollapse: 'separate',
168
+ display: p.full ? 'table' : 'inline-table', width: p.full ? '100%' : 'auto', borderCollapse: 'separate', cssFloat: 'none',
163
169
  }, { role: 'presentation', cellpadding: '0', cellspacing: '0', border: '0', align: p.full ? undefined : p.align });
164
170
  const td = el('td', {
165
171
  background: p.bg, borderRadius: p.radius + 'px', padding: p.py + 'px ' + p.px + 'px', textAlign: 'center',
package/src/render/rte.js CHANGED
@@ -138,7 +138,10 @@ export function renderRte(core, b) {
138
138
  if (b.type === 'text' || b.type === 'heading') r1.append(
139
139
  sep(),
140
140
  btn('minus', 'Smaller text', () => core.size(b, -1)),
141
- el('span', { fontFamily: 'var(--ed-font)', fontSize: '9.5px', color: 'var(--rte-muted)', minWidth: '32px', textAlign: 'center' }, { text: (b.props.size || 16) + 'px' }),
141
+ // `selSize`, not `props.size`: with the caret inside a run the ± pair
142
+ // sized on its own (core `sizeSelection`), the readout shows that run's
143
+ // size -- the block prop no longer tells the whole story.
144
+ el('span', { fontFamily: 'var(--ed-font)', fontSize: '9.5px', color: 'var(--rte-muted)', minWidth: '32px', textAlign: 'center' }, { text: core.selSize(b) + 'px' }),
142
145
  btn('plus', 'Larger text', () => core.size(b, 1)),
143
146
  );
144
147