@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/CHANGELOG.md +9 -0
- package/dist/mailcraft-editor.bundle.js +52 -52
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +1 -1
- package/src/core/editor-core.js +49 -14
- package/src/core/import-html.js +61 -3
- package/src/mailcraft-editor.js +22 -0
- package/src/render/canvas.js +11 -0
package/package.json
CHANGED
package/src/core/editor-core.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
//
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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; }); }
|
package/src/core/import-html.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/src/mailcraft-editor.js
CHANGED
|
@@ -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
|
|
package/src/render/canvas.js
CHANGED
|
@@ -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 --
|