@seliseblocks/mailcraft 0.2.19 → 0.2.20
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 +6 -0
- package/dist/mailcraft-editor.bundle.js +80 -80
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/activate-your-account.html +5 -5
- package/examples/templates/back-in-stock.html +2 -2
- package/examples/templates/cart-left-behind.html +2 -2
- package/examples/templates/community-giveaway.html +2 -2
- package/examples/templates/frontend-futures-invite.html +3 -3
- package/examples/templates/give-25-get-25.html +3 -3
- package/examples/templates/invoice-paid.html +3 -3
- package/examples/templates/meet-nova-launch.html +3 -3
- package/examples/templates/mega-weekend-sale.html +1 -1
- package/examples/templates/order-confirmed.html +2 -2
- package/examples/templates/rate-your-headphones.html +2 -2
- package/examples/templates/reset-your-password.html +3 -3
- package/examples/templates/thankyou-promo-code.html +2 -2
- package/examples/templates/the-sunday-brief.html +2 -2
- package/examples/templates/welcome-to-your-workspace.html +4 -4
- package/examples/templates/your-order-shipped.html +3 -3
- package/examples/templates/your-password-was-changed.html +4 -4
- package/examples/templates/your-signin-code.html +4 -4
- package/examples/vanilla.html +50 -50
- package/package.json +1 -1
- package/src/core/blocks.js +2 -2
- package/src/core/editor-core.js +2 -2
- package/src/core/export.js +20 -2
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +8 -2
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +1 -1
- package/src/core/variables.js +11 -11
- package/src/render/block-body.js +1 -1
- package/src/render/focus-preserve.js +158 -158
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
|
@@ -1043,9 +1043,15 @@ function logicMarkersOf(text) {
|
|
|
1043
1043
|
if (!lines.length) return null;
|
|
1044
1044
|
const out = [];
|
|
1045
1045
|
for (const line of lines) {
|
|
1046
|
-
|
|
1046
|
+
// Both tolerate padding, and symmetrically. The close tag used to accept
|
|
1047
|
+
// `{{/if}}` and nothing else, while the open one already allowed
|
|
1048
|
+
// `{{#if x }}` -- so a hand-written or foreign `{{/if }}` silently
|
|
1049
|
+
// stopped being a block end and imported as a text block instead,
|
|
1050
|
+
// leaving the condition open. The exporter's own output is tight
|
|
1051
|
+
// (`tightenTokens`), but nothing tightens what arrives through import.
|
|
1052
|
+
let m = line.match(/^\{\{\s*#(if|each)\s+([^{}]+?)\s*\}\}$/);
|
|
1047
1053
|
if (m) { out.push(blk(m[1] === 'if' ? 'condition' : 'loop', { expr: m[2], end: false })); continue; }
|
|
1048
|
-
m = line.match(/^\{\{
|
|
1054
|
+
m = line.match(/^\{\{\s*\/(if|each)\s*\}\}$/);
|
|
1049
1055
|
if (m) { out.push(blk(m[1] === 'if' ? 'condition' : 'loop', { expr: '', end: true })); continue; }
|
|
1050
1056
|
return null;
|
|
1051
1057
|
}
|
package/src/core/parse.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export function parseItems(s) {
|
|
2
|
-
return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
|
|
3
|
-
const i = l.indexOf('|');
|
|
4
|
-
return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
|
|
5
|
-
});
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function cellsOf(p) {
|
|
9
|
-
return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
|
|
10
|
-
}
|
|
1
|
+
export function parseItems(s) {
|
|
2
|
+
return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
|
|
3
|
+
const i = l.indexOf('|');
|
|
4
|
+
return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function cellsOf(p) {
|
|
9
|
+
return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
|
|
10
|
+
}
|
package/src/core/placeholder.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
/** Data-URI placeholder image generator, ported verbatim from the original. */
|
|
2
|
-
function enc(s) {
|
|
3
|
-
return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export function PH(label, w, ht) {
|
|
7
|
-
return 'data:image/svg+xml;utf8,' + enc(
|
|
8
|
-
'<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
|
|
9
|
-
'<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
|
|
10
|
-
'<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
|
|
11
|
-
'<rect width="100%" height="100%" fill="url(#s)"/>' +
|
|
12
|
-
'<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
|
|
13
|
-
'<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
|
|
14
|
-
);
|
|
15
|
-
}
|
|
1
|
+
/** Data-URI placeholder image generator, ported verbatim from the original. */
|
|
2
|
+
function enc(s) {
|
|
3
|
+
return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function PH(label, w, ht) {
|
|
7
|
+
return 'data:image/svg+xml;utf8,' + enc(
|
|
8
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
|
|
9
|
+
'<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
|
|
10
|
+
'<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
|
|
11
|
+
'<rect width="100%" height="100%" fill="url(#s)"/>' +
|
|
12
|
+
'<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
|
|
13
|
+
'<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
|
|
14
|
+
);
|
|
15
|
+
}
|
package/src/core/sanitize.js
CHANGED
|
@@ -27,7 +27,7 @@ export const scopeCss = (css, root) => {
|
|
|
27
27
|
return out;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
export const migrateTokens = (json) => String(json).replace(/\[\[\s*([\w.]+)\s*\]\]/g, '{' + '{
|
|
30
|
+
export const migrateTokens = (json) => String(json).replace(/\[\[\s*([\w.]+)\s*\]\]/g, '{' + '{$1}' + '}');
|
|
31
31
|
|
|
32
32
|
/** Paste sanitizer -- Word/Docs/Notion drop class soup, mso- properties and nested spans into the document; keep a small tag whitelist and drop attributes (href/target/rel on links survive). */
|
|
33
33
|
const PASTE_OK = { A: 1, B: 1, STRONG: 1, I: 1, EM: 1, U: 1, S: 1, STRIKE: 1, BR: 1, P: 1, UL: 1, OL: 1, LI: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, BLOCKQUOTE: 1, CODE: 1, SUP: 1, SUB: 1 };
|
package/src/core/variables.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
|
|
2
|
-
export const TOKEN = (t) => '{' + '{
|
|
3
|
-
|
|
4
|
-
/** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
|
|
5
|
-
export function vars(raw) {
|
|
6
|
-
const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
|
|
7
|
-
return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
|
|
11
|
-
export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
|
|
1
|
+
export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
|
|
2
|
+
export const TOKEN = (t) => '{' + '{' + t + '}' + '}';
|
|
3
|
+
|
|
4
|
+
/** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
|
|
5
|
+
export function vars(raw) {
|
|
6
|
+
const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
|
|
7
|
+
return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
|
|
11
|
+
export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
|
package/src/render/block-body.js
CHANGED
|
@@ -535,7 +535,7 @@ export function blockBody(b, theme, live, ctx, colPx) {
|
|
|
535
535
|
padding: '6px 12px', margin: '2px 0', color,
|
|
536
536
|
}, attr);
|
|
537
537
|
band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '9.5px', fontWeight: '700', letterSpacing: '0.12em', textTransform: 'uppercase', flex: 'none' }, { text: (p.end ? '⏶ ' : '⏷ ') + word }));
|
|
538
|
-
if (!p.end) band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '11.5px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, { text: '{
|
|
538
|
+
if (!p.end) band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '11.5px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, { text: '{' + '{' + (p.expr || '…') + '}' + '}' }));
|
|
539
539
|
return band;
|
|
540
540
|
}
|
|
541
541
|
// `pre-wrap`, not `pre`: `overflow-x:auto` gives the canvas a scrollbar
|
|
@@ -1,158 +1,158 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The original relies on React's reconciliation to keep a focused `<input>`'s
|
|
3
|
-
* DOM node alive across a per-keystroke re-render (Design-tab fields, the
|
|
4
|
-
* campaign title, AI brief/goal/tone, search boxes, the code textarea all
|
|
5
|
-
* commit on every `input` event, not just on blur). This renderer has no
|
|
6
|
-
* VDOM -- it rebuilds DOM from scratch -- so without this, typing a single
|
|
7
|
-
* character into any of those fields would lose focus immediately after.
|
|
8
|
-
*
|
|
9
|
-
* The fix: every such input (and every RTE-edited block: text, heading, box,
|
|
10
|
-
* html) carries a stable `data-focus-key`. Before a rebuild, capture the
|
|
11
|
-
* focused element's key + selection range; after, find the new element with
|
|
12
|
-
* the same key and restore focus and the caret position, so the net effect
|
|
13
|
-
* matches React's outcome even though the DOM node identity changed.
|
|
14
|
-
*
|
|
15
|
-
* Contenteditable blocks need this just as much as plain inputs: focusing
|
|
16
|
-
* one sets `core.state.editing`, and `EditorCore.mountKeyboard`'s
|
|
17
|
-
* `selectionchange` listener refreshes the toolbar's active states on every
|
|
18
|
-
* caret move. Without
|
|
19
|
-
* caret-position restoration here, that would blow away and recreate the
|
|
20
|
-
* focused div each time, so the very first keystroke would silently drop
|
|
21
|
-
* focus and the RTE toolbar would appear to do nothing.
|
|
22
|
-
*/
|
|
23
|
-
export function withFocusPreserved(root, rebuild) {
|
|
24
|
-
const active = root.activeElement;
|
|
25
|
-
const key = active && active.dataset ? active.dataset.focusKey : null;
|
|
26
|
-
let selStart = null; let selEnd = null; let scrollTop = null; let editable = false;
|
|
27
|
-
// A range slider mid-drag is the one focus-preservation case where
|
|
28
|
-
// restoring focus on a rebuilt node isn't enough: dragging its thumb is a
|
|
29
|
-
// native, implicit mouse capture tied to that exact element, and replacing
|
|
30
|
-
// the element (as every other rebuilt input does here) silently drops that
|
|
31
|
-
// capture -- the thumb stops tracking the mouse and the slider feels like
|
|
32
|
-
// it's snapping/jumping instead of gliding. So this one case skips
|
|
33
|
-
// rebuilding the node entirely: the live element is pulled out before
|
|
34
|
-
// `rebuild()` and spliced back into the freshly-built tree afterward.
|
|
35
|
-
const rangeNode = key && active.tagName === 'INPUT' && active.type === 'range' ? active : null;
|
|
36
|
-
if (rangeNode) {
|
|
37
|
-
// nothing to capture -- the node itself is preserved below, after rebuild()
|
|
38
|
-
} else if (key && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
|
|
39
|
-
try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch { /* some input types don't support selection */ }
|
|
40
|
-
scrollTop = active.scrollTop;
|
|
41
|
-
} else if (key && active.isContentEditable) {
|
|
42
|
-
editable = true;
|
|
43
|
-
const sel = shadowSelection(root);
|
|
44
|
-
if (sel && sel.rangeCount && active.contains(sel.anchorNode)) {
|
|
45
|
-
const range = sel.getRangeAt(0);
|
|
46
|
-
selStart = textOffset(active, range.startContainer, range.startOffset);
|
|
47
|
-
selEnd = textOffset(active, range.endContainer, range.endOffset);
|
|
48
|
-
}
|
|
49
|
-
scrollTop = active.scrollTop;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
rebuild();
|
|
53
|
-
|
|
54
|
-
if (!key) return;
|
|
55
|
-
const next = root.querySelector(`[data-focus-key="${cssEscape(key)}"]`);
|
|
56
|
-
if (!next) return;
|
|
57
|
-
if (rangeNode) {
|
|
58
|
-
for (const attr of ['min', 'max', 'step']) {
|
|
59
|
-
const v = next.getAttribute(attr);
|
|
60
|
-
if (v === null) rangeNode.removeAttribute(attr); else rangeNode.setAttribute(attr, v);
|
|
61
|
-
}
|
|
62
|
-
next.replaceWith(rangeNode);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (editable) {
|
|
66
|
-
// Set the Range *before* focusing: focusing a contenteditable establishes
|
|
67
|
-
// its own default collapsed selection as a side effect, and doing that
|
|
68
|
-
// after we've placed the real caret fires a second, out-of-order
|
|
69
|
-
// `selectionchange` notification for that now-stale default -- which
|
|
70
|
-
// arrives *after* the one for our real restore, so it looks like a fresh,
|
|
71
|
-
// later selection change and clobbers the correct caret right back to
|
|
72
|
-
// collapsed. Setting the range first means `.focus()` adopts the
|
|
73
|
-
// selection that's already there instead of replacing it.
|
|
74
|
-
if (selStart != null) {
|
|
75
|
-
try {
|
|
76
|
-
const range = document.createRange();
|
|
77
|
-
setPointAtOffset(range, next, selStart, true);
|
|
78
|
-
setPointAtOffset(range, next, selEnd, false);
|
|
79
|
-
const sel = shadowSelection(root);
|
|
80
|
-
sel.removeAllRanges();
|
|
81
|
-
sel.addRange(range);
|
|
82
|
-
} catch { /* ignore -- element structure changed under the caret */ }
|
|
83
|
-
}
|
|
84
|
-
// `preventScroll` matters a lot here: this `.focus()` fires on every
|
|
85
|
-
// re-render of a block mid-edit (every keystroke), not just once. Without
|
|
86
|
-
// it, the browser's default focus-scroll-into-view runs every time --
|
|
87
|
-
// harmless on a short template, but on a long one it yanks the canvas
|
|
88
|
-
// back toward the focused block on every keystroke, fighting whatever
|
|
89
|
-
// scroll position the user actually had.
|
|
90
|
-
next.focus({ preventScroll: true });
|
|
91
|
-
} else {
|
|
92
|
-
next.focus({ preventScroll: true });
|
|
93
|
-
if (selStart != null) {
|
|
94
|
-
try { next.setSelectionRange(selStart, selEnd); } catch { /* ignore */ }
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (scrollTop != null) next.scrollTop = scrollTop;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* `document.getSelection()`/`window.getSelection()` is redacted to the host
|
|
102
|
-
* document when the real selection lives inside an open shadow root -- in
|
|
103
|
-
* Chrome its `anchorNode` reports as `<body>` rather than the actual text
|
|
104
|
-
* node being edited, even though the visible caret and `execCommand` both
|
|
105
|
-
* still operate on the real position. Reading through that redacted object
|
|
106
|
-
* (as this file needs to, to compute/restore a caret offset) silently gives
|
|
107
|
-
* back garbage -- not an error, just always "position 0" -- which is what
|
|
108
|
-
* made every re-render-while-typing reset the caret to the start and type
|
|
109
|
-
* new characters in reverse. `ShadowRoot.getSelection()` (Chromium-only; no
|
|
110
|
-
* standard equivalent yet) reports the real node/offset.
|
|
111
|
-
*/
|
|
112
|
-
function shadowSelection(root) {
|
|
113
|
-
return typeof root.getSelection === 'function' ? root.getSelection() : window.getSelection();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Character offset of (node, offset) counting only text within `root`, walking in document order. */
|
|
117
|
-
export function textOffset(root, node, offset) {
|
|
118
|
-
if (node.nodeType !== Node.TEXT_NODE) {
|
|
119
|
-
// A range boundary can land on an element (e.g. offset counts child nodes) --
|
|
120
|
-
// resolve it to the text position right before its `offset`-th child.
|
|
121
|
-
let n = 0;
|
|
122
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
123
|
-
let cur; let target = node.childNodes[offset];
|
|
124
|
-
if (!target) { while (walker.nextNode()) n += walker.currentNode.nodeValue.length; return n; }
|
|
125
|
-
while ((cur = walker.nextNode())) { if (cur === target || target.contains(cur)) return n; n += cur.nodeValue.length; }
|
|
126
|
-
return n;
|
|
127
|
-
}
|
|
128
|
-
let n = 0;
|
|
129
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
130
|
-
let cur;
|
|
131
|
-
while ((cur = walker.nextNode())) {
|
|
132
|
-
if (cur === node) return n + offset;
|
|
133
|
-
n += cur.nodeValue.length;
|
|
134
|
-
}
|
|
135
|
-
return n;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Sets the start or end point of `range` to the text-offset position inside `root`. */
|
|
139
|
-
function setPointAtOffset(range, root, targetOffset, isStart) {
|
|
140
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
141
|
-
let n = 0; let cur; let last = null;
|
|
142
|
-
while ((cur = walker.nextNode())) {
|
|
143
|
-
last = cur;
|
|
144
|
-
const len = cur.nodeValue.length;
|
|
145
|
-
if (n + len >= targetOffset) {
|
|
146
|
-
const point = targetOffset - n;
|
|
147
|
-
if (isStart) range.setStart(cur, point); else range.setEnd(cur, point);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
n += len;
|
|
151
|
-
}
|
|
152
|
-
if (last) { if (isStart) range.setStart(last, last.nodeValue.length); else range.setEnd(last, last.nodeValue.length); }
|
|
153
|
-
else { if (isStart) range.setStart(root, 0); else range.setEnd(root, 0); }
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function cssEscape(s) {
|
|
157
|
-
return String(s).replace(/["\\]/g, '\\$&');
|
|
158
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The original relies on React's reconciliation to keep a focused `<input>`'s
|
|
3
|
+
* DOM node alive across a per-keystroke re-render (Design-tab fields, the
|
|
4
|
+
* campaign title, AI brief/goal/tone, search boxes, the code textarea all
|
|
5
|
+
* commit on every `input` event, not just on blur). This renderer has no
|
|
6
|
+
* VDOM -- it rebuilds DOM from scratch -- so without this, typing a single
|
|
7
|
+
* character into any of those fields would lose focus immediately after.
|
|
8
|
+
*
|
|
9
|
+
* The fix: every such input (and every RTE-edited block: text, heading, box,
|
|
10
|
+
* html) carries a stable `data-focus-key`. Before a rebuild, capture the
|
|
11
|
+
* focused element's key + selection range; after, find the new element with
|
|
12
|
+
* the same key and restore focus and the caret position, so the net effect
|
|
13
|
+
* matches React's outcome even though the DOM node identity changed.
|
|
14
|
+
*
|
|
15
|
+
* Contenteditable blocks need this just as much as plain inputs: focusing
|
|
16
|
+
* one sets `core.state.editing`, and `EditorCore.mountKeyboard`'s
|
|
17
|
+
* `selectionchange` listener refreshes the toolbar's active states on every
|
|
18
|
+
* caret move. Without
|
|
19
|
+
* caret-position restoration here, that would blow away and recreate the
|
|
20
|
+
* focused div each time, so the very first keystroke would silently drop
|
|
21
|
+
* focus and the RTE toolbar would appear to do nothing.
|
|
22
|
+
*/
|
|
23
|
+
export function withFocusPreserved(root, rebuild) {
|
|
24
|
+
const active = root.activeElement;
|
|
25
|
+
const key = active && active.dataset ? active.dataset.focusKey : null;
|
|
26
|
+
let selStart = null; let selEnd = null; let scrollTop = null; let editable = false;
|
|
27
|
+
// A range slider mid-drag is the one focus-preservation case where
|
|
28
|
+
// restoring focus on a rebuilt node isn't enough: dragging its thumb is a
|
|
29
|
+
// native, implicit mouse capture tied to that exact element, and replacing
|
|
30
|
+
// the element (as every other rebuilt input does here) silently drops that
|
|
31
|
+
// capture -- the thumb stops tracking the mouse and the slider feels like
|
|
32
|
+
// it's snapping/jumping instead of gliding. So this one case skips
|
|
33
|
+
// rebuilding the node entirely: the live element is pulled out before
|
|
34
|
+
// `rebuild()` and spliced back into the freshly-built tree afterward.
|
|
35
|
+
const rangeNode = key && active.tagName === 'INPUT' && active.type === 'range' ? active : null;
|
|
36
|
+
if (rangeNode) {
|
|
37
|
+
// nothing to capture -- the node itself is preserved below, after rebuild()
|
|
38
|
+
} else if (key && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
|
|
39
|
+
try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch { /* some input types don't support selection */ }
|
|
40
|
+
scrollTop = active.scrollTop;
|
|
41
|
+
} else if (key && active.isContentEditable) {
|
|
42
|
+
editable = true;
|
|
43
|
+
const sel = shadowSelection(root);
|
|
44
|
+
if (sel && sel.rangeCount && active.contains(sel.anchorNode)) {
|
|
45
|
+
const range = sel.getRangeAt(0);
|
|
46
|
+
selStart = textOffset(active, range.startContainer, range.startOffset);
|
|
47
|
+
selEnd = textOffset(active, range.endContainer, range.endOffset);
|
|
48
|
+
}
|
|
49
|
+
scrollTop = active.scrollTop;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
rebuild();
|
|
53
|
+
|
|
54
|
+
if (!key) return;
|
|
55
|
+
const next = root.querySelector(`[data-focus-key="${cssEscape(key)}"]`);
|
|
56
|
+
if (!next) return;
|
|
57
|
+
if (rangeNode) {
|
|
58
|
+
for (const attr of ['min', 'max', 'step']) {
|
|
59
|
+
const v = next.getAttribute(attr);
|
|
60
|
+
if (v === null) rangeNode.removeAttribute(attr); else rangeNode.setAttribute(attr, v);
|
|
61
|
+
}
|
|
62
|
+
next.replaceWith(rangeNode);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (editable) {
|
|
66
|
+
// Set the Range *before* focusing: focusing a contenteditable establishes
|
|
67
|
+
// its own default collapsed selection as a side effect, and doing that
|
|
68
|
+
// after we've placed the real caret fires a second, out-of-order
|
|
69
|
+
// `selectionchange` notification for that now-stale default -- which
|
|
70
|
+
// arrives *after* the one for our real restore, so it looks like a fresh,
|
|
71
|
+
// later selection change and clobbers the correct caret right back to
|
|
72
|
+
// collapsed. Setting the range first means `.focus()` adopts the
|
|
73
|
+
// selection that's already there instead of replacing it.
|
|
74
|
+
if (selStart != null) {
|
|
75
|
+
try {
|
|
76
|
+
const range = document.createRange();
|
|
77
|
+
setPointAtOffset(range, next, selStart, true);
|
|
78
|
+
setPointAtOffset(range, next, selEnd, false);
|
|
79
|
+
const sel = shadowSelection(root);
|
|
80
|
+
sel.removeAllRanges();
|
|
81
|
+
sel.addRange(range);
|
|
82
|
+
} catch { /* ignore -- element structure changed under the caret */ }
|
|
83
|
+
}
|
|
84
|
+
// `preventScroll` matters a lot here: this `.focus()` fires on every
|
|
85
|
+
// re-render of a block mid-edit (every keystroke), not just once. Without
|
|
86
|
+
// it, the browser's default focus-scroll-into-view runs every time --
|
|
87
|
+
// harmless on a short template, but on a long one it yanks the canvas
|
|
88
|
+
// back toward the focused block on every keystroke, fighting whatever
|
|
89
|
+
// scroll position the user actually had.
|
|
90
|
+
next.focus({ preventScroll: true });
|
|
91
|
+
} else {
|
|
92
|
+
next.focus({ preventScroll: true });
|
|
93
|
+
if (selStart != null) {
|
|
94
|
+
try { next.setSelectionRange(selStart, selEnd); } catch { /* ignore */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (scrollTop != null) next.scrollTop = scrollTop;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `document.getSelection()`/`window.getSelection()` is redacted to the host
|
|
102
|
+
* document when the real selection lives inside an open shadow root -- in
|
|
103
|
+
* Chrome its `anchorNode` reports as `<body>` rather than the actual text
|
|
104
|
+
* node being edited, even though the visible caret and `execCommand` both
|
|
105
|
+
* still operate on the real position. Reading through that redacted object
|
|
106
|
+
* (as this file needs to, to compute/restore a caret offset) silently gives
|
|
107
|
+
* back garbage -- not an error, just always "position 0" -- which is what
|
|
108
|
+
* made every re-render-while-typing reset the caret to the start and type
|
|
109
|
+
* new characters in reverse. `ShadowRoot.getSelection()` (Chromium-only; no
|
|
110
|
+
* standard equivalent yet) reports the real node/offset.
|
|
111
|
+
*/
|
|
112
|
+
function shadowSelection(root) {
|
|
113
|
+
return typeof root.getSelection === 'function' ? root.getSelection() : window.getSelection();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Character offset of (node, offset) counting only text within `root`, walking in document order. */
|
|
117
|
+
export function textOffset(root, node, offset) {
|
|
118
|
+
if (node.nodeType !== Node.TEXT_NODE) {
|
|
119
|
+
// A range boundary can land on an element (e.g. offset counts child nodes) --
|
|
120
|
+
// resolve it to the text position right before its `offset`-th child.
|
|
121
|
+
let n = 0;
|
|
122
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
123
|
+
let cur; let target = node.childNodes[offset];
|
|
124
|
+
if (!target) { while (walker.nextNode()) n += walker.currentNode.nodeValue.length; return n; }
|
|
125
|
+
while ((cur = walker.nextNode())) { if (cur === target || target.contains(cur)) return n; n += cur.nodeValue.length; }
|
|
126
|
+
return n;
|
|
127
|
+
}
|
|
128
|
+
let n = 0;
|
|
129
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
130
|
+
let cur;
|
|
131
|
+
while ((cur = walker.nextNode())) {
|
|
132
|
+
if (cur === node) return n + offset;
|
|
133
|
+
n += cur.nodeValue.length;
|
|
134
|
+
}
|
|
135
|
+
return n;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Sets the start or end point of `range` to the text-offset position inside `root`. */
|
|
139
|
+
function setPointAtOffset(range, root, targetOffset, isStart) {
|
|
140
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
141
|
+
let n = 0; let cur; let last = null;
|
|
142
|
+
while ((cur = walker.nextNode())) {
|
|
143
|
+
last = cur;
|
|
144
|
+
const len = cur.nodeValue.length;
|
|
145
|
+
if (n + len >= targetOffset) {
|
|
146
|
+
const point = targetOffset - n;
|
|
147
|
+
if (isStart) range.setStart(cur, point); else range.setEnd(cur, point);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
n += len;
|
|
151
|
+
}
|
|
152
|
+
if (last) { if (isStart) range.setStart(last, last.nodeValue.length); else range.setEnd(last, last.nodeValue.length); }
|
|
153
|
+
else { if (isStart) range.setStart(root, 0); else range.setEnd(root, 0); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function cssEscape(s) {
|
|
157
|
+
return String(s).replace(/["\\]/g, '\\$&');
|
|
158
|
+
}
|