@seliseblocks/mailcraft 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/DOCS.md +8 -0
  3. package/dist/mailcraft-editor.bundle.js +68 -56
  4. package/dist/mailcraft-editor.bundle.js.map +3 -3
  5. package/package.json +2 -2
  6. package/src/core/code-tools.js +302 -0
  7. package/src/core/editor-core.js +116 -1
  8. package/src/core/export.js +5 -2
  9. package/src/core/i18n/ar.js +8 -0
  10. package/src/core/i18n/bg.js +8 -0
  11. package/src/core/i18n/bn.js +8 -0
  12. package/src/core/i18n/ca.js +8 -0
  13. package/src/core/i18n/cs.js +8 -0
  14. package/src/core/i18n/da.js +8 -0
  15. package/src/core/i18n/de-CH.js +8 -0
  16. package/src/core/i18n/de.js +8 -0
  17. package/src/core/i18n/dz.js +8 -0
  18. package/src/core/i18n/el.js +8 -0
  19. package/src/core/i18n/en.js +8 -0
  20. package/src/core/i18n/es.js +8 -0
  21. package/src/core/i18n/et.js +8 -0
  22. package/src/core/i18n/fi.js +8 -0
  23. package/src/core/i18n/fr.js +8 -0
  24. package/src/core/i18n/hr.js +8 -0
  25. package/src/core/i18n/hu.js +8 -0
  26. package/src/core/i18n/it.js +8 -0
  27. package/src/core/i18n/lt.js +8 -0
  28. package/src/core/i18n/lv.js +8 -0
  29. package/src/core/i18n/nb.js +8 -0
  30. package/src/core/i18n/nl.js +8 -0
  31. package/src/core/i18n/pl.js +8 -0
  32. package/src/core/i18n/pt.js +8 -0
  33. package/src/core/i18n/ro.js +8 -0
  34. package/src/core/i18n/ru.js +8 -0
  35. package/src/core/i18n/sk.js +8 -0
  36. package/src/core/i18n/sl.js +8 -0
  37. package/src/core/i18n/sv.js +8 -0
  38. package/src/core/i18n/tr.js +8 -0
  39. package/src/core/i18n/uk.js +8 -0
  40. package/src/core/icons.js +2 -0
  41. package/src/mailcraft-editor.js +468 -16
  42. package/src/render/block-body.js +7 -1
  43. package/src/render/rte.js +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/mailcraft",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
5
5
  "license": "MIT",
