mnfst 0.5.229 → 0.5.231
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/lib/manifest.appwrite.data.js +1 -1
- package/lib/manifest.css +20 -2
- package/lib/manifest.data.js +1 -1
- package/lib/manifest.edit.css +7 -1
- package/lib/manifest.edit.js +137 -16
- package/lib/manifest.integrity.json +7 -7
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.sw.js +1 -1
- package/lib/manifest.text.edit.css +13 -1
- package/lib/manifest.text.edit.js +251 -54
- package/lib/manifest.toasts.js +64 -28
- package/lib/manifest.tooltips.js +56 -40
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
One directive, two roles. Without a command modifier it marks the editable area
|
|
4
4
|
and binds its value: x-text-edit="post". With one, it turns any element into a
|
|
5
|
-
control for that area: <button x-text-edit.
|
|
5
|
+
control for that area: <button x-text-edit.strong>. Controls need no wrapper and
|
|
6
6
|
no shared parent — they resolve to an area by containment, then by focus, so a
|
|
7
7
|
toolbar can sit anywhere and two dialogs in one DOM never cross wires.
|
|
8
8
|
|
|
@@ -33,8 +33,24 @@
|
|
|
33
33
|
const MARKS = { STRONG: '**', B: '**', EM: '*', I: '*', S: '~~', DEL: '~~', CODE: '`' };
|
|
34
34
|
const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'A', 'IMG']);
|
|
35
35
|
|
|
36
|
+
/* ---- URL policy ----
|
|
37
|
+
One rule for every href and src: no script schemes, data: only as an image.
|
|
38
|
+
URL parsers skip controls and whitespace inside a scheme, so this does too. */
|
|
39
|
+
function unsafeUrl(url, image) {
|
|
40
|
+
const u = String(url == null ? '' : url).replace(/[\x00-\x20\x7f]/g, '');
|
|
41
|
+
const scheme = (u.match(/^([a-z][a-z0-9+.-]*):/i) || [])[1];
|
|
42
|
+
if (!scheme) return false;
|
|
43
|
+
const s = scheme.toLowerCase();
|
|
44
|
+
if (s === 'javascript' || s === 'vbscript') return true;
|
|
45
|
+
return s === 'data' && !(image && /^data:image\//i.test(u));
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
/* ---- HTML → Markdown ---- */
|
|
37
|
-
|
|
49
|
+
// < > and an entity-shaped & are escaped too, so typed markup stays text in any renderer.
|
|
50
|
+
const ENTITY = /&(?=#?[a-z0-9]+;)/gi;
|
|
51
|
+
const esc = (t) => t.replace(/([\\`*_[\]<>])/g, '\\$1').replace(ENTITY, '\\&');
|
|
52
|
+
// A destination ends at whitespace or an unbalanced paren; < > would open raw HTML.
|
|
53
|
+
const mdUrl = (u) => u.replace(/[\s()<>]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')).replace(ENTITY, '\\&');
|
|
38
54
|
|
|
39
55
|
function inline(node) {
|
|
40
56
|
let out = '';
|
|
@@ -43,8 +59,8 @@
|
|
|
43
59
|
if (n.nodeType !== 1) return;
|
|
44
60
|
const tag = n.tagName;
|
|
45
61
|
if (tag === 'BR') { out += ' \n'; return; }
|
|
46
|
-
if (tag === 'IMG') { const src = n.getAttribute('src') || ''; out += src ? `` : ''; return; }
|
|
47
|
-
if (tag === 'A') { const h = n.getAttribute('href') || ''; out += h ? `[${inline(n)}](${h})` : inline(n); return; }
|
|
62
|
+
if (tag === 'IMG') { const src = n.getAttribute('src') || ''; out += src && !unsafeUrl(src, true) ? `})` : ''; return; }
|
|
63
|
+
if (tag === 'A') { const h = n.getAttribute('href') || ''; out += h && !unsafeUrl(h) ? `[${inline(n)}](${mdUrl(h)})` : inline(n); return; }
|
|
48
64
|
const m = MARKS[tag];
|
|
49
65
|
if (!m) { out += inline(n); return; }
|
|
50
66
|
const body = tag === 'CODE' ? n.textContent : inline(n);
|
|
@@ -65,6 +81,7 @@
|
|
|
65
81
|
if (tag === 'PRE') return '```\n' + preText(node).replace(/\n+$/, '') + '\n```';
|
|
66
82
|
if (/^H[1-6]$/.test(tag)) return '#'.repeat(+tag[1]) + ' ' + inline(node).trim();
|
|
67
83
|
if (tag === 'BLOCKQUOTE') return blocks(node, depth).split('\n').map(l => ('> ' + l).trimEnd()).join('\n');
|
|
84
|
+
if (tag === 'TABLE') return tableLines(node).map(cells => cells.map(c => inline(c).trim()).join(CELL_SEP)).join('\n\n');
|
|
68
85
|
if (tag === 'UL' || tag === 'OL') {
|
|
69
86
|
const pad = ' '.repeat(depth);
|
|
70
87
|
return [...node.children].filter(li => li.tagName === 'LI').map((li, i) => {
|
|
@@ -95,8 +112,41 @@
|
|
|
95
112
|
|
|
96
113
|
const toMarkdown = (root) => blocks(root, 0).replace(/\n{3,}/g, '\n\n').trim();
|
|
97
114
|
|
|
115
|
+
/* ---- Tables in markdown ----
|
|
116
|
+
Markdown here has no tables (they need .html), so a table becomes one line per
|
|
117
|
+
row, cells joined by " · " — readable, and unlike pipes or tabs not syntax
|
|
118
|
+
that re-parses as something else. */
|
|
119
|
+
const CELL_SEP = ' · ';
|
|
120
|
+
const tableLines = (table) => [...table.rows].map(r => [...r.cells])
|
|
121
|
+
.filter(cells => cells.some(c => c.textContent.trim() || c.querySelector('img')));
|
|
122
|
+
|
|
123
|
+
function flattenTables(html, blocks) {
|
|
124
|
+
const t = document.createElement('template');
|
|
125
|
+
t.innerHTML = String(html == null ? '' : html);
|
|
126
|
+
// Innermost first, so a nested table is already text when its host is read.
|
|
127
|
+
[...t.content.querySelectorAll('table')].reverse().forEach(table => {
|
|
128
|
+
const frag = document.createDocumentFragment();
|
|
129
|
+
tableLines(table).forEach((cells, i) => {
|
|
130
|
+
const line = blocks ? document.createElement('p') : frag;
|
|
131
|
+
if (!blocks && i) frag.appendChild(document.createElement('br'));
|
|
132
|
+
cells.forEach((cell, j) => {
|
|
133
|
+
cell.querySelectorAll(BLOCK.join(',')).forEach(b => b.replaceWith(...b.childNodes, ' '));
|
|
134
|
+
cell.normalize();
|
|
135
|
+
const a = cell.firstChild, z = cell.lastChild;
|
|
136
|
+
if (a && a.nodeType === 3) a.nodeValue = a.nodeValue.replace(/^\s+/, '');
|
|
137
|
+
if (z && z.nodeType === 3) z.nodeValue = z.nodeValue.replace(/\s+$/, '');
|
|
138
|
+
if (j) line.appendChild(document.createTextNode(CELL_SEP));
|
|
139
|
+
while (cell.firstChild) line.appendChild(cell.firstChild);
|
|
140
|
+
});
|
|
141
|
+
if (blocks) frag.appendChild(line);
|
|
142
|
+
});
|
|
143
|
+
table.replaceWith(frag);
|
|
144
|
+
});
|
|
145
|
+
return t.innerHTML;
|
|
146
|
+
}
|
|
147
|
+
|
|
98
148
|
/* ---- Markdown → HTML ---- */
|
|
99
|
-
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
149
|
+
const escHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
100
150
|
|
|
101
151
|
// Escapes are masked out before the mark passes and restored last, so a literal
|
|
102
152
|
// \* never gets read as emphasis and never comes back double-escaped.
|
|
@@ -104,13 +154,13 @@
|
|
|
104
154
|
function inlineHtml(s) {
|
|
105
155
|
const held = [];
|
|
106
156
|
return escHtml(s)
|
|
107
|
-
.replace(/\\([\\`*_~[\]])/g, (_, c) => HOLD + (held.push(c) - 1) + HOLD)
|
|
157
|
+
.replace(/\\(<|>|&|[\\`*_~[\]])/g, (_, c) => HOLD + (held.push(c) - 1) + HOLD)
|
|
108
158
|
.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`)
|
|
109
159
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
110
160
|
.replace(/~~([^~]+)~~/g, '<s>$1</s>')
|
|
111
161
|
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
|
|
112
|
-
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, src) =>
|
|
113
|
-
.replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_, t, h) =>
|
|
162
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, src) => unsafeUrl(src, true) ? '' : `<img src="${src}" alt="${alt}">`)
|
|
163
|
+
.replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_, t, h) => unsafeUrl(h) ? t : `<a href="${h}">${t}</a>`)
|
|
114
164
|
.replace(/ {2}\n/g, '<br>')
|
|
115
165
|
.replace(new RegExp(HOLD + '(\\d+)' + HOLD, 'g'), (_, i) => held[+i]);
|
|
116
166
|
}
|
|
@@ -189,8 +239,8 @@
|
|
|
189
239
|
if (!ok.has(c.tagName)) return c.replaceWith(...c.childNodes);
|
|
190
240
|
[...c.attributes].forEach(a => {
|
|
191
241
|
const n = a.name.toLowerCase();
|
|
192
|
-
if (c.tagName === 'A' && n === 'href' &&
|
|
193
|
-
if (c.tagName === 'IMG' && (n === '
|
|
242
|
+
if (c.tagName === 'A' && n === 'href' && !unsafeUrl(a.value)) return;
|
|
243
|
+
if (c.tagName === 'IMG' && (n === 'alt' || (n === 'src' && !unsafeUrl(a.value, true)))) return;
|
|
194
244
|
if (c.tagName === 'INPUT' && (n === 'type' || n === 'checked')) return;
|
|
195
245
|
// Without these a merged cell comes apart on the next save
|
|
196
246
|
if (/^(TD|TH)$/.test(c.tagName) && (n === 'colspan' || n === 'rowspan') && /^[1-9]\d{0,2}$/.test(a.value)) return;
|
|
@@ -202,6 +252,9 @@
|
|
|
202
252
|
return t.innerHTML;
|
|
203
253
|
}
|
|
204
254
|
|
|
255
|
+
// Markdown → HTML bound for the DOM: always through the sanitizer.
|
|
256
|
+
const markdownHtml = (md) => sanitize(fromMarkdown(md), true);
|
|
257
|
+
|
|
205
258
|
/* ---- Selection primitives ---- */
|
|
206
259
|
const sel = () => window.getSelection();
|
|
207
260
|
const range = () => { const s = sel(); return s && s.rangeCount ? s.getRangeAt(0) : null; };
|
|
@@ -1037,7 +1090,7 @@
|
|
|
1037
1090
|
// does what the writer meant. Anything else (spaces, no dot, no scheme) is
|
|
1038
1091
|
// not applied; the field re-syncs to the real href, showing it didn't take.
|
|
1039
1092
|
function normalizeUrl(url) {
|
|
1040
|
-
if (/\s/.test(url) ||
|
|
1093
|
+
if (/\s/.test(url) || unsafeUrl(url)) return null;
|
|
1041
1094
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || /^(mailto|tel|sms):/i.test(url) || /^[/#?.]/.test(url)) return url;
|
|
1042
1095
|
if (/^[\w-]+(\.[\w-]+)+(:\d+)?(\/|$|\?|#)/.test(url)) return 'https://' + url;
|
|
1043
1096
|
return null;
|
|
@@ -1051,13 +1104,38 @@
|
|
|
1051
1104
|
if (existing) { existing.setAttribute('href', url); return; }
|
|
1052
1105
|
const made = wrapInline('a');
|
|
1053
1106
|
if (made.length) made.forEach(a => a.setAttribute('href', url));
|
|
1054
|
-
else insertHTML(`<a href="${url}">${url}</a>`); // nothing selected — drop the URL in as its own link
|
|
1107
|
+
else insertHTML(`<a href="${escHtml(url)}">${escHtml(url)}</a>`); // nothing selected — drop the URL in as its own link
|
|
1055
1108
|
}
|
|
1056
1109
|
const linkHref = () => { const a = ancestor('a'); return a ? a.getAttribute('href') || '' : ''; };
|
|
1057
1110
|
|
|
1058
1111
|
/* ---- Insertions ---- */
|
|
1059
1112
|
function insertHTML(html) { document.execCommand('insertHTML', false, html); }
|
|
1060
|
-
|
|
1113
|
+
|
|
1114
|
+
// .plain: the engine's own plain-text editing where it has one; key and input guards otherwise.
|
|
1115
|
+
const PLAINTEXT_ONLY = (() => {
|
|
1116
|
+
try { const d = document.createElement('div'); d.contentEditable = 'plaintext-only'; return d.contentEditable === 'plaintext-only'; } catch { return false; }
|
|
1117
|
+
})();
|
|
1118
|
+
|
|
1119
|
+
// Lines kept as <br>, inserted by hand: insertHTML collapses newlines, and
|
|
1120
|
+
// engines disagree on what it does in a plaintext-only host at all.
|
|
1121
|
+
function insertPlain(area, text) {
|
|
1122
|
+
const r = range(); if (!r || !area.contains(r.startContainer)) return;
|
|
1123
|
+
const frag = document.createDocumentFragment();
|
|
1124
|
+
String(text || '').replace(/\r\n?/g, '\n').split('\n').forEach((line, i) => {
|
|
1125
|
+
if (i) frag.appendChild(document.createElement('br'));
|
|
1126
|
+
if (line) frag.appendChild(document.createTextNode(line));
|
|
1127
|
+
});
|
|
1128
|
+
const end = frag.lastChild; if (!end) return;
|
|
1129
|
+
record(area);
|
|
1130
|
+
r.deleteContents();
|
|
1131
|
+
r.insertNode(frag);
|
|
1132
|
+
const s = sel(), nr = document.createRange();
|
|
1133
|
+
nr.setStartAfter(end); nr.collapse(true);
|
|
1134
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
1135
|
+
record(area);
|
|
1136
|
+
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1137
|
+
}
|
|
1138
|
+
const insertImage = (src, alt) => { if (src && !unsafeUrl(src, true)) insertHTML(`<img src="${escHtml(src)}" alt="${escHtml(alt || '')}">`); };
|
|
1061
1139
|
|
|
1062
1140
|
function insertTable(spec) {
|
|
1063
1141
|
// A table dropped at a caret that is already in a table would nest a grid
|
|
@@ -1176,8 +1254,9 @@
|
|
|
1176
1254
|
leading: { page: 1, block: 1, run: (v) => styleBlock('lineHeight', v), active: () => false, reflect: () => readBlockStyle('lineHeight') },
|
|
1177
1255
|
|
|
1178
1256
|
clear: { md: 1, run: clearFormatting, active: () => false },
|
|
1179
|
-
|
|
1180
|
-
|
|
1257
|
+
// A burst still waiting to be recorded is undoable, and leaves nothing to redo.
|
|
1258
|
+
undo: { md: 1, history: 1, run: (v, area) => step(area, -1), active: () => false, enabled: (a) => a._history.at > 0 || !!a._history.timer },
|
|
1259
|
+
redo: { md: 1, history: 1, run: (v, area) => step(area, 1), active: () => false, enabled: (a) => !a._history.timer && a._history.at < a._history.stack.length - 1 },
|
|
1181
1260
|
block: { md: 1, block: 1, run: (v) => setBlock(String(v || 'p').toLowerCase()), active: () => false, reflect: () => (blockTag() || 'P').toLowerCase() }
|
|
1182
1261
|
});
|
|
1183
1262
|
|
|
@@ -1247,6 +1326,29 @@
|
|
|
1247
1326
|
|
|
1248
1327
|
const MODES = new Set(['html', 'plain', 'minimal', 'literal', 'autofocus']);
|
|
1249
1328
|
|
|
1329
|
+
// Nearest command for an unknown modifier, for the console warning only.
|
|
1330
|
+
const HINTS = {
|
|
1331
|
+
bold: 'strong', italic: 'em', underline: 'u', strike: 's', strikethrough: 's', link: 'a', quote: 'blockquote',
|
|
1332
|
+
codeblock: 'pre', bullet: 'ul', bullets: 'ul', numbered: 'ol', heading: 'h2', paragraph: 'p', image: 'img',
|
|
1333
|
+
rule: 'hr', highlight: 'mark', task: 'checklist', tasks: 'checklist', subscript: 'sub', superscript: 'sup'
|
|
1334
|
+
};
|
|
1335
|
+
function suggest(m) {
|
|
1336
|
+
if (!m) return ' — name a command, e.g. .strong';
|
|
1337
|
+
const dist = (a, b) => {
|
|
1338
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i]);
|
|
1339
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
1340
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++)
|
|
1341
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1342
|
+
return d[a.length][b.length];
|
|
1343
|
+
};
|
|
1344
|
+
let hit = HINTS[m.toLowerCase()];
|
|
1345
|
+
if (!hit) {
|
|
1346
|
+
const [best] = Object.keys(COMMANDS).map(c => [c, dist(m.toLowerCase(), c)]).sort((x, y) => x[1] - y[1]);
|
|
1347
|
+
if (best && best[1] <= Math.min(2, m.length - 1)) hit = best[0];
|
|
1348
|
+
}
|
|
1349
|
+
return hit ? ` — did you mean .${hit}?` : '';
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1250
1352
|
/* ---- Autoformat ----
|
|
1251
1353
|
A line converts when it is finished, not while it is being typed: press Enter
|
|
1252
1354
|
and the line you are leaving becomes what its markdown described. Converting
|
|
@@ -1276,7 +1378,6 @@
|
|
|
1276
1378
|
{ re: /!\[([^\]]*)\]\(([^)\s]+)\)/, tag: 'img', alt: 1, src: 2 },
|
|
1277
1379
|
{ re: /\[([^\]]+)\]\(([^)\s]+)\)/, tag: 'a', href: 2 }
|
|
1278
1380
|
];
|
|
1279
|
-
const unsafe = (url) => /^\s*javascript:/i.test(url || '');
|
|
1280
1381
|
|
|
1281
1382
|
function retagBlock(b, tag) {
|
|
1282
1383
|
const n = document.createElement(tag);
|
|
@@ -1316,12 +1417,12 @@
|
|
|
1316
1417
|
function inlineEl(rule, m) {
|
|
1317
1418
|
const el = document.createElement(rule.tag);
|
|
1318
1419
|
if (rule.tag === 'img') {
|
|
1319
|
-
if (
|
|
1420
|
+
if (unsafeUrl(m[rule.src], true)) return null;
|
|
1320
1421
|
el.setAttribute('src', m[rule.src]); el.setAttribute('alt', m[rule.alt] || '');
|
|
1321
1422
|
return el;
|
|
1322
1423
|
}
|
|
1323
1424
|
el.textContent = m[rule.content || 1];
|
|
1324
|
-
if (rule.href) { if (
|
|
1425
|
+
if (rule.href) { if (unsafeUrl(m[rule.href])) return null; el.setAttribute('href', m[rule.href]); }
|
|
1325
1426
|
return el;
|
|
1326
1427
|
}
|
|
1327
1428
|
|
|
@@ -1374,10 +1475,32 @@
|
|
|
1374
1475
|
const s = sel(), nr = document.createRange();
|
|
1375
1476
|
nr.setStart(tail, 0); nr.collapse(true);
|
|
1376
1477
|
s.removeAllRanges(); s.addRange(nr);
|
|
1478
|
+
area._exit = el.tagName === 'IMG' ? null : el;
|
|
1377
1479
|
record(area);
|
|
1378
1480
|
return true;
|
|
1379
1481
|
}
|
|
1380
1482
|
|
|
1483
|
+
// Engines normalize a caret at a mark's edge back inside it, so the next text
|
|
1484
|
+
// typed after a conversion is moved out: the mark ends where its markers did.
|
|
1485
|
+
function leaveMark(area, typed) {
|
|
1486
|
+
const el = area._exit; area._exit = null;
|
|
1487
|
+
const r = range();
|
|
1488
|
+
if (!el || !el.isConnected || !r || !r.collapsed) return false;
|
|
1489
|
+
const node = r.startContainer;
|
|
1490
|
+
if (node.nodeType !== 3 || !el.contains(node) || r.startOffset !== node.length || !node.nodeValue.endsWith(typed)) return false;
|
|
1491
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
1492
|
+
let last = null; for (let n = walker.nextNode(); n; n = walker.nextNode()) last = n;
|
|
1493
|
+
if (last !== node || node.length === typed.length) return false;
|
|
1494
|
+
node.deleteData(node.length - typed.length, typed.length);
|
|
1495
|
+
let tail = el.nextSibling;
|
|
1496
|
+
if (!tail || tail.nodeType !== 3) { tail = document.createTextNode(''); el.after(tail); }
|
|
1497
|
+
tail.insertData(0, typed);
|
|
1498
|
+
const s = sel(), nr = document.createRange();
|
|
1499
|
+
nr.setStart(tail, typed.length); nr.collapse(true);
|
|
1500
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
1501
|
+
return true;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1381
1504
|
function convertInline(root) {
|
|
1382
1505
|
const nodes = [];
|
|
1383
1506
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
@@ -1617,8 +1740,9 @@
|
|
|
1617
1740
|
area.innerHTML = h.stack[next].html;
|
|
1618
1741
|
area.focus();
|
|
1619
1742
|
caretIn(area, h.stack[next].caret);
|
|
1620
|
-
|
|
1743
|
+
saveRange(area);
|
|
1621
1744
|
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1745
|
+
h.restoring = false; // after the event, so it records nothing
|
|
1622
1746
|
}
|
|
1623
1747
|
|
|
1624
1748
|
/* ---- Registry + resolution ----
|
|
@@ -1680,9 +1804,10 @@
|
|
|
1680
1804
|
return !!control && resolve(control) === area;
|
|
1681
1805
|
}
|
|
1682
1806
|
|
|
1807
|
+
const TEXTLESS = /^(checkbox|radio|button|submit|reset|range|color|file|image|hidden)$/i;
|
|
1683
1808
|
const editableElsewhere = (node) => {
|
|
1684
1809
|
const el = elementAt(node);
|
|
1685
|
-
return !!el && (el.isContentEditable ||
|
|
1810
|
+
return !!el && (el.isContentEditable || el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && !TEXTLESS.test(el.type)));
|
|
1686
1811
|
};
|
|
1687
1812
|
|
|
1688
1813
|
function unambiguous(found) {
|
|
@@ -1700,6 +1825,8 @@
|
|
|
1700
1825
|
if (r && area.contains(r.startContainer)) area._range = r.cloneRange();
|
|
1701
1826
|
}
|
|
1702
1827
|
|
|
1828
|
+
const forget = (keep) => areas.forEach(a => { if (!keep || !a.contains(keep)) a._range = null; });
|
|
1829
|
+
|
|
1703
1830
|
function restoreRange(area) {
|
|
1704
1831
|
const r = range();
|
|
1705
1832
|
if (r && area.contains(r.startContainer)) return;
|
|
@@ -1715,7 +1842,7 @@
|
|
|
1715
1842
|
// write something the next save would drop, it reports itself unavailable.
|
|
1716
1843
|
function allows(area, id, ctrl) {
|
|
1717
1844
|
const spec = COMMANDS[id], cfg = area && area._te;
|
|
1718
|
-
if (!spec || !cfg
|
|
1845
|
+
if (!spec || !cfg) return false;
|
|
1719
1846
|
const page = !!(ctrl && ctrl.page && spec.page);
|
|
1720
1847
|
// The writer's caret is visibly in ANOTHER editable element (a plain
|
|
1721
1848
|
// x-edit leaf, an input, someone else's contenteditable): acting now
|
|
@@ -1724,9 +1851,13 @@
|
|
|
1724
1851
|
// commands write variables, never the selection — they stay live.
|
|
1725
1852
|
const live = range();
|
|
1726
1853
|
if (!page && live && !area.contains(live.startContainer) && editableElsewhere(live.startContainer)) return false;
|
|
1854
|
+
if (spec.history) return spec.enabled(area); // every mode, .plain included
|
|
1855
|
+
if (cfg.mode === 'plain') return false;
|
|
1727
1856
|
if (cfg.minimal && spec.block && !page) return false;
|
|
1728
1857
|
if (cfg.mode !== 'html' && !spec.md && !page) return false;
|
|
1729
1858
|
if (page) return true; // writes to the area, needs no caret
|
|
1859
|
+
// Never focused, or released to another field: no caret to act on.
|
|
1860
|
+
if (!area._range || !area.contains(area._range.startContainer)) return false;
|
|
1730
1861
|
// A wrap has nothing to wrap without a selection, and a control that is lit
|
|
1731
1862
|
// but does nothing reads as broken — so say so rather than no-op.
|
|
1732
1863
|
if (spec.needsSelection && !hasSelection()) return false;
|
|
@@ -1767,7 +1898,7 @@
|
|
|
1767
1898
|
normalizeLists(area);
|
|
1768
1899
|
if (!spec.history) record(area);
|
|
1769
1900
|
saveRange(area);
|
|
1770
|
-
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1901
|
+
if (!spec.history) area.dispatchEvent(new Event('input', { bubbles: true })); // step fires its own
|
|
1771
1902
|
sync();
|
|
1772
1903
|
}
|
|
1773
1904
|
|
|
@@ -1802,8 +1933,14 @@
|
|
|
1802
1933
|
|
|
1803
1934
|
Alpine.directive('text-edit', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
|
|
1804
1935
|
const id = modifiers.find(m => COMMANDS[m]);
|
|
1805
|
-
|
|
1806
|
-
|
|
1936
|
+
if (id) return control(el, id, modifiers, expression, evaluate, cleanup);
|
|
1937
|
+
// A control's tag never becomes a textbox; an unknown command is reported instead.
|
|
1938
|
+
if (/^(BUTTON|SELECT|INPUT|TEXTAREA|A)$/.test(el.tagName)) {
|
|
1939
|
+
const m = modifiers.find(x => !MODES.has(x) && x !== 'page');
|
|
1940
|
+
console.warn(`[Manifest Text Edit] unknown command${m ? ` .${m}` : ''} on <${el.tagName.toLowerCase()}>${suggest(m)}`);
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
return area(el, modifiers, expression, { effect, evaluateLater, cleanup });
|
|
1807
1944
|
});
|
|
1808
1945
|
|
|
1809
1946
|
// x-text-edit-menu — the turnkey selection bubble: put commands inside,
|
|
@@ -1838,7 +1975,7 @@
|
|
|
1838
1975
|
if (lastFocused && lastFocused !== a) release(lastFocused); // the one being left owes a null
|
|
1839
1976
|
saveRange(a); lastFocused = a; report(a);
|
|
1840
1977
|
}
|
|
1841
|
-
else if (
|
|
1978
|
+
else if (editableElsewhere(r.startContainer)) { forget(); if (lastFocused) release(); }
|
|
1842
1979
|
sync();
|
|
1843
1980
|
});
|
|
1844
1981
|
|
|
@@ -1850,9 +1987,13 @@
|
|
|
1850
1987
|
document.addEventListener('focusin', (e) => {
|
|
1851
1988
|
const t = e.target;
|
|
1852
1989
|
if (!t || !t.closest) return;
|
|
1853
|
-
if (t.closest('[data-text-edit
|
|
1854
|
-
|
|
1855
|
-
|
|
1990
|
+
if (t.closest('[data-text-edit-control]')) return;
|
|
1991
|
+
// Writing somewhere else: no area's old caret may be restored by a later click.
|
|
1992
|
+
const elsewhere = editableElsewhere(t);
|
|
1993
|
+
if (elsewhere) forget(t);
|
|
1994
|
+
if (t.closest('[data-text-edit]')) return;
|
|
1995
|
+
if (!lastFocused && !elsewhere) return;
|
|
1996
|
+
if (lastFocused) release();
|
|
1856
1997
|
sync();
|
|
1857
1998
|
}, true);
|
|
1858
1999
|
}
|
|
@@ -1881,27 +2022,31 @@
|
|
|
1881
2022
|
selectedCells().forEach(c => c.setAttribute('data-text-edit-cell', ''));
|
|
1882
2023
|
}
|
|
1883
2024
|
|
|
1884
|
-
function
|
|
1885
|
-
|
|
1886
|
-
const live = sel();
|
|
1887
|
-
if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
|
|
1888
|
-
if (area.querySelector('table')) paintCells(area);
|
|
1889
|
-
const shape = selectionOf(area);
|
|
1890
|
-
area._selection = shape;
|
|
2025
|
+
function place(area, shape) {
|
|
2026
|
+
if (!shape || shape.collapsed) return;
|
|
1891
2027
|
const px = (n) => Math.round(n) + 'px';
|
|
1892
2028
|
// Mirrored onto the root as well as the area. A menu anchored to the
|
|
1893
2029
|
// selection is rarely a descendant of the editor — and once it is a popover
|
|
1894
2030
|
// it is in the top layer, where nothing inherits from the page at all — so
|
|
1895
2031
|
// custom properties on the area alone are unreachable from where they are
|
|
1896
2032
|
// needed. On the root they are readable from anywhere.
|
|
1897
|
-
|
|
1898
|
-
if (shape && !shape.collapsed) targets.forEach(t => {
|
|
2033
|
+
[area, document.documentElement].forEach(t => {
|
|
1899
2034
|
t.style.setProperty('--text-edit-selection-x', px(shape.x));
|
|
1900
2035
|
t.style.setProperty('--text-edit-selection-y', px(shape.y));
|
|
1901
2036
|
t.style.setProperty('--text-edit-selection-width', px(shape.width));
|
|
1902
2037
|
t.style.setProperty('--text-edit-selection-height', px(shape.height));
|
|
1903
2038
|
t.style.setProperty('--text-edit-selection-center', px(shape.x + shape.width / 2));
|
|
1904
2039
|
});
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function report(area) {
|
|
2043
|
+
// A caret placed inside one cell ends any block selection.
|
|
2044
|
+
const live = sel();
|
|
2045
|
+
if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
|
|
2046
|
+
if (area.querySelector('table')) paintCells(area);
|
|
2047
|
+
const shape = selectionOf(area);
|
|
2048
|
+
area._selection = shape;
|
|
2049
|
+
place(area, shape);
|
|
1905
2050
|
area.toggleAttribute('data-text-edit-selected', !!shape && !shape.collapsed);
|
|
1906
2051
|
document.documentElement.toggleAttribute('data-text-edit-selected', !!shape && !shape.collapsed);
|
|
1907
2052
|
area.dispatchEvent(new CustomEvent('text-edit:selection', { bubbles: true, detail: shape }));
|
|
@@ -1913,17 +2058,41 @@
|
|
|
1913
2058
|
function selectionMenu(el, cleanup) {
|
|
1914
2059
|
if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
|
|
1915
2060
|
el.setAttribute('data-text-edit-menu', '');
|
|
2061
|
+
// Follow the selection through scrolling and resizing while shown.
|
|
2062
|
+
let shown = null, frame = 0;
|
|
2063
|
+
const follow = () => {
|
|
2064
|
+
if (frame) return;
|
|
2065
|
+
frame = requestAnimationFrame(() => { frame = 0; if (shown && shown.isConnected) place(shown, selectionOf(shown)); });
|
|
2066
|
+
};
|
|
2067
|
+
const track = (area) => {
|
|
2068
|
+
if (!shown) {
|
|
2069
|
+
window.addEventListener('scroll', follow, { capture: true, passive: true });
|
|
2070
|
+
window.addEventListener('resize', follow, { passive: true });
|
|
2071
|
+
}
|
|
2072
|
+
shown = area;
|
|
2073
|
+
};
|
|
2074
|
+
const untrack = () => {
|
|
2075
|
+
if (!shown) return;
|
|
2076
|
+
shown = null;
|
|
2077
|
+
window.removeEventListener('scroll', follow, { capture: true });
|
|
2078
|
+
window.removeEventListener('resize', follow);
|
|
2079
|
+
};
|
|
1916
2080
|
const onSel = (e) => {
|
|
1917
2081
|
const area = elementAt(e.target) && elementAt(e.target).closest('[data-text-edit]');
|
|
1918
2082
|
if (!area || resolve(el) !== area) return;
|
|
1919
2083
|
const want = !!e.detail && !e.detail.collapsed;
|
|
2084
|
+
if (want) track(area); else untrack();
|
|
1920
2085
|
try {
|
|
1921
2086
|
if (want && !el.matches(':popover-open')) el.showPopover();
|
|
1922
2087
|
if (!want && el.matches(':popover-open')) el.hidePopover();
|
|
1923
2088
|
} catch { }
|
|
1924
2089
|
};
|
|
1925
2090
|
document.addEventListener('text-edit:selection', onSel);
|
|
1926
|
-
cleanup(() => {
|
|
2091
|
+
cleanup(() => {
|
|
2092
|
+
document.removeEventListener('text-edit:selection', onSel);
|
|
2093
|
+
untrack(); if (frame) cancelAnimationFrame(frame);
|
|
2094
|
+
try { el.hidePopover(); } catch { }
|
|
2095
|
+
});
|
|
1927
2096
|
}
|
|
1928
2097
|
|
|
1929
2098
|
function control(el, id, modifiers, expression, evaluate, cleanup) {
|
|
@@ -1940,6 +2109,10 @@
|
|
|
1940
2109
|
controls.add(el);
|
|
1941
2110
|
|
|
1942
2111
|
const valued = 'value' in el && el.tagName !== 'BUTTON';
|
|
2112
|
+
// Never take the caret; a disabled control leaves focus where it is as well.
|
|
2113
|
+
const disabled = () => el.getAttribute('aria-disabled') === 'true';
|
|
2114
|
+
el.addEventListener('mousedown', e => { if (!valued || disabled()) e.preventDefault(); });
|
|
2115
|
+
if (valued) el.addEventListener('click', e => { if (disabled()) e.preventDefault(); });
|
|
1943
2116
|
if (valued) {
|
|
1944
2117
|
const fire = () => { const a = resolve(el); if (a) run(a, id, el.value, el._te); };
|
|
1945
2118
|
if (el.type === 'color') {
|
|
@@ -1994,14 +2167,14 @@
|
|
|
1994
2167
|
function area(el, modifiers, expression, { effect, evaluateLater, cleanup }) {
|
|
1995
2168
|
const mode = modifiers.includes('html') ? 'html' : modifiers.includes('plain') ? 'plain' : 'markdown';
|
|
1996
2169
|
const minimal = modifiers.includes('minimal');
|
|
1997
|
-
modifiers.filter(m => !MODES.has(m)).forEach(m => console.warn(`[Manifest Text Edit] unknown modifier: .${m}`));
|
|
2170
|
+
modifiers.filter(m => !MODES.has(m)).forEach(m => console.warn(`[Manifest Text Edit] unknown modifier: .${m}${suggest(m)}`));
|
|
1998
2171
|
|
|
1999
2172
|
el.setAttribute('data-text-edit', mode);
|
|
2000
2173
|
// Tells anything composing with this plugin — x-edit, chiefly — whether the
|
|
2001
2174
|
// value already has an owner, or whether the surrounding editor should
|
|
2002
2175
|
// capture the content itself.
|
|
2003
2176
|
el.toggleAttribute('data-text-edit-bound', !!expression);
|
|
2004
|
-
el.setAttribute('contenteditable', 'true');
|
|
2177
|
+
el.setAttribute('contenteditable', mode === 'plain' && PLAINTEXT_ONLY ? 'plaintext-only' : 'true');
|
|
2005
2178
|
el.setAttribute('role', 'textbox');
|
|
2006
2179
|
el.setAttribute('aria-multiline', 'true');
|
|
2007
2180
|
if (minimal) el.setAttribute('data-text-edit-minimal', '');
|
|
@@ -2011,7 +2184,7 @@
|
|
|
2011
2184
|
: toMarkdown(el);
|
|
2012
2185
|
const write = (v) => {
|
|
2013
2186
|
if (mode === 'plain') el.textContent = v == null ? '' : String(v);
|
|
2014
|
-
else el.innerHTML = mode === 'html' ? sanitize(v, true, true) :
|
|
2187
|
+
else el.innerHTML = mode === 'html' ? sanitize(v, true, true) : markdownHtml(v);
|
|
2015
2188
|
if (!el.childNodes.length) el.innerHTML = '<p><br></p>';
|
|
2016
2189
|
normalizeLists(el);
|
|
2017
2190
|
if (el.querySelector('table')) normalizeTables(el);
|
|
@@ -2098,7 +2271,7 @@
|
|
|
2098
2271
|
if (a && el.contains(a)) e.preventDefault();
|
|
2099
2272
|
});
|
|
2100
2273
|
|
|
2101
|
-
el.addEventListener('focusin', () => { if (lastFocused && lastFocused !== el) release(lastFocused); lastFocused = el; el.setAttribute('data-text-edit-focused', ''); sync(); });
|
|
2274
|
+
el.addEventListener('focusin', () => { if (lastFocused && lastFocused !== el) release(lastFocused); lastFocused = el; el.setAttribute('data-text-edit-focused', ''); saveRange(el); sync(); });
|
|
2102
2275
|
// Focus gone somewhere that is neither this area nor one of its controls.
|
|
2103
2276
|
el.addEventListener('focusout', (e) => {
|
|
2104
2277
|
if (keeps(el, e.relatedTarget || pointerTarget)) return;
|
|
@@ -2106,10 +2279,18 @@
|
|
|
2106
2279
|
sync();
|
|
2107
2280
|
});
|
|
2108
2281
|
el.addEventListener('input', (e) => {
|
|
2109
|
-
|
|
2110
|
-
if (!writing && e.data && !e.isComposing
|
|
2282
|
+
// Mid-composition the IME owns the text; an armed mark lands on compositionend.
|
|
2283
|
+
if (!writing && e.data && !e.isComposing) {
|
|
2284
|
+
if (applyPending(el, e.data)) commitValue();
|
|
2285
|
+
if (leaveMark(el, e.data)) commitValue();
|
|
2286
|
+
if (autoformat && convertInlineAtCaret(el)) commitValue();
|
|
2287
|
+
}
|
|
2288
|
+
if (!writing) recordSoon(el); // before sync, so Undo lights up now
|
|
2111
2289
|
settle(); sync();
|
|
2112
2290
|
});
|
|
2291
|
+
el.addEventListener('compositionend', (e) => {
|
|
2292
|
+
if (!writing && e.data && applyPending(el, e.data)) { commitValue(); sync(); }
|
|
2293
|
+
});
|
|
2113
2294
|
el.addEventListener('change', e => {
|
|
2114
2295
|
if (e.target.type !== 'checkbox') return;
|
|
2115
2296
|
// Clicking sets the property; only the attribute survives serialization.
|
|
@@ -2120,13 +2301,26 @@
|
|
|
2120
2301
|
|
|
2121
2302
|
// A finished line resolves to what its markdown described, unless .literal.
|
|
2122
2303
|
const autoformat = mode !== 'plain' && !modifiers.includes('literal');
|
|
2123
|
-
el.addEventListener('input', () => {
|
|
2124
|
-
if (writing) return;
|
|
2125
|
-
recordSoon(el);
|
|
2304
|
+
el.addEventListener('input', (e) => {
|
|
2305
|
+
if (writing || e.isComposing) return;
|
|
2126
2306
|
if (ancestor('li')) normalizeLists(el); // Backspace reshapes lists too
|
|
2127
2307
|
});
|
|
2128
2308
|
|
|
2309
|
+
// Native undo (Edit menu, context menu, shake) would desync the plugin's stack.
|
|
2310
|
+
el.addEventListener('beforeinput', (e) => {
|
|
2311
|
+
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
|
|
2312
|
+
e.preventDefault();
|
|
2313
|
+
run(el, e.inputType === 'historyUndo' ? 'undo' : 'redo');
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
if (mode === 'plain' && /^format/.test(e.inputType || '')) e.preventDefault();
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
el.addEventListener('pointerdown', () => { el._exit = null; });
|
|
2320
|
+
|
|
2129
2321
|
el.addEventListener('keydown', (e) => {
|
|
2322
|
+
if (e.isComposing || e.keyCode === 229) return; // the IME's keystroke
|
|
2323
|
+
if ((e.key || '').length > 1 && !/^(Shift|Alt|Control|Meta|CapsLock)$/.test(e.key)) el._exit = null;
|
|
2130
2324
|
// Tab indents rather than leaving the field, the way every editor behaves.
|
|
2131
2325
|
// Escape is the way out, so keyboard users are never trapped.
|
|
2132
2326
|
if (e.key === 'Enter' && mode !== 'plain') {
|
|
@@ -2179,21 +2373,22 @@
|
|
|
2179
2373
|
// now (including our own docs) — links go through an `a` control.
|
|
2180
2374
|
if (!(e.metaKey || e.ctrlKey)) return;
|
|
2181
2375
|
const id = { b: 'strong', i: 'em', u: 'u' }[e.key.toLowerCase()];
|
|
2182
|
-
if (!id
|
|
2376
|
+
if (!id) return;
|
|
2377
|
+
// Never the browser's own formatting: it would look applied and not be stored.
|
|
2183
2378
|
e.preventDefault();
|
|
2184
|
-
run(el, id);
|
|
2379
|
+
if (allows(el, id)) run(el, id);
|
|
2185
2380
|
});
|
|
2186
2381
|
|
|
2187
2382
|
// Paste as our own subset — never the source app's markup.
|
|
2188
2383
|
el.addEventListener('paste', (e) => {
|
|
2189
2384
|
e.preventDefault();
|
|
2190
2385
|
const dt = e.clipboardData; if (!dt) return;
|
|
2386
|
+
if (mode === 'plain') return insertPlain(el, dt.getData('text/plain'));
|
|
2191
2387
|
const html = dt.getData('text/html');
|
|
2192
2388
|
// No keepStyle: a paste brings the text and its marks, never the source
|
|
2193
2389
|
// application's fonts and colours.
|
|
2194
|
-
insertHTML(mode === '
|
|
2195
|
-
:
|
|
2196
|
-
: fromMarkdown(dt.getData('text/plain').replace(/([^\n])\n(?!\n)/g, '$1 \n')));
|
|
2390
|
+
insertHTML(html ? sanitize(mode === 'markdown' || minimal ? flattenTables(html, !minimal) : html, !minimal)
|
|
2391
|
+
: markdownHtml(dt.getData('text/plain').replace(/([^\n])\n(?!\n)/g, '$1 \n')));
|
|
2197
2392
|
});
|
|
2198
2393
|
|
|
2199
2394
|
const api = {
|
|
@@ -2202,7 +2397,9 @@
|
|
|
2202
2397
|
write(v);
|
|
2203
2398
|
el._history.stack = [{ html: el.innerHTML, caret: 0 }]; el._history.at = 0;
|
|
2204
2399
|
settle();
|
|
2400
|
+
el._history.restoring = true;
|
|
2205
2401
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2402
|
+
el._history.restoring = false;
|
|
2206
2403
|
},
|
|
2207
2404
|
get page() { return pageStyle(el); },
|
|
2208
2405
|
set page(v) { setPageStyle(el, v); sync(); },
|
|
@@ -2213,7 +2410,7 @@
|
|
|
2213
2410
|
run: (id, arg) => run(el, id, arg),
|
|
2214
2411
|
active: (id) => allows(el, id) && COMMANDS[id].active(undefined, el),
|
|
2215
2412
|
can: (id) => allows(el, id),
|
|
2216
|
-
selectAll: () => { el.focus(); selectNode(el); sync(); },
|
|
2413
|
+
selectAll: () => { el.focus(); selectNode(el); saveRange(el); sync(); },
|
|
2217
2414
|
markdown: () => toMarkdown(el),
|
|
2218
2415
|
html: () => sanitize(el.innerHTML, true, true)
|
|
2219
2416
|
};
|
|
@@ -2230,5 +2427,5 @@
|
|
|
2230
2427
|
document.addEventListener('alpine:init', init);
|
|
2231
2428
|
if (window.Alpine && Alpine.directive) init();
|
|
2232
2429
|
|
|
2233
|
-
window.ManifestTextEdit = { toMarkdown, fromMarkdown, sanitize, commands: () => Object.keys(COMMANDS) };
|
|
2430
|
+
window.ManifestTextEdit = { toMarkdown, fromMarkdown: markdownHtml, sanitize, commands: () => Object.keys(COMMANDS) };
|
|
2234
2431
|
})();
|