@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/CHANGELOG.md +14 -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 +110 -14
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +61 -3
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +22 -0
- package/src/render/canvas.js +11 -0
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +27 -2
- package/src/render/story.js +415 -415
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 = [
|
|
@@ -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
|
-
//
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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; }); }
|
package/src/core/i18n/index.js
CHANGED
|
@@ -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);
|
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
|