6
6
  "author": "SELISE Digital Platforms",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "scripts": {
62
62
  "build": "node build.js",
63
- "test": "node test/storage.test.mjs && node test/export.test.mjs && node test/templates.test.mjs && node test/system.test.mjs && node test/toolbar.test.mjs && node test/editor-dom.test.mjs && node test/css-cascade.test.mjs && node test/render-dom.test.mjs && node test/import-html.test.mjs && node test/interaction.test.mjs && node test/focus-preserve.test.mjs && node test/shortcuts.test.mjs && node test/import-structure.test.mjs && node test/roundtrip.test.mjs",
63
+ "test": "node test/storage.test.mjs && node test/export.test.mjs && node test/templates.test.mjs && node test/system.test.mjs && node test/toolbar.test.mjs && node test/editor-dom.test.mjs && node test/css-cascade.test.mjs && node test/render-dom.test.mjs && node test/import-html.test.mjs && node test/interaction.test.mjs && node test/focus-preserve.test.mjs && node test/shortcuts.test.mjs && node test/import-structure.test.mjs && node test/roundtrip.test.mjs && node test/code-tools.test.mjs",
64
64
  "prepublishOnly": "npm run build && npm test",
65
65
  "coverage": "c8 --reporter=text --reporter=text-summary --src=src --all --exclude=\"test/**\" --exclude=\"examples/**\" --exclude=\"build.js\" npm test"
66
66
  },
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Pure string helpers behind the Code view's editor features: a conservative
3
+ * HTML pretty-printer (Format), a tag scanner that powers the code <-> preview
4
+ * inspect link, plain-text search, and <mark> insertion into already
5
+ * syntax-highlighted lines. No DOM anywhere -- everything here is
6
+ * string-in/string-out so it runs (and is tested) in bare Node.
7
+ */
8
+
9
+ const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
10
+
11
+ /** Raw-text elements: the parser treats their contents as text, so the scanner and formatter must never read tags inside them. */
12
+ const RAW_TAGS = new Set(['style', 'script', 'textarea', 'title']);
13
+
14
+ /**
15
+ * The only tags between which the formatter may *invent* a line break where
16
+ * the source had no whitespace at all. Chosen because whitespace-only text is
17
+ * invisible there: table internals (the parser lifts it out of the row grid),
18
+ * and metadata that never renders. Deliberately absent: div, span, a, img --
19
+ * whitespace between inline or inline-block elements paints as a visible gap,
20
+ * and hybrid email columns (inline-block divs) actually break on a stray
21
+ * space, so those boundaries only ever break where whitespace already existed.
22
+ */
23
+ const SAFE_BREAK = new Set(['html', 'head', 'body', 'meta', 'title', 'link', 'style', 'script', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'ul', 'ol', 'li']);
24
+
25
+ /** whitespace-significant containers: everything inside is emitted verbatim. */
26
+ const PRE_TAGS = new Set(['pre', 'textarea']);
27
+
28
+ /** Index of the `>` closing the tag that starts before `from`, skipping quoted attribute values (`<img alt="a > b">`). -1 when the tag never closes. */
29
+ function tagEnd(s, from) {
30
+ let quote = '';
31
+ for (let j = from; j < s.length; j++) {
32
+ const ch = s[j];
33
+ if (quote) { if (ch === quote) quote = ''; }
34
+ else if (ch === '"' || ch === "'") quote = ch;
35
+ else if (ch === '>') return j;
36
+ }
37
+ return -1;
38
+ }
39
+
40
+ const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*/;
41
+
42
+ /**
43
+ * Lexes html into { kind, text, tag?, selfClose? } tokens: 'tag' (an open
44
+ * tag), 'close', 'comment' (whole comment, conditional comments included),
45
+ * 'decl' (doctype / CDATA / processing instruction), 'raw' (the verbatim
46
+ * contents of a raw-text element) and 'text'. Lenient by design -- a stray
47
+ * `<` stays part of the surrounding text.
48
+ */
49
+ function tokenize(src) {
50
+ const s = String(src == null ? '' : src);
51
+ const lower = s.toLowerCase();
52
+ const n = s.length;
53
+ const tokens = [];
54
+ const pushText = (from, to) => { if (to > from) tokens.push({ kind: 'text', text: s.slice(from, to) }); };
55
+ let last = 0;
56
+ let i = 0;
57
+ while (i < n) {
58
+ const lt = s.indexOf('<', i);
59
+ if (lt === -1) break;
60
+ const c = s[lt + 1];
61
+ if (c === '!' || c === '?') {
62
+ let end; let kind = 'decl';
63
+ if (s.startsWith('<!--', lt)) { const at = s.indexOf('-->', lt + 4); end = at === -1 ? n : at + 3; kind = 'comment'; }
64
+ else { const at = tagEnd(s, lt + 1); end = at === -1 ? n : at + 1; }
65
+ pushText(last, lt);
66
+ tokens.push({ kind, text: s.slice(lt, end) });
67
+ last = i = end;
68
+ continue;
69
+ }
70
+ if (c === '/' && TAG_NAME.test(s.slice(lt + 2, lt + 3))) {
71
+ const at = tagEnd(s, lt + 2);
72
+ const end = at === -1 ? n : at + 1;
73
+ const tag = TAG_NAME.exec(lower.slice(lt + 2, lt + 32))[0];
74
+ pushText(last, lt);
75
+ tokens.push({ kind: 'close', tag, text: s.slice(lt, end) });
76
+ last = i = end;
77
+ continue;
78
+ }
79
+ if (c && TAG_NAME.test(c)) {
80
+ const at = tagEnd(s, lt + 1);
81
+ const end = at === -1 ? n : at + 1;
82
+ const tag = TAG_NAME.exec(lower.slice(lt + 1, lt + 32))[0];
83
+ const selfClose = s[end - 2] === '/';
84
+ pushText(last, lt);
85
+ tokens.push({ kind: 'tag', tag, text: s.slice(lt, end), selfClose });
86
+ last = i = end;
87
+ if (RAW_TAGS.has(tag) && !selfClose) {
88
+ const closeAt = lower.indexOf('</' + tag, end);
89
+ const stop = closeAt === -1 ? n : closeAt;
90
+ if (stop > end) tokens.push({ kind: 'raw', tag, text: s.slice(end, stop) });
91
+ last = i = stop;
92
+ }
93
+ continue;
94
+ }
95
+ i = lt + 1;
96
+ }
97
+ pushText(last, n);
98
+ return tokens;
99
+ }
100
+
101
+ /** A text run that is exactly one handlebars block tag ({{#if …}}, {{/each}}) -- worth its own line, like the structural rows it usually sits between. */
102
+ const isLogicToken = (text) => /^\{\{[#/][^{}]*\}\}$/.test(text);
103
+
104
+ /**
105
+ * Pretty-prints html without changing what it renders: two-space indentation,
106
+ * one structural tag per line. The rules that keep it rendering-identical:
107
+ *
108
+ * 1. Non-whitespace text is copied byte for byte -- never reflowed.
109
+ * 2. Whitespace between nodes is only *normalized* (to newline + indent)
110
+ * where whitespace already existed; a break is *invented* only between
111
+ * SAFE_BREAK tags, where whitespace-only text is invisible.
112
+ * 3. Raw-text contents (<style>, <script>) and everything inside <pre> /
113
+ * <textarea> pass through verbatim.
114
+ * 4. Comments are atomic, so MSO conditionals survive whole.
115
+ *
116
+ * Idempotent: formatting formatted output returns it unchanged.
117
+ */
118
+ export function formatHtml(src) {
119
+ const tokens = tokenize(src);
120
+ // Split text into whitespace gaps (formatting material) and content cores (untouchable).
121
+ const items = [];
122
+ for (const tk of tokens) {
123
+ if (tk.kind !== 'text') { items.push(tk); continue; }
124
+ const m = /^(\s*)([\s\S]*?)(\s*)$/.exec(tk.text);
125
+ if (m[2] === '') { items.push({ kind: 'gap', text: tk.text }); continue; }
126
+ if (m[1]) items.push({ kind: 'gap', text: m[1] });
127
+ items.push({ kind: 'text', text: m[2] });
128
+ if (m[3]) items.push({ kind: 'gap', text: m[3] });
129
+ }
130
+ const breaky = (it) => !!it && (
131
+ ((it.kind === 'tag' || it.kind === 'close') && SAFE_BREAK.has(it.tag))
132
+ || it.kind === 'decl'
133
+ || (it.kind === 'text' && isLogicToken(it.text))
134
+ );
135
+ let out = '';
136
+ let started = false;
137
+ const stack = [];
138
+ let prev = null;
139
+ let gap = null;
140
+ for (const it of items) {
141
+ if (it.kind === 'gap') { gap = (gap || '') + it.text; continue; }
142
+ if (it.kind === 'close') {
143
+ const at = stack.lastIndexOf(it.tag);
144
+ if (at !== -1) stack.length = at; // implicitly closes unclosed children too
145
+ }
146
+ const inPre = stack.some((tag) => PRE_TAGS.has(tag));
147
+ const brk = started && !inPre && (
148
+ (gap !== null && gap !== '')
149
+ || (breaky(prev) && breaky(it))
150
+ || (breaky(prev) && it.kind === 'comment')
151
+ || (prev && prev.kind === 'comment' && breaky(it))
152
+ );
153
+ if (started && brk) out += '\n' + ' '.repeat(stack.length);
154
+ else if (started && gap) out += gap; // an inline gap survives verbatim
155
+ out += it.text;
156
+ if (it.kind === 'tag' && !it.selfClose && !VOID_TAGS.has(it.tag) && !RAW_TAGS.has(it.tag)) stack.push(it.tag);
157
+ prev = it;
158
+ gap = null;
159
+ started = true;
160
+ }
161
+ if (!started) return String(src == null ? '' : src);
162
+ return out.endsWith('\n') ? out : out + '\n';
163
+ }
164
+
165
+ /**
166
+ * Scans html for elements without parsing it into a DOM. Returns
167
+ * `{ els, byTag }`: `els` in document order, each
168
+ * `{ tag, nth, openStart, openEnd, closeStart, closeEnd, parent }` where
169
+ * `nth` is its index among same-tag elements (the key the inspect link uses
170
+ * to find the same element in the preview document, immune to the parser's
171
+ * inserted <tbody>s), `parent` is an index into `els` (-1 at the root), and
172
+ * offsets are into the source string. Unclosed elements end where their
173
+ * parent closes (or at EOF); raw-text contents are skipped, so a `</td>`
174
+ * inside a style string is never mistaken for markup.
175
+ */
176
+ export function scanElements(src) {
177
+ const s = String(src == null ? '' : src);
178
+ const lower = s.toLowerCase();
179
+ const n = s.length;
180
+ const els = [];
181
+ const byTag = Object.create(null);
182
+ const stack = [];
183
+ let i = 0;
184
+ while (i < n) {
185
+ const lt = s.indexOf('<', i);
186
+ if (lt === -1) break;
187
+ const c = s[lt + 1];
188
+ if (s.startsWith('<!--', lt)) { const at = s.indexOf('-->', lt + 4); i = at === -1 ? n : at + 3; continue; }
189
+ if (c === '!' || c === '?') { const at = tagEnd(s, lt + 1); i = at === -1 ? n : at + 1; continue; }
190
+ if (c === '/' && TAG_NAME.test(s.slice(lt + 2, lt + 3))) {
191
+ const at = tagEnd(s, lt + 2);
192
+ const end = at === -1 ? n : at + 1;
193
+ const tag = TAG_NAME.exec(lower.slice(lt + 2, lt + 32))[0];
194
+ for (let k = stack.length - 1; k >= 0; k--) {
195
+ if (els[stack[k]].tag !== tag) continue;
196
+ while (stack.length > k + 1) { const idx = stack.pop(); els[idx].closeStart = els[idx].closeEnd = lt; }
197
+ const idx = stack.pop();
198
+ els[idx].closeStart = lt;
199
+ els[idx].closeEnd = end;
200
+ break;
201
+ }
202
+ i = end;
203
+ continue;
204
+ }
205
+ if (c && TAG_NAME.test(c)) {
206
+ const at = tagEnd(s, lt + 1);
207
+ const end = at === -1 ? n : at + 1;
208
+ const tag = TAG_NAME.exec(lower.slice(lt + 1, lt + 32))[0];
209
+ const selfClose = s[end - 2] === '/';
210
+ const list = byTag[tag] || (byTag[tag] = []);
211
+ const el = { tag, nth: list.length, openStart: lt, openEnd: end, closeStart: end, closeEnd: end, parent: stack.length ? stack[stack.length - 1] : -1 };
212
+ els.push(el);
213
+ list.push(el);
214
+ i = end;
215
+ if (selfClose || VOID_TAGS.has(tag)) continue;
216
+ if (RAW_TAGS.has(tag)) {
217
+ const closeAt = lower.indexOf('</' + tag, end);
218
+ if (closeAt === -1) { el.closeStart = el.closeEnd = n; i = n; }
219
+ else {
220
+ const cgt = tagEnd(s, closeAt + 2);
221
+ el.closeStart = closeAt;
222
+ el.closeEnd = cgt === -1 ? n : cgt + 1;
223
+ i = el.closeEnd;
224
+ }
225
+ continue;
226
+ }
227
+ stack.push(els.length - 1);
228
+ continue;
229
+ }
230
+ i = lt + 1;
231
+ }
232
+ while (stack.length) { const idx = stack.pop(); els[idx].closeStart = els[idx].closeEnd = n; }
233
+ return { els, byTag };
234
+ }
235
+
236
+ /** The innermost element whose source range contains character `offset`, or null. */
237
+ export function elementAtOffset(scan, offset) {
238
+ let best = null;
239
+ for (const e of scan.els) {
240
+ if (e.openStart > offset) break; // els are in openStart order
241
+ if (offset < Math.max(e.closeEnd, e.openEnd)) best = e;
242
+ }
243
+ return best;
244
+ }
245
+
246
+ /**
247
+ * Case-insensitive plain-text occurrences of `query` in `src`, capped so a
248
+ * one-letter query on a huge document cannot stall the repaint. Falls back to
249
+ * case-sensitive matching for the rare locale-sensitive strings whose
250
+ * lowercase form changes length (e.g. 'İ'), where offsets would drift.
251
+ */
252
+ export function findMatches(src, query, cap = 5000) {
253
+ const out = [];
254
+ const raw = String(src == null ? '' : src);
255
+ const q0 = String(query == null ? '' : query);
256
+ if (!q0) return out;
257
+ let s = raw.toLowerCase();
258
+ let q = q0.toLowerCase();
259
+ if (s.length !== raw.length || q.length !== q0.length) { s = raw; q = q0; }
260
+ let i = 0;
261
+ while (out.length < cap) {
262
+ const at = s.indexOf(q, i);
263
+ if (at === -1) break;
264
+ out.push({ start: at, end: at + q.length });
265
+ i = at + q.length;
266
+ }
267
+ return out;
268
+ }
269
+
270
+ const MARK_CSS = 'background:rgba(245,158,11,0.28);border-radius:2px;color:inherit;padding:0;margin:0';
271
+ const MARK_CUR_CSS = 'background:rgba(245,158,11,0.6);outline:1.5px solid rgba(180,83,9,0.85);border-radius:2px;color:inherit;padding:0;margin:0';
272
+
273
+ /**
274
+ * Inserts <mark> around `ranges` (offsets into the *text* of one source line,
275
+ * `{ start, end, cur }`) into that line's already-highlighted html. Walks the
276
+ * html counting text positions -- an entity counts as the one source character
277
+ * it escapes, tags count as nothing -- and closes/reopens marks at tag
278
+ * boundaries so the highlighter's own spans stay properly nested.
279
+ */
280
+ export function markRanges(html, ranges) {
281
+ if (!ranges || !ranges.length || !html) return html;
282
+ const token = /(<[^>]*>)|(&[a-zA-Z][a-zA-Z0-9]*;|&#[0-9]+;|&#x[0-9a-fA-F]+;)|([\s\S])/g;
283
+ let out = '';
284
+ let pos = 0;
285
+ let open = null;
286
+ let m;
287
+ while ((m = token.exec(html))) {
288
+ if (m[1]) {
289
+ if (open) { out += '</mark>'; open = null; }
290
+ out += m[1];
291
+ continue;
292
+ }
293
+ let r = null;
294
+ for (const range of ranges) { if (pos >= range.start && pos < range.end) { r = range; break; } }
295
+ if (open && open !== r) { out += '</mark>'; open = null; }
296
+ if (r && !open) { out += '<mark style="' + (r.cur ? MARK_CUR_CSS : MARK_CSS) + '">'; open = r; }
297
+ out += m[0];
298
+ pos += 1;
299
+ }
300
+ if (open) out += '</mark>';
301
+ return out;
302
+ }
@@ -580,7 +580,7 @@ export class EditorCore {
580
580
  * dragging through uniformly-formatted text rebuilds nothing at all.
581
581
  */
582
582
  formatFingerprint(b) {
583
- let s = b.id + '|' + this.currentTag() + '|' + (b.props.size || 16) + '|' + (this.state.linkDraft ? 1 : 0) + '|';
583
+ let s = b.id + '|' + this.currentTag() + '|' + this.selSize(b) + '|' + (this.state.linkDraft ? 1 : 0) + '|';
584
584
  for (const cmd of ['bold', 'italic', 'underline', 'strikeThrough', 'superscript', 'subscript', 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull', 'insertUnorderedList', 'insertOrderedList']) {
585
585
  let on = false;
586
586
  try { on = document.queryCommandState(cmd); } catch { /* ignore */ }
@@ -622,6 +622,13 @@ export class EditorCore {
622
622
  };
623
623
 
624
624
  size(b, delta) {
625
+ // A non-collapsed selection inside a rich text block means the user is
626
+ // sizing a *run*, not the block -- every neighbouring control (bold,
627
+ // color, highlight) is selection-scoped, so a ± that rewrote the whole
628
+ // block's prop here read as broken. Only `text` can keep the resulting
629
+ // spans: a heading folds back through `textContent` (syncEdit), which
630
+ // would silently drop them, so it stays block-level.
631
+ if (b.type === 'text' && this.sizeSelection(b, delta)) return;
625
632
  // Uncommitted inline formatting is folded into props by `setProp` below
626
633
  // (`onFoldLiveEdit`), not here. This used to do its own fold, as a second
627
634
  // commit: that read `editEl.innerHTML` unconditionally, so a second click
@@ -639,6 +646,114 @@ export class EditorCore {
639
646
  this.setProp(b.id, 'size', Math.max(lo, Math.min(hi, cur + delta)));
640
647
  }
641
648
 
649
+ /** Nearest inline px font-size walking up from `node` to the edited block's wrapper -- null when no run declares one (the block prop then owns the size). */
650
+ inlineSizeAt(node) {
651
+ let n = node && node.nodeType === 1 ? node : (node ? node.parentElement : null);
652
+ while (n && n !== this.editEl) {
653
+ const m = /^([\d.]+)px$/.exec((n.style && n.style.fontSize) || '');
654
+ if (m) return parseFloat(m[1]);
655
+ n = n.parentElement;
656
+ }
657
+ return null;
658
+ }
659
+
660
+ /** What the ± readout should show: the inline size at the selection when the caret sits inside a sized run, else the block's own size. Also part of `formatFingerprint`, so moving the caret across differently-sized runs refreshes the toolbar. */
661
+ selSize(b) {
662
+ const live = this.find(this.state.doc, b.id).block || b;
663
+ const base = Number(live.props.size) || 16;
664
+ if (b.type !== 'text' || this.state.editing !== b.id || !this.editEl) return base;
665
+ const sel = this.getSelection();
666
+ const node = sel && sel.rangeCount && this.editEl.contains(sel.anchorNode)
667
+ ? sel.anchorNode
668
+ : (this.savedRange && this.editEl.contains(this.savedRange.startContainer) ? this.savedRange.startContainer : null);
669
+ const inline = node ? this.inlineSizeAt(node) : null;
670
+ return inline == null ? base : Math.round(inline);
671
+ }
672
+
673
+ /**
674
+ * Sizes just the selected run(s) of text by wrapping each selected text node
675
+ * in a `font-size` span (or restepping the span a previous click made --
676
+ * repeated ± must not nest one span per click). Wrapping happens at the text
677
+ * node, the innermost level, so the new size always outranks any inline size
678
+ * an imported ancestor carries. The change lives in the contenteditable like
679
+ * bold/italic do and folds into props through the same blur/commit path.
680
+ *
681
+ * Returns false when the click is not selection-scoped -- no live edit, a
682
+ * bare caret, or a selection covering the whole block. The last keeps
683
+ * select-all + ± behaving as the block-level master scale it always was
684
+ * (`syncRichContent` then *scales* mixed sizes instead of flattening them,
685
+ * and the saved `size` prop stays truthful).
686
+ */
687
+ sizeSelection(b, delta) {
688
+ const root = this.editEl;
689
+ if (!root || !root.isConnected || this.state.editing !== b.id) return false;
690
+ const sel = this.getSelection();
691
+ // Same fallback discipline as `exec`: the live selection wins when it is
692
+ // inside the edited block; `savedRange` covers focus stolen by a control.
693
+ let src = sel && sel.rangeCount && root.contains(sel.anchorNode) && root.contains(sel.focusNode) ? sel.getRangeAt(0) : null;
694
+ if (!src && this.savedRange && root.contains(this.savedRange.startContainer) && root.contains(this.savedRange.endContainer)) src = this.savedRange;
695
+ if (!src || src.collapsed) return false;
696
+ const range = src.cloneRange();
697
+ const total = root.textContent.length;
698
+ if (charOffset(root, range.startContainer, range.startOffset) === 0
699
+ && charOffset(root, range.endContainer, range.endOffset) === total) return false;
700
+
701
+ const [lo, hi] = SIZE_SPAN.text;
702
+ const live = this.find(this.state.doc, b.id).block || b;
703
+ const cur = this.inlineSizeAt(range.startContainer) || Number(live.props.size) || 16;
704
+ const next = Math.max(lo, Math.min(hi, Math.round(cur) + delta));
705
+
706
+ // Split the boundary text nodes so every text node intersecting the range
707
+ // is *fully* inside it; order matters when both ends share one node.
708
+ const endC = range.endContainer;
709
+ if (endC.nodeType === 3 && range.endOffset < endC.nodeValue.length) endC.splitText(range.endOffset);
710
+ const startC = range.startContainer;
711
+ if (startC.nodeType === 3 && range.startOffset > 0) {
712
+ const tail = startC.splitText(range.startOffset);
713
+ range.setStart(tail, 0);
714
+ if (endC === startC) range.setEnd(tail, tail.nodeValue.length);
715
+ }
716
+ const s = charOffset(root, range.startContainer, range.startOffset);
717
+ const e = charOffset(root, range.endContainer, range.endOffset);
718
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
719
+ const hits = [];
720
+ let pos = 0; let tn;
721
+ while ((tn = walker.nextNode())) {
722
+ const len = tn.nodeValue.length;
723
+ if (len && pos >= s && pos + len <= e) hits.push(tn);
724
+ pos += len;
725
+ if (pos >= e) break;
726
+ }
727
+ // Something non-text was selected (an image, say): still handled -- the
728
+ // click must not fall through and resize the whole block.
729
+ if (!hits.length) return true;
730
+ const wraps = hits.map((node) => {
731
+ const parent = node.parentElement;
732
+ if (parent && parent !== root && parent.tagName === 'SPAN' && parent.childNodes.length === 1) {
733
+ parent.style.fontSize = next + 'px';
734
+ return parent;
735
+ }
736
+ const span = document.createElement('span');
737
+ span.style.fontSize = next + 'px';
738
+ node.replaceWith(span);
739
+ span.appendChild(node);
740
+ return span;
741
+ });
742
+ // Reselect the runs so the next ± click steps from here, and cache the
743
+ // range the way `exec` does for controls that steal focus. Boundaries go
744
+ // *inside* the first/last wrap (each holds exactly one text node), so the
745
+ // selection anchor sits under the new span and `selSize` reads it for the
746
+ // toolbar readout.
747
+ const first = wraps[0].firstChild;
748
+ const last = wraps[wraps.length - 1].lastChild;
749
+ const r2 = document.createRange();
750
+ r2.setStart(first, 0);
751
+ r2.setEnd(last, last.nodeValue.length);
752
+ if (sel) { sel.removeAllRanges(); sel.addRange(r2); }
753
+ this.savedRange = r2.cloneRange();
754
+ return true;
755
+ }
756
+
642
757
  hasCountdown() { return this.state.doc.rows.some((r) => r.cols.some((c) => c.blocks.some((b) => b.type === 'countdown'))); }
643
758
 
644
759
  persist(doc, assets, chrome) {
@@ -114,12 +114,15 @@ export function decorateLogicTags(html) {
114
114
  const chip = (kind, expr, end) => {
115
115
  const color = kind === 'each' ? '#7c3aed' : '#0e7490';
116
116
  const word = end ? (kind === 'each' ? 'END LOOP' : 'END IF') : (kind === 'each' ? 'REPEAT EACH' : 'SHOW IF');
117
- return '<span style="display:inline-flex;align-items:center;gap:7px;box-sizing:border-box;border:1.5px dashed ' + color + ';border-radius:7px;background:' + color + '14;padding:4px 10px;margin:2px 0;color:' + color + ';font-family:ui-monospace,monospace;font-size:9.5px;font-weight:700;letter-spacing:0.12em;">'
117
+ // data-mc-deco marks decoration-only nodes so the code view's inspect
118
+ // link can skip them when counting elements (they exist only in the
119
+ // preview document, never in the source string being mapped).
120
+ return '<span data-mc-deco="" style="display:inline-flex;align-items:center;gap:7px;box-sizing:border-box;border:1.5px dashed ' + color + ';border-radius:7px;background:' + color + '14;padding:4px 10px;margin:2px 0;color:' + color + ';font-family:ui-monospace,monospace;font-size:9.5px;font-weight:700;letter-spacing:0.12em;">'
118
121
  + (end ? '⏶ ' : '⏷ ') + word
119
122
  + (expr ? ' <span style="font-weight:400;font-size:11px;letter-spacing:0;">{{ ' + esc(expr) + ' }}</span>' : '')
120
123
  + '</span>';
121
124
  };
122
- const bandRow = (kind, expr, end) => '<tr><td colspan="99" style="padding:2px 8px;">' + chip(kind, expr, end) + '</td></tr>';
125
+ const bandRow = (kind, expr, end) => '<tr><td colspan="99" data-mc-deco="" style="padding:2px 8px;">' + chip(kind, expr, end) + '</td></tr>';
123
126
  let s = String(html || '');
124
127
  // Between-row tags first (adjacent to a <tr> or after a </tr>), looped for
125
128
  // the same adjacency reason as import-html's foldLogicWrappers.
@@ -151,6 +151,14 @@ export const AR = {
151
151
  'code.applyNote':
152
152
  'يحلّل زر التطبيق كل صف جدول من المستوى الأعلى إلى صف لوحة يمكنك تحديده وإعادة ترتيبه وحذفه — الترميز المكتوب يدويًا يبقى سليمًا.',
153
153
  'code.meta': '{kb} كيلوبايت · {lines} أسطر',
154
+ 'code.format': 'تنسيق المصدر (المسافات البادئة فقط)',
155
+ 'code.wrap': 'التفاف النص',
156
+ 'code.find': 'بحث',
157
+ 'code.replace': 'استبدال',
158
+ 'code.replaceAll': 'استبدال الكل',
159
+ 'code.prevMatch': 'النتيجة السابقة',
160
+ 'code.nextMatch': 'النتيجة التالية',
161
+ 'code.goToLine': 'الانتقال إلى سطر',
154
162
  'toast.sourceReloaded': 'تمت إعادة تحميل المصدر من اللوحة',
155
163
  'toast.sourceAppliedOne': 'تم تطبيق المصدر — صف واحد على اللوحة',
156
164
  'toast.sourceAppliedMany': 'تم تطبيق المصدر — {rows} صفوف على اللوحة',
@@ -131,6 +131,14 @@ export const BG = {
131
131
  'code.applyNote':
132
132
  'Прилагането анализира всеки ред на таблица от най-високо ниво в ред на платното, който все още можете да избирате, преподреждате и изтривате — ръчно написаната маркировка преминава непроменена.',
133
133
  'code.meta': '{kb} KB · {lines} реда',
134
+ 'code.format': 'Форматиране на кода (само отстъпи)',
135
+ 'code.wrap': 'Пренасяне на редове',
136
+ 'code.find': 'Търсене',
137
+ 'code.replace': 'Замяна',
138
+ 'code.replaceAll': 'Замяна на всички',
139
+ 'code.prevMatch': 'Предишно съвпадение',
140
+ 'code.nextMatch': 'Следващо съвпадение',
141
+ 'code.goToLine': 'Към ред',
134
142
  'toast.sourceReloaded': 'Изходният код е презареден от платното',
135
143
  'toast.sourceAppliedOne': 'Изходният код е приложен — 1 ред на платното',
136
144
  'toast.sourceAppliedMany': 'Изходният код е приложен — {rows} реда на платното',
@@ -150,6 +150,14 @@ export const BN = {
150
150
  'code.applyNote':
151
151
  'প্রয়োগ করলে প্রতিটি টেবিল সারি একটি ক্যানভাস সারিতে পার্স হয় যা আপনি এখনও নির্বাচন, পুনর্বিন্যাস ও মুছতে পারবেন — হাতে-লেখা মার্কআপ অক্ষত থাকে।',
152
152
  'code.meta': '{kb} KB · {lines}টি লাইন',
153
+ 'code.format': 'সোর্স ফরম্যাট করুন (শুধু ইন্ডেন্টেশন)',
154
+ 'code.wrap': 'ওয়ার্ড র‍্যাপ',
155
+ 'code.find': 'খুঁজুন',
156
+ 'code.replace': 'প্রতিস্থাপন',
157
+ 'code.replaceAll': 'সব প্রতিস্থাপন',
158
+ 'code.prevMatch': 'আগের মিল',
159
+ 'code.nextMatch': 'পরের মিল',
160
+ 'code.goToLine': 'লাইনে যান',
153
161
  'toast.sourceReloaded': 'ক্যানভাস থেকে সোর্স পুনরায় লোড হয়েছে',
154
162
  'toast.sourceAppliedOne': 'সোর্স প্রয়োগ হয়েছে — ক্যানভাসে ১টি সারি',
155
163
  'toast.sourceAppliedMany': 'সোর্স প্রয়োগ হয়েছে — ক্যানভাসে {rows}টি সারি',
@@ -131,6 +131,14 @@ export const CA = {
131
131
  'code.applyNote':
132
132
  'Aplicar analitza cada fila de taula de nivell superior en una fila de llenç que encara podeu seleccionar, reordenar i eliminar — el marcatge escrit a mà sobreviu al procés.',
133
133
  'code.meta': '{kb} KB · {lines} línies',
134
+ 'code.format': 'Formata el codi font (només sagnat)',
135
+ 'code.wrap': 'Ajustament de línia',
136
+ 'code.find': 'Cerca',
137
+ 'code.replace': 'Substitueix',
138
+ 'code.replaceAll': 'Substitueix-ho tot',
139
+ 'code.prevMatch': 'Coincidència anterior',
140
+ 'code.nextMatch': 'Coincidència següent',
141
+ 'code.goToLine': 'Vés a la línia',
134
142
  'toast.sourceReloaded': 'Font recarregada des del llenç',
135
143
  'toast.sourceAppliedOne': 'Font aplicada — 1 fila al llenç',
136
144
  'toast.sourceAppliedMany': 'Font aplicada — {rows} files al llenç',
@@ -131,6 +131,14 @@ export const CS = {
131
131
  'code.applyNote':
132
132
  'Použití rozebere každý řádek tabulky nejvyšší úrovně do řádku plátna, který lze stále vybírat, přeuspořádávat a mazat — ručně psaný kód proces přežije beze změny.',
133
133
  'code.meta': '{kb} KB · {lines} řádků',
134
+ 'code.format': 'Formátovat zdroj (pouze odsazení)',
135
+ 'code.wrap': 'Zalamování řádků',
136
+ 'code.find': 'Najít',
137
+ 'code.replace': 'Nahradit',
138
+ 'code.replaceAll': 'Nahradit vše',
139
+ 'code.prevMatch': 'Předchozí shoda',
140
+ 'code.nextMatch': 'Další shoda',
141
+ 'code.goToLine': 'Přejít na řádek',
134
142
  'toast.sourceReloaded': 'Zdroj znovu načten z plátna',
135
143
  'toast.sourceAppliedOne': 'Zdroj použit — 1 řádek na plátně',
136
144
  'toast.sourceAppliedMany': 'Zdroj použit — {rows} řádků na plátně',
@@ -131,6 +131,14 @@ export const DA = {
131
131
  'code.applyNote':
132
132
  'Anvend fortolker hver tabelrække på øverste niveau til en lærredsrække, du stadig kan markere, omarrangere og slette — håndskrevet kode overlever processen uændret.',
133
133
  'code.meta': '{kb} KB · {lines} linjer',
134
+ 'code.format': 'Formatér kilden (kun indrykning)',
135
+ 'code.wrap': 'Tekstombrydning',
136
+ 'code.find': 'Find',
137
+ 'code.replace': 'Erstat',
138
+ 'code.replaceAll': 'Erstat alle',
139
+ 'code.prevMatch': 'Forrige match',
140
+ 'code.nextMatch': 'Næste match',
141
+ 'code.goToLine': 'Gå til linje',
134
142
  'toast.sourceReloaded': 'Kilde genindlæst fra lærredet',
135
143
  'toast.sourceAppliedOne': 'Kilde anvendt — 1 række på lærredet',
136
144
  'toast.sourceAppliedMany': 'Kilde anvendt — {rows} rækker på lærredet',
@@ -131,6 +131,14 @@ export const DE_CH = {
131
131
  'code.applyNote':
132
132
  'Beim Anwenden wird jede oberste Tabellenzeile in eine Canvas-Zeile umgewandelt, die du weiterhin auswählen, neu anordnen und löschen kannst — handgeschriebenes Markup übersteht den Vorgang unverändert.',
133
133
  'code.meta': '{kb} KB · {lines} Zeilen',
134
+ 'code.format': 'Quelltext formatieren (nur Einrückung)',
135
+ 'code.wrap': 'Zeilenumbruch',
136
+ 'code.find': 'Suchen',
137
+ 'code.replace': 'Ersetzen',
138
+ 'code.replaceAll': 'Alle ersetzen',
139
+ 'code.prevMatch': 'Vorheriger Treffer',
140
+ 'code.nextMatch': 'Nächster Treffer',
141
+ 'code.goToLine': 'Gehe zu Zeile',
134
142
  'toast.sourceReloaded': 'Quelltext aus der Canvas neu geladen',
135
143
  'toast.sourceAppliedOne': 'Quelltext angewendet — 1 Zeile auf der Canvas',
136
144
  'toast.sourceAppliedMany': 'Quelltext angewendet — {rows} Zeilen auf der Canvas',
@@ -131,6 +131,14 @@ export const DE = {
131
131
  'code.applyNote':
132
132
  'Beim Anwenden wird jede oberste Tabellenzeile in eine Canvas-Zeile umgewandelt, die du weiterhin auswählen, neu anordnen und löschen kannst — handgeschriebenes Markup übersteht den Vorgang unverändert.',
133
133
  'code.meta': '{kb} KB · {lines} Zeilen',
134
+ 'code.format': 'Quelltext formatieren (nur Einrückung)',
135
+ 'code.wrap': 'Zeilenumbruch',
136
+ 'code.find': 'Suchen',
137
+ 'code.replace': 'Ersetzen',
138
+ 'code.replaceAll': 'Alle ersetzen',
139
+ 'code.prevMatch': 'Vorheriger Treffer',
140
+ 'code.nextMatch': 'Nächster Treffer',
141
+ 'code.goToLine': 'Gehe zu Zeile',
134
142
  'toast.sourceReloaded': 'Quelltext aus der Canvas neu geladen',
135
143
  'toast.sourceAppliedOne': 'Quelltext angewendet — 1 Zeile auf der Canvas',
136
144
  'toast.sourceAppliedMany': 'Quelltext angewendet — {rows} Zeilen auf der Canvas',
@@ -153,6 +153,14 @@ export const DZ = {
153
153
  'code.applyNote':
154
154
  'ལག་ལེན་འཐབ་པའི་སྐབས་ཐིག་ཁྲམ་ཐིག་ཁྲམ་རེ་རེ་སེལ་འཐུ, གོ་རིམ་བསྒྱུར, ད་རུང་བསུབ་བཏུབ་པའི་ཐིག་ཁྲམ་ཅིག་ལུ་བཤད་བཤག — ལག་གིས་བྲིས་པའི་མཱརཀ་཮཮པ་ཧེ་མ་བཟུམ་ལུས་འོང༌།',
155
155
  'code.meta': '{kb} KB · ཐིག་ {lines}',
156
+ 'code.format': 'རྩ་འཛིན་བཟོ་རྣམ་སྒྲིག་ནི (ནང་བསྐུམ་རྐྱངམ་གཅིག)',
157
+ 'code.wrap': 'ཚིག་ཐིག་བསྒྱིར་ནི',
158
+ 'code.find': 'འཚོལ་ཞིབ',
159
+ 'code.replace': 'ཚབ་བཙུགས',
160
+ 'code.replaceAll': 'ཆ་མཉམ་ཚབ་བཙུགས',
161
+ 'code.prevMatch': 'ཧེ་མའི་མཐུན་པ',
162
+ 'code.nextMatch': 'ཤུལ་མའི་མཐུན་པ',
163
+ 'code.goToLine': 'ཐིག་ལུ་འགྱོ',
156
164
  'toast.sourceReloaded': 'ཐིག་ཁྲམ་ནང་ལས་རྩ་འཛིན་སླར་འདྲེན་བྱུང',
157
165
  'toast.sourceAppliedOne': 'རྩ་འཛིན་ལག་ལེན་འཐབ་ཡོད — ཐིག་ཁྲམ་ནང་ཐིག་ ༡',
158
166
  'toast.sourceAppliedMany': 'རྩ་འཛིན་ལག་ལེན་འཐབ་ཡོད — ཐིག་ཁྲམ་ནང་ཐིག་ {rows}',
@@ -131,6 +131,14 @@ export const EL = {
131
131
  'code.applyNote':
132
132
  'Η εφαρμογή αναλύει κάθε γραμμή πίνακα ανώτατου επιπέδου σε γραμμή καμβά που μπορείτε ακόμη να επιλέξετε, να αναδιατάξετε και να διαγράψετε — ο χειρόγραφος κώδικας επιβιώνει αναλλοίωτος.',
133
133
  'code.meta': '{kb} KB · {lines} γραμμές',
134
+ 'code.format': 'Μορφοποίηση πηγαίου κώδικα (μόνο εσοχές)',
135
+ 'code.wrap': 'Αναδίπλωση λέξεων',
136
+ 'code.find': 'Εύρεση',
137
+ 'code.replace': 'Αντικατάσταση',
138
+ 'code.replaceAll': 'Αντικατάσταση όλων',
139
+ 'code.prevMatch': 'Προηγούμενη αντιστοιχία',
140
+ 'code.nextMatch': 'Επόμενη αντιστοιχία',
141
+ 'code.goToLine': 'Μετάβαση στη γραμμή',
134
142
  'toast.sourceReloaded': 'Η πηγή επαναφορτώθηκε από τον καμβά',
135
143
  'toast.sourceAppliedOne': 'Η πηγή εφαρμόστηκε — 1 γραμμή στον καμβά',
136
144
  'toast.sourceAppliedMany': 'Η πηγή εφαρμόστηκε — {rows} γραμμές στον καμβά',