@seliseblocks/mailcraft 0.1.1 → 0.2.1
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/DOCS.md +243 -16
- package/README.md +15 -6
- package/README.md.txt +135 -0
- package/dist/mailcraft-editor.bundle.js +87 -47
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/activate-your-account.html +1 -1
- package/examples/templates/back-in-stock.html +1 -1
- package/examples/templates/cart-left-behind.html +1 -1
- package/examples/templates/community-giveaway.html +1 -1
- package/examples/templates/frontend-futures-invite.html +1 -1
- package/examples/templates/give-25-get-25.html +1 -1
- package/examples/templates/invoice-paid.html +1 -1
- package/examples/templates/meet-nova-launch.html +1 -1
- package/examples/templates/mega-weekend-sale.html +1 -1
- package/examples/templates/order-confirmed.html +80 -80
- package/examples/templates/rate-your-headphones.html +1 -1
- package/examples/templates/reset-your-password.html +1 -1
- package/examples/templates/thankyou-promo-code.html +1 -1
- package/examples/templates/the-sunday-brief.html +1 -1
- package/examples/templates/welcome-to-your-workspace.html +1 -1
- package/examples/templates/your-order-shipped.html +1 -1
- package/examples/templates/your-password-was-changed.html +1 -1
- package/examples/templates/your-signin-code.html +1 -1
- package/examples/vanilla.html +261 -47
- package/package.json +7 -2
- package/src/core/assets.js +10 -15
- package/src/core/binder.js +140 -118
- package/src/core/blocks.js +14 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +1540 -1492
- package/src/core/export.js +158 -55
- package/src/core/i18n/ar.js +218 -177
- package/src/core/i18n/bg.js +195 -152
- package/src/core/i18n/bn.js +217 -176
- package/src/core/i18n/ca.js +195 -152
- package/src/core/i18n/cs.js +195 -152
- package/src/core/i18n/da.js +195 -152
- package/src/core/i18n/de-CH.js +195 -152
- package/src/core/i18n/de.js +195 -152
- package/src/core/i18n/dz.js +220 -179
- package/src/core/i18n/el.js +195 -152
- package/src/core/i18n/en.js +3 -11
- package/src/core/i18n/es.js +195 -152
- package/src/core/i18n/et.js +195 -152
- package/src/core/i18n/fi.js +195 -152
- package/src/core/i18n/fr.js +195 -152
- package/src/core/i18n/hr.js +195 -152
- package/src/core/i18n/hu.js +195 -152
- package/src/core/i18n/index.js +83 -83
- package/src/core/i18n/it.js +195 -152
- package/src/core/i18n/lt.js +195 -152
- package/src/core/i18n/lv.js +195 -152
- package/src/core/i18n/nb.js +195 -152
- package/src/core/i18n/nl.js +195 -152
- package/src/core/i18n/pl.js +195 -152
- package/src/core/i18n/pt.js +195 -152
- package/src/core/i18n/ro.js +195 -152
- package/src/core/i18n/ru.js +195 -152
- package/src/core/i18n/sk.js +195 -152
- package/src/core/i18n/sl.js +195 -152
- package/src/core/i18n/sv.js +195 -152
- package/src/core/i18n/tables.js +50 -50
- package/src/core/i18n/tr.js +195 -152
- package/src/core/i18n/uk.js +195 -152
- package/src/core/icons.js +237 -235
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +1047 -959
- 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/sanitize.js +141 -141
- package/src/core/storage-limits.js +189 -184
- package/src/core/storage.js +85 -85
- package/src/core/theme.js +15 -1
- package/src/core/variables.js +11 -11
- package/src/index.js +9 -9
- package/src/mailcraft-editor.js +64 -25
- package/src/render/block-body.js +49 -6
- package/src/render/canvas.js +74 -8
- package/src/render/fields.js +619 -588
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +241 -212
- package/src/render/screenshot.js +138 -132
- package/src/render/story.js +415 -415
- package/src/render/style.js +44 -8
- package/types/index.d.ts +422 -0
package/src/mailcraft-editor.js
CHANGED
|
@@ -8,6 +8,7 @@ import { renderField, renderFieldCards, typeCommit } from './render/fields.js';
|
|
|
8
8
|
import { icon } from './core/icons.js';
|
|
9
9
|
import { TOKEN } from './core/variables.js';
|
|
10
10
|
import { hl, cssUrl } from './core/sanitize.js';
|
|
11
|
+
import { decorateLogicTags } from './core/export.js';
|
|
11
12
|
import { withFocusPreserved } from './render/focus-preserve.js';
|
|
12
13
|
import { captureTemplatePng } from './render/screenshot.js';
|
|
13
14
|
import { resolveToolbar, toolbarKey } from './core/toolbar.js';
|
|
@@ -310,7 +311,7 @@ export class MailCraftEditor extends ElementBase {
|
|
|
310
311
|
this.core.emit();
|
|
311
312
|
}
|
|
312
313
|
|
|
313
|
-
/** `dir` wins when the host sets it explicitly; otherwise it defaults from `isRtl(locale)` so `locale="ar"` gets RTL for free. Applied directly to `#mc` (not full state, since it
|
|
314
|
+
/** `dir` wins when the host sets it explicitly; otherwise it defaults from `isRtl(locale)` so `locale="ar"` gets RTL for free. Applied directly to `#mc` (not full state, since it only mirrors the editor chrome). The email sheet, the code editor, and the value-shaped inspector fields pin their own `dir` so the document being built never flips with the chrome (see render/canvas.js renderDoc). */
|
|
314
315
|
applyDir() {
|
|
315
316
|
if (!this.mc) return;
|
|
316
317
|
const explicit = this.getAttribute('dir');
|
|
@@ -417,20 +418,21 @@ export class MailCraftEditor extends ElementBase {
|
|
|
417
418
|
|
|
418
419
|
/**
|
|
419
420
|
* Host-supplied file storage -- see `core/storage.js` for the contract, and
|
|
420
|
-
* README.md for a worked example.
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
421
|
+
* README.md for a worked example. The package ships no files of its own: with
|
|
422
|
+
* nothing set here the library opens empty and holds only what is dropped
|
|
423
|
+
* into it, in this browser, in this draft. Setting a provider hands the whole
|
|
424
|
+
* library to the backend -- folders, paging, upload and delete all go through
|
|
425
|
+
* it, and nothing is written to localStorage. Set `null` to go back.
|
|
424
426
|
*/
|
|
425
427
|
get storageProvider() { return this.core.storageProvider; }
|
|
426
428
|
set storageProvider(provider) { this.core.setStorageProvider(provider); }
|
|
427
429
|
|
|
428
430
|
/**
|
|
429
431
|
* What may be uploaded: `{ accept, maxBytes, maxWidth, maxHeight, maxFilesPerDrop, allowSvg }`.
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* See `core/storage-limits.js`.
|
|
432
|
+
* Only `maxBytes` is required alongside a provider -- sizes depend on the
|
|
433
|
+
* sending platform and the host's own rules, none of which this package can
|
|
434
|
+
* know. `accept` is optional and defaults to every image type the validator
|
|
435
|
+
* can recognize; list MIME types to narrow it. See `core/storage-limits.js`.
|
|
434
436
|
*/
|
|
435
437
|
get storageLimits() { return this.core.storageLimits; }
|
|
436
438
|
set storageLimits(limits) { this.core.setStorageLimits(limits); }
|
|
@@ -521,7 +523,7 @@ export class MailCraftEditor extends ElementBase {
|
|
|
521
523
|
|
|
522
524
|
this.footerBar = this.footerText = this.footerLink = null;
|
|
523
525
|
|
|
524
|
-
this.body = elS('div', 'display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: minmax(0, 1fr) auto; min-height: 0;', { class: 'mc-layout' });
|
|
526
|
+
this.body = elS('div', 'display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; background: var(--ed-work);', { class: 'mc-layout' });
|
|
525
527
|
this.frame.appendChild(this.body);
|
|
526
528
|
this.body.appendChild(this.buildMain());
|
|
527
529
|
this.body.appendChild(this.buildAside());
|
|
@@ -742,7 +744,7 @@ export class MailCraftEditor extends ElementBase {
|
|
|
742
744
|
* would strand whichever node the previous render had left in place.
|
|
743
745
|
*/
|
|
744
746
|
buildFooter() {
|
|
745
|
-
this.footerBar = elS('div', 'display: flex; grid-column: 1; grid-row: 2; justify-self: center; align-items: center; justify-content: center; gap: 4px; max-width: calc(100% - 32px); padding: 6px 14px;
|
|
747
|
+
this.footerBar = elS('div', 'display: flex; grid-column: 1; grid-row: 2; justify-self: center; align-items: center; justify-content: center; gap: 4px; max-width: calc(100% - 32px); padding: 6px 14px; background: transparent; color: var(--ed-panel-meta); font-family: var(--ed-font); font-size: var(--ed-panel-meta-size); letter-spacing: 0.06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;', { class: 'mc-footer' });
|
|
746
748
|
this.footerText = elS('span', '', { class: 'mc-footer-text' });
|
|
747
749
|
this.footerLink = elS('a', 'color: inherit; text-decoration: none;', { class: 'mc-footer-link' });
|
|
748
750
|
this.footerBar.appendChild(this.footerText);
|
|
@@ -919,8 +921,11 @@ export class MailCraftEditor extends ElementBase {
|
|
|
919
921
|
if (this.redoBtn) this.redoBtn.disabled = !s.future.length;
|
|
920
922
|
// A host-supplied `theme` attribute owns light/dark (see applyTheme) --
|
|
921
923
|
// hide the built-in toggle rather than let it fight the host's control.
|
|
924
|
+
// setProperty(..., 'important'): a plain assignment loses to the
|
|
925
|
+
// `!important` on `.mc-icon-label` (render/style.js) that keeps every
|
|
926
|
+
// other label-icon button visible, leaving the toggle rendered anyway.
|
|
922
927
|
if (this.chromeBtn) {
|
|
923
|
-
this.chromeBtn.style.display
|
|
928
|
+
this.chromeBtn.style.setProperty('display', this.hasAttribute('theme') ? 'none' : 'flex', 'important');
|
|
924
929
|
this.chromeBtn.labelNode.textContent = s.chrome === 'light' ? t('action.chromeToDark') : t('action.chromeToLight');
|
|
925
930
|
this.chromeBtn.i18nTipKey = s.chrome === 'light' ? 'action.chromeHintToDark' : 'action.chromeHintToLight';
|
|
926
931
|
this.chromeBtn.tipNode.textContent = t(this.chromeBtn.i18nTipKey);
|
|
@@ -1266,7 +1271,8 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1266
1271
|
line.addEventListener('click', () => this.core.select('block', b.id));
|
|
1267
1272
|
const chip = elS('span', `width: 22px; height: 22px; flex: none; border-radius: 6px; display: flex; align-items: center; justify-content: center; background: ${bSel ? 'var(--ed-accent)' : 'var(--ed-soft)'}; color: ${bSel ? 'var(--ed-accent-ink)' : 'var(--ed-accent)'};`);
|
|
1268
1273
|
chip.appendChild(icon(b.type, 12));
|
|
1269
|
-
|
|
1274
|
+
// dir=auto: this excerpt is the email's own copy, not chrome text.
|
|
1275
|
+
line.append(chip, elS('span', 'font-family: var(--ed-font); font-size: 12px; font-weight: 500; color: var(--ed-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: text || typeName, class: 'mc-tree-title', dir: 'auto' }));
|
|
1270
1276
|
if (text) {
|
|
1271
1277
|
line.append(
|
|
1272
1278
|
elS('span', 'flex: 1; min-width: 8px;'),
|
|
@@ -1305,7 +1311,7 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1305
1311
|
wrap.appendChild(this.sectionBar(t('vars.title'), t('vars.fromCode', { count: this.core.vars().length })));
|
|
1306
1312
|
const card = this.sectionBody();
|
|
1307
1313
|
card.appendChild(elS('p', 'margin: 0 0 12px; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: t('vars.declaredNote') }));
|
|
1308
|
-
const search = elS('input', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); border-radius: 8px; color: var(--ed-text); font: inherit; font-size: 12.5px; padding: 7px 9px; margin-bottom: 8px;', { placeholder: t('vars.filterPlaceholder'), 'data-focus-key': 'var-query' });
|
|
1314
|
+
const search = elS('input', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); border-radius: 8px; color: var(--ed-text); font: inherit; font-size: 12.5px; padding: 7px 9px; margin-bottom: 8px;', { placeholder: t('vars.filterPlaceholder'), 'data-focus-key': 'var-query', dir: 'auto' });
|
|
1309
1315
|
search.value = s.varQuery || '';
|
|
1310
1316
|
search.addEventListener('focus', () => { search.style.borderColor = 'var(--ed-accent)'; search.style.outline = 'none'; });
|
|
1311
1317
|
search.addEventListener('blur', () => { search.style.borderColor = 'var(--ed-line)'; });
|
|
@@ -1326,7 +1332,8 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1326
1332
|
const textSpan = elS('span', 'flex: 1; min-width: 0;');
|
|
1327
1333
|
textSpan.append(
|
|
1328
1334
|
elS('span', 'display: block; font-family: var(--ed-font); font-weight: 600; font-size: 12.5px; line-height: 1.2;', { text: name }),
|
|
1329
|
-
|
|
1335
|
+
// dir=ltr keeps the braces on the right ends of {{ token }} under RTL.
|
|
1336
|
+
elS('span', 'display: block; font-family: ui-monospace, monospace; font-size: 10px; color: var(--ed-accent); margin-top: 2px;', { text: TOKEN(v), dir: 'ltr' }),
|
|
1330
1337
|
);
|
|
1331
1338
|
btn.append(textSpan, elS('span', 'font-family: var(--ed-font); font-size: 9.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ed-faint);', { text: t('vars.insert') }));
|
|
1332
1339
|
grid.appendChild(btn);
|
|
@@ -1356,7 +1363,7 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1356
1363
|
elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('modal.fileManager'), 'data-i18n': 'modal.fileManager' }),
|
|
1357
1364
|
(this.libraryTitleText = elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 16px; line-height: 1.25;')),
|
|
1358
1365
|
);
|
|
1359
|
-
this.assetSearch = elS('input', 'width: 210px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 7px 9px;', { placeholder: t('library.searchPlaceholder'), 'data-i18n-placeholder': 'library.searchPlaceholder', 'data-focus-key': 'asset-query' });
|
|
1366
|
+
this.assetSearch = elS('input', 'width: 210px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 7px 9px;', { placeholder: t('library.searchPlaceholder'), 'data-i18n-placeholder': 'library.searchPlaceholder', 'data-focus-key': 'asset-query', dir: 'auto' });
|
|
1360
1367
|
this.assetSearch.addEventListener('focus', () => { this.assetSearch.style.borderColor = 'var(--ed-accent)'; this.assetSearch.style.outline = 'none'; });
|
|
1361
1368
|
this.assetSearch.addEventListener('blur', () => { this.assetSearch.style.borderColor = 'var(--ed-line)'; });
|
|
1362
1369
|
// Debounced re-render; setAssetQuery separately debounces the backend call.
|
|
@@ -1516,7 +1523,9 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1516
1523
|
head.append(headText, this.copyBtn, this.shotBtn, downloadBtn, closeBtn);
|
|
1517
1524
|
modal.appendChild(head);
|
|
1518
1525
|
|
|
1519
|
-
|
|
1526
|
+
// dir=ltr: this holds HTML source, which bidi-scrambles under the RTL
|
|
1527
|
+
// chrome (same reason as buildCodeEditor).
|
|
1528
|
+
this.exportTextarea = elS('textarea', 'width: 100%; height: 100%; box-sizing: border-box; border: 0; background: var(--ed-work); color: var(--ed-text); font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.55; padding: 18px 20px; resize: none;', { readonly: 'true', dir: 'ltr' });
|
|
1520
1529
|
modal.appendChild(this.exportTextarea);
|
|
1521
1530
|
this.exportMetaEl = elS('div', 'padding: 10px 20px; border-top: 1px solid var(--ed-line); font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.06em; color: var(--ed-faint);');
|
|
1522
1531
|
modal.appendChild(this.exportMetaEl);
|
|
@@ -1583,8 +1592,16 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1583
1592
|
|
|
1584
1593
|
const right = elS('div', 'display: grid; grid-template-rows: auto 1fr; min-height: 0; border: 1px solid var(--ed-line); border-radius: 12px; overflow: hidden; background: var(--ed-panel);', { class: 'mc-code-preview' });
|
|
1585
1594
|
right.appendChild(elS('div', 'padding: 7px 16px; border-bottom: 1px solid var(--ed-line); background: var(--ed-panel); font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('code.livePreview'), 'data-i18n': 'code.livePreview', class: 'mc-pane-label' }));
|
|
1586
|
-
|
|
1587
|
-
|
|
1595
|
+
// Flush, not a card on a mat: the document inside the frame paints its
|
|
1596
|
+
// own page background edge to edge, so padding the wrap and framing the
|
|
1597
|
+
// iframe stacked a second and third rectangle around a template that
|
|
1598
|
+
// already has one -- the pane, a mat, a bordered card, then the email's
|
|
1599
|
+
// own page, then its content column. `is-device` (renderCodeModal) puts
|
|
1600
|
+
// the mat and the frame back for the 390px phone width, where the card
|
|
1601
|
+
// *is* the point.
|
|
1602
|
+
const previewWrap = elS('div', 'overflow: auto; padding: 0; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
|
|
1603
|
+
this.codePreviewBody = previewWrap;
|
|
1604
|
+
this.codeFrame = elS('iframe', "width: 100%; height: 100%; min-height: 420px; border: 0; background: #ffffff; transition: width 0.24s cubic-bezier(0.22,0.61,0.36,1);", { title: t('code.liveHtmlPreviewTitle'), 'data-i18n-title': 'code.liveHtmlPreviewTitle', class: 'mc-code-frame' });
|
|
1588
1605
|
this.codeFrame.addEventListener('load', () => this.syncCodePreviewScrollbar());
|
|
1589
1606
|
previewWrap.appendChild(this.codeFrame);
|
|
1590
1607
|
right.appendChild(previewWrap);
|
|
@@ -1601,7 +1618,10 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1601
1618
|
* highlight or caret drifting away from the editable text. */
|
|
1602
1619
|
buildCodeEditor() {
|
|
1603
1620
|
const MONO = 'font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 20px; tab-size: 2;';
|
|
1604
|
-
|
|
1621
|
+
// dir=ltr: HTML source is directional text; under an RTL chrome the
|
|
1622
|
+
// inherited direction would bidi-scramble it and, worse, let the caret
|
|
1623
|
+
// in the transparent textarea drift off the highlighted layer beneath.
|
|
1624
|
+
const root = elS('div', 'position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; background: var(--ed-work);', { dir: 'ltr' });
|
|
1605
1625
|
const inner = elS('div', 'position: relative; min-height: 100%; width: 100%; min-width: 0;');
|
|
1606
1626
|
this.codePre = elS('pre', `${MONO} margin: 0; padding: 14px 0 80px; width: 100%; min-width: 0; min-height: 100%; white-space: normal; color: var(--ed-text); pointer-events: none; background: linear-gradient(to right, var(--ed-panel-2) 0 52px, var(--ed-work) 52px);`, { 'aria-hidden': 'true' });
|
|
1607
1627
|
this.codeTextarea = elS('textarea', `${MONO} position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 14px 18px 80px 70px; border: 0; background: transparent; color: transparent; caret-color: var(--ed-accent); white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; overflow: hidden; resize: none; outline: none;`, { spellcheck: 'false', wrap: 'soft', 'data-focus-key': 'code-src' });
|
|
@@ -1645,7 +1665,10 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1645
1665
|
if (!s.codeOpen) return;
|
|
1646
1666
|
this.refreshCodeSource();
|
|
1647
1667
|
if (this._codeFrameSrc !== s.codeLive) {
|
|
1648
|
-
|
|
1668
|
+
// Display-only: the preview pane draws logic tags as the canvas's
|
|
1669
|
+
// dashed bands; the source pane and everything exported keep the
|
|
1670
|
+
// literal {{#if}}/{{#each}} text.
|
|
1671
|
+
this.codeFrame.srcdoc = decorateLogicTags(s.codeLive);
|
|
1649
1672
|
this._codeFrameSrc = s.codeLive;
|
|
1650
1673
|
}
|
|
1651
1674
|
this.syncCodePreviewScrollbar();
|
|
@@ -1658,7 +1681,9 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1658
1681
|
tip(btn, w.label, 'down');
|
|
1659
1682
|
this.codeWidthSeg.appendChild(btn);
|
|
1660
1683
|
});
|
|
1661
|
-
|
|
1684
|
+
const phone = s.codeDevice === 'mobile';
|
|
1685
|
+
this.codeFrame.style.width = phone ? '390px' : '100%';
|
|
1686
|
+
this.codePreviewBody.classList.toggle('is-device', phone);
|
|
1662
1687
|
}
|
|
1663
1688
|
|
|
1664
1689
|
/** Repaint the syntax layer, gutter and metadata without touching the
|
|
@@ -1730,7 +1755,7 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1730
1755
|
|
|
1731
1756
|
const briefWrap = elS('div');
|
|
1732
1757
|
briefWrap.appendChild(elS('label', 'display: block; font-size: 11.5px; color: var(--ed-muted); margin-bottom: 4px;', { text: t('ai.briefLabel'), 'data-i18n': 'ai.briefLabel' }));
|
|
1733
|
-
this.aiBriefInput = elS('textarea', 'width: 100%; min-height: 78px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 9px; resize: vertical;', { placeholder: t('ai.briefPlaceholder'), 'data-i18n-placeholder': 'ai.briefPlaceholder', 'data-focus-key': 'ai-brief' });
|
|
1758
|
+
this.aiBriefInput = elS('textarea', 'width: 100%; min-height: 78px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 9px; resize: vertical;', { placeholder: t('ai.briefPlaceholder'), 'data-i18n-placeholder': 'ai.briefPlaceholder', 'data-focus-key': 'ai-brief', dir: 'auto' });
|
|
1734
1759
|
this.aiBriefInput.addEventListener('focus', () => { this.aiBriefInput.style.borderColor = 'var(--ed-accent)'; this.aiBriefInput.style.outline = 'none'; });
|
|
1735
1760
|
// Debounced with a blur flush: runAi reads state.aiBrief, and clicking
|
|
1736
1761
|
// Generate blurs this field first -- the flush lands the last keystrokes
|
|
@@ -1772,7 +1797,7 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1772
1797
|
s.aiResults.forEach((r) => {
|
|
1773
1798
|
const card = elS('div', 'position: relative; border: 1px solid var(--ed-line); border-radius: var(--ed-radius-sm); padding: 15px;');
|
|
1774
1799
|
card.appendChild(elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-accent); margin-bottom: 7px;', { text: r.kind }));
|
|
1775
|
-
card.appendChild(elS('div', 'font-size: 14px; line-height: 1.55; white-space: pre-wrap;', { text: r.text }));
|
|
1800
|
+
card.appendChild(elS('div', 'font-size: 14px; line-height: 1.55; white-space: pre-wrap;', { text: r.text, dir: 'auto' }));
|
|
1776
1801
|
const useBtn = elS('button', 'margin-top: 12px; border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; padding: 6px 11px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.12em; text-transform: uppercase; transition: background 0.16s, color 0.16s, border-color 0.16s;', { type: 'button', text: r.action });
|
|
1777
1802
|
useBtn.addEventListener('mouseenter', () => { useBtn.style.background = 'var(--ed-accent)'; useBtn.style.color = 'var(--ed-accent-ink)'; useBtn.style.borderColor = 'var(--ed-accent)'; });
|
|
1778
1803
|
useBtn.addEventListener('mouseleave', () => { useBtn.style.background = 'transparent'; useBtn.style.color = 'var(--ed-text)'; useBtn.style.borderColor = 'var(--ed-line)'; });
|
|
@@ -1801,7 +1826,14 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1801
1826
|
closeBtn.addEventListener('click', () => this.core.setState({ previewOpen: false }));
|
|
1802
1827
|
head.append(headText, this.previewDeviceSeg, closeBtn);
|
|
1803
1828
|
overlay.appendChild(head);
|
|
1804
|
-
|
|
1829
|
+
// Flush and unpainted: this body *is* the sent email's page, so the
|
|
1830
|
+
// sheet sits edge to edge exactly as exportHtml() renders it -- the
|
|
1831
|
+
// theme's page background is applied on each render (renderPreviewModal).
|
|
1832
|
+
const body = elS('div', 'overflow-y: auto; padding: 0;', { class: 'mc-preview-body' });
|
|
1833
|
+
// Kept on the instance: renderPreviewModal repaints it from the document's
|
|
1834
|
+
// page background on every render, and it runs long after this builder's
|
|
1835
|
+
// local has gone out of scope.
|
|
1836
|
+
this.previewBody = body;
|
|
1805
1837
|
this.previewSlot = elS('div', '', { class: 'mc-sheet-wrap' });
|
|
1806
1838
|
this.previewSlot.style.cssText = 'display: flex; justify-content: center;';
|
|
1807
1839
|
body.appendChild(this.previewSlot);
|
|
@@ -1826,6 +1858,13 @@ export class MailCraftEditor extends ElementBase {
|
|
|
1826
1858
|
tip(btn, d.label, 'down');
|
|
1827
1859
|
this.previewDeviceSeg.appendChild(btn);
|
|
1828
1860
|
});
|
|
1861
|
+
// The preview body doubles as the sent page: the theme's page background
|
|
1862
|
+
// paints it, exactly as the exported <body style="background:..."> does.
|
|
1863
|
+
// Falls back to the empty string -- and so to the stylesheet's dotted
|
|
1864
|
+
// grid -- for a transparent page, which is exactly what a client with no
|
|
1865
|
+
// page colour of its own shows behind the email.
|
|
1866
|
+
const pageBg = s.doc.theme.bg || '';
|
|
1867
|
+
this.previewBody.style.background = /^(transparent|none)$/i.test(pageBg.trim()) ? '' : pageBg;
|
|
1829
1868
|
this.previewSlot.innerHTML = '';
|
|
1830
1869
|
this.previewSlot.appendChild(renderDoc(this.core, false));
|
|
1831
1870
|
}
|
package/src/render/block-body.js
CHANGED
|
@@ -15,6 +15,30 @@ function el(tag, style, attrs) {
|
|
|
15
15
|
|
|
16
16
|
function m(v) { return String(v == null ? '' : v); }
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Writing assistants (Grammarly, LanguageTool and friends) treat any
|
|
20
|
+
* `contenteditable` as a field of their own: they float a button over its
|
|
21
|
+
* bottom-right corner -- squarely on top of this editor's own block toolbar --
|
|
22
|
+
* and inject their highlight/overlay DOM into it, which `syncLiveEdit`
|
|
23
|
+
* (mailcraft-editor.js) then reads back as innerHTML and stores in the
|
|
24
|
+
* template, so their markup ends up in the exported email. Every one of them
|
|
25
|
+
* honours an opt-out attribute on the field itself, so each editable surface
|
|
26
|
+
* declares all of them here rather than repeating the list six times below.
|
|
27
|
+
* The `spellcheck="false"` that already rode along stays on every copy,
|
|
28
|
+
* editable or not, and `core/export.js` strips all of it back out on the way
|
|
29
|
+
* to the recipient.
|
|
30
|
+
*/
|
|
31
|
+
const NO_ASSIST = {
|
|
32
|
+
'data-gramm': 'false',
|
|
33
|
+
'data-gramm_editor': 'false',
|
|
34
|
+
'data-enable-grammarly': 'false',
|
|
35
|
+
'data-lt-active': 'false',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function editableAttrs(on) {
|
|
39
|
+
return on ? { contenteditable: 'true', spellcheck: 'false', ...NO_ASSIST } : { spellcheck: 'false' };
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
/** An explicit block font owns every rich descendant; an empty value deliberately leaves imported inline typography untouched. */
|
|
19
43
|
function overrideRichFont(root, fontFamily) {
|
|
20
44
|
if (!fontFamily) return;
|
|
@@ -38,7 +62,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
38
62
|
const edit = live;
|
|
39
63
|
const content = el('div', {
|
|
40
64
|
padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align, fontWeight: p.weight, color: p.color || t.text, outline: 'none', fontFamily: p.fontFamily || t.font,
|
|
41
|
-
}, { ...attr,
|
|
65
|
+
}, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
42
66
|
// Imported email HTML keeps inline font-family declarations so an
|
|
43
67
|
// untouched block retains its source typography. Once the user chooses
|
|
44
68
|
// a block font, however, those nested declarations outrank inheritance
|
|
@@ -76,7 +100,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
76
100
|
// email pattern; the color falls back to the label color so a bare
|
|
77
101
|
// "outline thickness" bump looks right without a second step.
|
|
78
102
|
border: p.borderW ? p.borderW + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.borderColor || p.color) : '0',
|
|
79
|
-
}, { href: p.href,
|
|
103
|
+
}, { href: p.href, ...editableAttrs(live), text: p.label });
|
|
80
104
|
a.addEventListener('click', (e) => e.preventDefault());
|
|
81
105
|
// Button editing is deliberately plain: no focus-tracked `editing` state, no
|
|
82
106
|
// paste handling, no floating RTE toolbar -- only its label commits on blur.
|
|
@@ -147,7 +171,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
147
171
|
const safe = live
|
|
148
172
|
? raw.replace(/<style([^>]*)>([\s\S]*?)<\/style>/gi, (mm, at, css) => '<style' + at + '>' + ctx.scopeCss(css, '[data-mc-sheet]') + '</style>')
|
|
149
173
|
: raw;
|
|
150
|
-
const content = el('div', { padding: '6px 0', fontFamily: t.font, color: t.text, outline: 'none' }, { ...attr,
|
|
174
|
+
const content = el('div', { padding: '6px 0', fontFamily: t.font, color: t.text, outline: 'none' }, { ...attr, ...editableAttrs(edit), html: m(safe), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
151
175
|
if (edit) wireEditable(content, b, 'code', ctx, false);
|
|
152
176
|
if (!live) return content;
|
|
153
177
|
return wrapWithRte(b, ctx, edit && ctx.editingId === b.id, content);
|
|
@@ -187,7 +211,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
187
211
|
color: p.color || t.text, outline: 'none',
|
|
188
212
|
// An explicit per-block font beats the Condensed/Body style toggle.
|
|
189
213
|
fontFamily: p.fontFamily || (p.font === 'condensed' ? "'Arial Narrow', 'Helvetica Neue Condensed', Helvetica, Arial, sans-serif" : t.font),
|
|
190
|
-
}, { ...attr,
|
|
214
|
+
}, { ...attr, ...editableAttrs(edit), text: m(p.text), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
191
215
|
if (edit) wireEditable(head, b, 'text', ctx, true);
|
|
192
216
|
if (!live) return head;
|
|
193
217
|
return wrapWithRte(b, ctx, ctx.editingId === b.id, head);
|
|
@@ -217,7 +241,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
217
241
|
fontWeight: isHead ? '600' : '400', outline: 'none',
|
|
218
242
|
fontFamily: p.fontFamily || t.font,
|
|
219
243
|
letterSpacing: isHead ? '0.06em' : 'normal', textTransform: isHead ? 'uppercase' : 'none', fontSize: isHead ? p.size + 1 + 'px' : p.size + 'px',
|
|
220
|
-
}, {
|
|
244
|
+
}, { ...editableAttrs(edit), text: m(cell), 'data-focus-key': edit ? 'block:' + b.id + ':c' + ri + '-' + ci : undefined });
|
|
221
245
|
if (edit) {
|
|
222
246
|
// stopPropagation is load-bearing (a bubbled click would select
|
|
223
247
|
// via the block wrapper AND deselect via the workspace), but it
|
|
@@ -262,13 +286,32 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
262
286
|
minHeight: p.minH ? p.minH + 'px' : 'auto', maxWidth: p.maxW + '%', margin: p.align === 'center' ? '0 auto' : '0',
|
|
263
287
|
boxShadow: p.shadow ? '0 10px 30px rgba(29,31,32,0.12)' : 'none',
|
|
264
288
|
fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6',
|
|
265
|
-
}, { ...attr,
|
|
289
|
+
}, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
266
290
|
if (edit) wireEditable(box, b, 'html', ctx, false);
|
|
267
291
|
if (!live) return box;
|
|
268
292
|
return wrapWithRte(b, ctx, ctx.editingId === b.id, box);
|
|
269
293
|
}
|
|
270
294
|
case 'svg':
|
|
271
295
|
return el('div', { padding: p.py + 'px 0', textAlign: p.align }, { ...attr, html: '<span style="display:inline-block;width:' + p.width + '%">' + p.code + '</span>' });
|
|
296
|
+
// Dynamic-content markers. Editor furniture, never sent: the exporter
|
|
297
|
+
// emits the template tag itself at this position (core/export.js) and
|
|
298
|
+
// never reads this DOM back. The band renders in the preview too -- the
|
|
299
|
+
// preview shows the template the way it shows merge tokens, so the logic
|
|
300
|
+
// structure stays visible there rather than silently vanishing.
|
|
301
|
+
case 'condition':
|
|
302
|
+
case 'loop': {
|
|
303
|
+
const isLoop = b.type === 'loop';
|
|
304
|
+
const color = isLoop ? '#7c3aed' : '#0e7490';
|
|
305
|
+
const word = p.end ? (isLoop ? 'end loop' : 'end if') : (isLoop ? 'repeat each' : 'show if');
|
|
306
|
+
const band = el('div', {
|
|
307
|
+
display: 'flex', alignItems: 'center', gap: '8px', boxSizing: 'border-box',
|
|
308
|
+
border: '1.5px dashed ' + color, borderRadius: '7px', background: color + '14',
|
|
309
|
+
padding: '6px 12px', margin: '2px 0', color,
|
|
310
|
+
}, attr);
|
|
311
|
+
band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '9.5px', fontWeight: '700', letterSpacing: '0.12em', textTransform: 'uppercase', flex: 'none' }, { text: (p.end ? '⏶ ' : '⏷ ') + word }));
|
|
312
|
+
if (!p.end) band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '11.5px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, { text: '{{ ' + (p.expr || '…') + ' }}' }));
|
|
313
|
+
return band;
|
|
314
|
+
}
|
|
272
315
|
case 'codeblock':
|
|
273
316
|
return el('pre', { margin: '0', padding: p.pad + 'px', background: p.bg, color: p.color, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: p.size + 'px', lineHeight: '1.6', overflowX: 'auto', whiteSpace: 'pre' }, { ...attr, text: m(p.code) });
|
|
274
317
|
default:
|
package/src/render/canvas.js
CHANGED
|
@@ -169,21 +169,63 @@ export function renderDoc(core, live) {
|
|
|
169
169
|
// Lives only for this render's drag gesture(s) -- no core state, no re-render.
|
|
170
170
|
const rowDropTracker = { active: null };
|
|
171
171
|
|
|
172
|
+
// `dir="ltr"` pins the sheet to the document's own direction. The editor
|
|
173
|
+
// chrome mirrors under an RTL locale (`dir=rtl` on `#mc`), but the email
|
|
174
|
+
// being built is a separate document: exportHtml emits no `dir`, so mail
|
|
175
|
+
// clients render it LTR -- letting the chrome's direction cascade in here
|
|
176
|
+
// flipped bidi punctuation and default alignment on the canvas and made it
|
|
177
|
+
// disagree with the exported result.
|
|
178
|
+
const radius = Number(theme.radius) || 0;
|
|
172
179
|
const root = el('div', {
|
|
173
|
-
width: width + 'px', maxWidth: '100%', background: theme.contentBg, color: theme.text, fontFamily: theme.font,
|
|
174
|
-
|
|
175
|
-
|
|
180
|
+
width: width + 'px', maxWidth: '100%', background: theme.contentBg || 'transparent', color: theme.text, fontFamily: theme.font,
|
|
181
|
+
// Only when the document actually asks for a shape: an unconditional
|
|
182
|
+
// `0px` would override the editor chrome's own soft corner on the sheet
|
|
183
|
+
// (style.js) and square off every template that never touched the field.
|
|
184
|
+
borderRadius: radius ? radius + 'px' : '',
|
|
185
|
+
// Clipping the rows to that corner is right for the sent email (export
|
|
186
|
+
// emits `overflow:hidden` alongside the radius) and for the static
|
|
187
|
+
// preview, but not for the editable canvas: the sheet is also what the
|
|
188
|
+
// row grip straddles and what the floating RTE toolbar overhangs
|
|
189
|
+
// (top:-82px on the first block), and hiding the overflow cuts both off.
|
|
190
|
+
overflow: !live && radius ? 'hidden' : '',
|
|
191
|
+
transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s, border-radius 0.2s',
|
|
192
|
+
}, { 'data-mc-sheet': '1', dir: 'ltr' });
|
|
193
|
+
|
|
194
|
+
/*
|
|
195
|
+
* The page: the full-width section the email sits on, painted from the
|
|
196
|
+
* document's own `bg`/`padY`/`padX` rather than left to the workspace
|
|
197
|
+
* chrome. It is the band a mail client shows around the content column --
|
|
198
|
+
* the one part of the template that used to exist only in the exported
|
|
199
|
+
* HTML, so changing its colour did nothing visible in the editor and its
|
|
200
|
+
* size could not be changed at all. With the padding at its 0 default the
|
|
201
|
+
* page hugs the sheet exactly and nothing about the canvas changes.
|
|
202
|
+
*/
|
|
203
|
+
const padY = Number(theme.padY) || 0;
|
|
204
|
+
const padX = Number(theme.padX) || 0;
|
|
205
|
+
const page = el('div', {
|
|
206
|
+
background: theme.bg || 'transparent',
|
|
207
|
+
padding: padY + 'px ' + padX + 'px',
|
|
208
|
+
boxSizing: 'border-box', maxWidth: '100%', display: 'flex', justifyContent: 'center',
|
|
209
|
+
transition: 'background 0.2s, padding 0.22s cubic-bezier(0.22,0.61,0.36,1)',
|
|
210
|
+
// `is-padded` is what moves the editor's frame outward (style.js): with
|
|
211
|
+
// no padding the page hugs the sheet exactly, so the sheet keeps the
|
|
212
|
+
// frame and an untouched canvas looks exactly as it always did.
|
|
213
|
+
}, { 'data-mc-page': '1', class: 'mc-page' + (padY || padX ? ' is-padded' : ''), dir: 'ltr' });
|
|
176
214
|
|
|
177
215
|
const rowLines = [];
|
|
178
216
|
if (live) {
|
|
179
|
-
|
|
217
|
+
// On the page, not the sheet: a drag held over the page padding is still
|
|
218
|
+
// aimed at the template, and these fire for the sheet's own events too
|
|
219
|
+
// (they bubble). Column drops stop propagation before they get here, as
|
|
220
|
+
// they did when these listeners sat on the sheet.
|
|
221
|
+
page.addEventListener('dragover', (e) => {
|
|
180
222
|
const dr = core.drag; if (!dr) return;
|
|
181
223
|
e.preventDefault();
|
|
182
224
|
const index = core.indexFromPoint(root, e.clientY);
|
|
183
225
|
showLine(rowDropTracker, rowLines[index]);
|
|
184
226
|
});
|
|
185
|
-
|
|
186
|
-
|
|
227
|
+
page.addEventListener('dragleave', (e) => { if (e.target === root || e.target === page) hideActive(rowDropTracker); });
|
|
228
|
+
page.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
|
|
187
229
|
}
|
|
188
230
|
|
|
189
231
|
d.rows.forEach((r, ri) => {
|
|
@@ -229,8 +271,31 @@ export function renderDoc(core, live) {
|
|
|
229
271
|
bWrap.addEventListener('dragstart', core.startDrag({ kind: 'move-block', id: b.id }));
|
|
230
272
|
bWrap.addEventListener('dragend', () => { core.drag = null; hideActive(rowDropTracker); hideActive(colTracker); });
|
|
231
273
|
bWrap.addEventListener('click', (e) => { e.stopPropagation(); core.select('block', b.id); });
|
|
274
|
+
// WebKit gives a `draggable="true"` ancestor priority over the
|
|
275
|
+
// selection machinery: a mousedown anywhere inside this wrapper is
|
|
276
|
+
// taken as the start of an element drag, so in Safari a double-click
|
|
277
|
+
// on block text never selects the word and pressing and sweeping
|
|
278
|
+
// across it never extends a selection. (Chromium and Gecko both
|
|
279
|
+
// special-case contenteditable descendants; WebKit does not.)
|
|
280
|
+
// Dropping the attribute for the length of a gesture that begins
|
|
281
|
+
// inside editable text hands those mousedowns back to the caret;
|
|
282
|
+
// restoring it on mouseup keeps drag-to-reorder working from every
|
|
283
|
+
// other part of the block -- including its padding, which is what a
|
|
284
|
+
// block is normally picked up by.
|
|
285
|
+
bWrap.addEventListener('mousedown', (e) => {
|
|
286
|
+
const target = e.target;
|
|
287
|
+
if (!target || target.nodeType !== 1 || !target.closest('[contenteditable="true"]')) return;
|
|
288
|
+
bWrap.draggable = false;
|
|
289
|
+
const view = bWrap.ownerDocument.defaultView;
|
|
290
|
+
if (view) view.addEventListener('mouseup', () => { bWrap.draggable = true; }, { once: true });
|
|
291
|
+
});
|
|
232
292
|
}
|
|
233
|
-
|
|
293
|
+
// Not while this block is being inline-edited: the pill sits at the
|
|
294
|
+
// block's top-right, flush against the floating RTE toolbar's bottom
|
|
295
|
+
// edge -- visually colliding with it, and putting its Delete button a
|
|
296
|
+
// few pixels from the RTE's own controls. The RTE stands in for it for
|
|
297
|
+
// the duration of the edit; it comes back on blur (still selected).
|
|
298
|
+
if (bSel && ctx.editingId !== b.id) bWrap.appendChild(toolbar(core, b.id, 'block', DEF(b.type).code));
|
|
234
299
|
bWrap.appendChild(blockBody(b, theme, live, ctx));
|
|
235
300
|
items.push(bWrap);
|
|
236
301
|
});
|
|
@@ -280,5 +345,6 @@ export function renderDoc(core, live) {
|
|
|
280
345
|
root.appendChild(el('div', { border: '1px dashed var(--ed-accent-sheet-line)', padding: '90px 20px', textAlign: 'center', fontFamily: 'ui-monospace,monospace', fontSize: '10.5px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--ed-faint)' }, { text: 'drag a row or block here' }));
|
|
281
346
|
}
|
|
282
347
|
|
|
283
|
-
|
|
348
|
+
page.appendChild(root);
|
|
349
|
+
return page;
|
|
284
350
|
}
|