@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.
- package/CHANGELOG.md +18 -0
- package/DOCS.md +8 -0
- package/dist/mailcraft-editor.bundle.js +68 -56
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -2
- package/src/core/code-tools.js +302 -0
- package/src/core/editor-core.js +116 -1
- package/src/core/export.js +5 -2
- package/src/core/i18n/ar.js +8 -0
- package/src/core/i18n/bg.js +8 -0
- package/src/core/i18n/bn.js +8 -0
- package/src/core/i18n/ca.js +8 -0
- package/src/core/i18n/cs.js +8 -0
- package/src/core/i18n/da.js +8 -0
- package/src/core/i18n/de-CH.js +8 -0
- package/src/core/i18n/de.js +8 -0
- package/src/core/i18n/dz.js +8 -0
- package/src/core/i18n/el.js +8 -0
- package/src/core/i18n/en.js +8 -0
- package/src/core/i18n/es.js +8 -0
- package/src/core/i18n/et.js +8 -0
- package/src/core/i18n/fi.js +8 -0
- package/src/core/i18n/fr.js +8 -0
- package/src/core/i18n/hr.js +8 -0
- package/src/core/i18n/hu.js +8 -0
- package/src/core/i18n/it.js +8 -0
- package/src/core/i18n/lt.js +8 -0
- package/src/core/i18n/lv.js +8 -0
- package/src/core/i18n/nb.js +8 -0
- package/src/core/i18n/nl.js +8 -0
- package/src/core/i18n/pl.js +8 -0
- package/src/core/i18n/pt.js +8 -0
- package/src/core/i18n/ro.js +8 -0
- package/src/core/i18n/ru.js +8 -0
- package/src/core/i18n/sk.js +8 -0
- package/src/core/i18n/sl.js +8 -0
- package/src/core/i18n/sv.js +8 -0
- package/src/core/i18n/tr.js +8 -0
- package/src/core/i18n/uk.js +8 -0
- package/src/core/icons.js +2 -0
- package/src/mailcraft-editor.js +468 -16
- package/src/render/block-body.js +7 -1
- 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.
|
|
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
|
+
}
|
package/src/core/editor-core.js
CHANGED
|
@@ -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
|
|
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) {
|
package/src/core/export.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/core/i18n/ar.js
CHANGED
|
@@ -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} صفوف على اللوحة',
|
package/src/core/i18n/bg.js
CHANGED
|
@@ -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} реда на платното',
|
package/src/core/i18n/bn.js
CHANGED
|
@@ -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}টি সারি',
|
package/src/core/i18n/ca.js
CHANGED
|
@@ -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ç',
|
package/src/core/i18n/cs.js
CHANGED
|
@@ -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ě',
|
package/src/core/i18n/da.js
CHANGED
|
@@ -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',
|
package/src/core/i18n/de-CH.js
CHANGED
|
@@ -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',
|
package/src/core/i18n/de.js
CHANGED
|
@@ -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',
|
package/src/core/i18n/dz.js
CHANGED
|
@@ -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}',
|
package/src/core/i18n/el.js
CHANGED
|
@@ -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} γραμμές στον καμβά',
|